@everystack/cli 0.2.33 → 0.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -60,6 +60,7 @@
60
60
  },
61
61
  "peerDependencies": {
62
62
  "@everystack/server": ">=0.1.0",
63
+ "@aws-sdk/client-cloudwatch": "3.1053.0",
63
64
  "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
64
65
  "@aws-sdk/client-lambda": "3.1053.0",
65
66
  "@aws-sdk/client-s3": "3.1053.0",
@@ -74,6 +75,9 @@
74
75
  "@everystack/server": {
75
76
  "optional": true
76
77
  },
78
+ "@aws-sdk/client-cloudwatch": {
79
+ "optional": true
80
+ },
77
81
  "@aws-sdk/client-cloudwatch-logs": {
78
82
  "optional": true
79
83
  },
@@ -100,6 +104,7 @@
100
104
  }
101
105
  },
102
106
  "devDependencies": {
107
+ "@aws-sdk/client-cloudwatch": "3.1053.0",
103
108
  "@aws-sdk/client-cloudwatch-logs": "3.1053.0",
104
109
  "@aws-sdk/client-lambda": "3.1053.0",
105
110
  "@aws-sdk/client-s3": "3.1053.0",
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { resolveConfig, opsFunction } from '../config.js';
9
9
  import { invokeAction } from '../aws.js';
10
- import { step, success, fail, info } from '../output.js';
10
+ import { step, success, fail, info, warn } from '../output.js';
11
11
 
12
12
  export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
13
13
  step('Resolving deployed config...');
@@ -34,6 +34,11 @@ export async function dbMigrateCommand(flags: Record<string, string>): Promise<v
34
34
  process.exit(1);
35
35
  }
36
36
  success(result?.message || 'Migrations applied');
37
+ // Journal drift warnings — a migration file the journal doesn't list
38
+ // will silently never apply. Never swallow these.
39
+ for (const warning of result?.warnings ?? []) {
40
+ warn(warning);
41
+ }
37
42
  } catch (err: any) {
38
43
  fail(`Migration failed: ${err.message}`);
39
44
  info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
@@ -0,0 +1,262 @@
1
+ /**
2
+ * `everystack status` — platform health from the terminal.
3
+ *
4
+ * Three sections, each independent:
5
+ * CDN — CloudFront metrics from CloudWatch (requests, hit rate, error rates)
6
+ * Lambda — per-function invocations / errors / p95 duration
7
+ * Platform — per-origin rollup summary via the ops Lambda's platform:health
8
+ * action (same numbers as /admin/health)
9
+ *
10
+ * On a V1 stack (no database) the platform section degrades to a note —
11
+ * CDN and Lambda metrics still work with nothing but IAM credentials.
12
+ */
13
+
14
+ import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
15
+ import { invokeAction } from '../aws.js';
16
+ import { fail, info, step } from '../output.js';
17
+
18
+ export interface MetricSpec {
19
+ namespace: string;
20
+ metricName: string;
21
+ stat: string;
22
+ dimensions: Record<string, string>;
23
+ /** CloudFront metrics live in us-east-1 regardless of stack region. */
24
+ region?: string;
25
+ }
26
+
27
+ export interface StatusDeps {
28
+ /** Fetch a single aggregated CloudWatch datapoint for the window. Null = no data. */
29
+ getMetric: (region: string, spec: MetricSpec, hours: number) => Promise<number | null>;
30
+ invoke: typeof invokeAction;
31
+ }
32
+
33
+ interface OriginSummaryLite {
34
+ source: string;
35
+ requestCount: number;
36
+ hitRatio: number | null;
37
+ availability: number | null;
38
+ status5xx: number;
39
+ }
40
+
41
+ export interface StatusData {
42
+ hours: number;
43
+ cdn: {
44
+ distributionId: string;
45
+ requests: number | null;
46
+ cacheHitRate: number | null;
47
+ rate4xx: number | null;
48
+ rate5xx: number | null;
49
+ } | null;
50
+ lambdas: Array<{
51
+ label: string;
52
+ functionName: string;
53
+ invocations: number | null;
54
+ errors: number | null;
55
+ p95Ms: number | null;
56
+ }>;
57
+ platform: {
58
+ perOrigin: OriginSummaryLite[];
59
+ totals: { requestCount: number; availability: number | null; hitRatio: number | null; status5xx: number };
60
+ percentiles: {
61
+ p50: number | null; p95: number | null; p99: number | null;
62
+ under100ms: number | null; under300ms: number | null; under1s: number | null;
63
+ };
64
+ } | null;
65
+ platformError: string | null;
66
+ }
67
+
68
+ async function defaultGetMetric(
69
+ region: string,
70
+ spec: MetricSpec,
71
+ hours: number,
72
+ ): Promise<number | null> {
73
+ const { CloudWatchClient, GetMetricDataCommand } = await import('@aws-sdk/client-cloudwatch');
74
+ const client = new CloudWatchClient({ region: spec.region ?? region });
75
+ const end = new Date();
76
+ const start = new Date(end.getTime() - hours * 3600_000);
77
+ const response = await client.send(new GetMetricDataCommand({
78
+ StartTime: start,
79
+ EndTime: end,
80
+ MetricDataQueries: [{
81
+ Id: 'm0',
82
+ MetricStat: {
83
+ Metric: {
84
+ Namespace: spec.namespace,
85
+ MetricName: spec.metricName,
86
+ Dimensions: Object.entries(spec.dimensions).map(([Name, Value]) => ({ Name, Value })),
87
+ },
88
+ Period: hours * 3600,
89
+ Stat: spec.stat,
90
+ },
91
+ }],
92
+ }));
93
+ const values = response.MetricDataResults?.[0]?.Values ?? [];
94
+ if (values.length === 0) return null;
95
+ return values.reduce((a, b) => a + b, 0) / (spec.stat === 'Sum' ? 1 : values.length);
96
+ }
97
+
98
+ export async function gatherStatus(
99
+ config: CliConfig,
100
+ hours: number,
101
+ deps: StatusDeps,
102
+ ): Promise<StatusData> {
103
+ const data: StatusData = {
104
+ hours,
105
+ cdn: null,
106
+ lambdas: [],
107
+ platform: null,
108
+ platformError: null,
109
+ };
110
+
111
+ const tasks: Promise<void>[] = [];
112
+
113
+ if (config.distributionId) {
114
+ const cfDims = { DistributionId: config.distributionId, Region: 'Global' };
115
+ const cf = (metricName: string, stat: string) =>
116
+ deps.getMetric(config.region, {
117
+ namespace: 'AWS/CloudFront', metricName, stat, dimensions: cfDims, region: 'us-east-1',
118
+ }, hours).catch(() => null);
119
+ tasks.push(
120
+ Promise.all([
121
+ cf('Requests', 'Sum'),
122
+ cf('CacheHitRate', 'Average'),
123
+ cf('4xxErrorRate', 'Average'),
124
+ cf('5xxErrorRate', 'Average'),
125
+ ]).then(([requests, cacheHitRate, rate4xx, rate5xx]) => {
126
+ data.cdn = { distributionId: config.distributionId!, requests, cacheHitRate, rate4xx, rate5xx };
127
+ }),
128
+ );
129
+ }
130
+
131
+ const lambdaTargets = [
132
+ { label: 'api', functionName: config.apiFunctionName },
133
+ { label: 'image', functionName: config.imageFunctionName },
134
+ { label: 'worker', functionName: config.workerFunctionName },
135
+ ].filter((t): t is { label: string; functionName: string } => !!t.functionName);
136
+
137
+ for (const target of lambdaTargets) {
138
+ const dims = { FunctionName: target.functionName };
139
+ const lm = (metricName: string, stat: string) =>
140
+ deps.getMetric(config.region, {
141
+ namespace: 'AWS/Lambda', metricName, stat, dimensions: dims,
142
+ }, hours).catch(() => null);
143
+ tasks.push(
144
+ Promise.all([
145
+ lm('Invocations', 'Sum'),
146
+ lm('Errors', 'Sum'),
147
+ lm('Duration', 'p95'),
148
+ ]).then(([invocations, errors, p95Ms]) => {
149
+ data.lambdas.push({ ...target, invocations, errors, p95Ms });
150
+ }),
151
+ );
152
+ }
153
+
154
+ tasks.push(
155
+ deps
156
+ .invoke(config.region, opsFunction(config), 'platform:health', { hours })
157
+ .then((result) => {
158
+ const payload = result as StatusData['platform'] & { error?: string };
159
+ if (payload && !payload.error && payload.totals) {
160
+ data.platform = payload;
161
+ } else {
162
+ data.platformError = payload?.error ?? 'empty response';
163
+ }
164
+ })
165
+ .catch((err: Error) => {
166
+ data.platformError = err.message;
167
+ }),
168
+ );
169
+
170
+ await Promise.all(tasks);
171
+ // Keep lambda section in declaration order regardless of resolution order
172
+ data.lambdas.sort(
173
+ (a, b) =>
174
+ lambdaTargets.findIndex((t) => t.label === a.label) -
175
+ lambdaTargets.findIndex((t) => t.label === b.label),
176
+ );
177
+ return data;
178
+ }
179
+
180
+ const DASH = '—';
181
+
182
+ function num(value: number | null | undefined): string {
183
+ if (value === null || value === undefined) return DASH;
184
+ return Math.round(value).toLocaleString();
185
+ }
186
+
187
+ function pct(value: number | null | undefined, digits = 0): string {
188
+ if (value === null || value === undefined) return DASH;
189
+ return `${(value * 100).toFixed(digits)}%`;
190
+ }
191
+
192
+ /** CloudWatch rate metrics arrive as 0–100, ratios from rollups as 0–1. */
193
+ function rate(value: number | null | undefined, digits = 1): string {
194
+ if (value === null || value === undefined) return DASH;
195
+ return `${value.toFixed(digits)}%`;
196
+ }
197
+
198
+ function ms(value: number | null | undefined): string {
199
+ if (value === null || value === undefined) return DASH;
200
+ return `${Math.round(value)}ms`;
201
+ }
202
+
203
+ export function formatStatus(data: StatusData): string[] {
204
+ const lines: string[] = [];
205
+
206
+ if (data.cdn) {
207
+ lines.push(`CDN ${data.cdn.distributionId} (last ${data.hours}h)`);
208
+ lines.push(
209
+ ` requests ${num(data.cdn.requests)} cache hit ${rate(data.cdn.cacheHitRate)} 4xx ${rate(data.cdn.rate4xx, 2)} 5xx ${rate(data.cdn.rate5xx, 2)}`,
210
+ );
211
+ }
212
+
213
+ if (data.lambdas.length > 0) {
214
+ lines.push('Lambda');
215
+ for (const fn of data.lambdas) {
216
+ lines.push(
217
+ ` ${fn.label.padEnd(7)} invocations ${num(fn.invocations)} errors ${num(fn.errors)} p95 ${ms(fn.p95Ms)}`,
218
+ );
219
+ }
220
+ }
221
+
222
+ if (data.platform) {
223
+ const p = data.platform;
224
+ lines.push(`Platform (rollups, last ${data.hours}h)`);
225
+ for (const origin of p.perOrigin) {
226
+ lines.push(
227
+ ` ${origin.source.padEnd(7)} ${num(origin.requestCount).padStart(8)} reqs hit ${pct(origin.hitRatio)} availability ${pct(origin.availability, 2)}`,
228
+ );
229
+ }
230
+ lines.push(
231
+ ` p50 ${ms(p.percentiles.p50)} · p95 ${ms(p.percentiles.p95)} · p99 ${ms(p.percentiles.p99)} · ${pct(p.percentiles.under300ms, 1)} under 300ms · availability ${pct(p.totals.availability, 2)}`,
232
+ );
233
+ } else {
234
+ lines.push(`Platform: no database rollups (V1 stack or pipeline not deployed) — ${data.platformError ?? ''}`);
235
+ }
236
+
237
+ return lines;
238
+ }
239
+
240
+ export async function statusCommand(
241
+ flags: Record<string, string>,
242
+ deps?: Partial<StatusDeps>,
243
+ ): Promise<void> {
244
+ step('Resolving deployed config...');
245
+ let config: CliConfig;
246
+ try {
247
+ config = await resolveConfig(flags.stage);
248
+ } catch (err: unknown) {
249
+ fail((err as Error).message);
250
+ process.exit(1);
251
+ }
252
+
253
+ const hours = Number(flags.hours) > 0 ? Number(flags.hours) : 1;
254
+ const data = await gatherStatus(config, hours, {
255
+ getMetric: deps?.getMetric ?? defaultGetMetric,
256
+ invoke: deps?.invoke ?? invokeAction,
257
+ });
258
+
259
+ for (const line of formatStatus(data)) {
260
+ info(line);
261
+ }
262
+ }
@@ -430,10 +430,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
430
430
  info('Add `mediaBucket: media.name` to the return block in sst.config.ts and redeploy.');
431
431
  } else {
432
432
  step('Syncing media assets...');
433
- // @ts-ignore minimatch lacks type declarations in this project
434
- const minimatchMod = await import('minimatch');
435
- const minimatch: (p: string, pattern: string, opts?: { matchBase?: boolean }) => boolean =
436
- minimatchMod.minimatch || minimatchMod.default || minimatchMod;
433
+ const { minimatch } = await import('minimatch');
437
434
  const MEDIA_CONCURRENCY = 10;
438
435
  let mediaUploaded = 0;
439
436
  let mediaSkipped = 0;
@@ -3,34 +3,92 @@
3
3
  *
4
4
  * Separated from discover.ts to avoid Jest ESM module resolution issues
5
5
  * with the @aws-sdk/client-cloudfront ListDistributions types.
6
+ *
7
+ * SST Routers do not put the app/stage in the distribution Comment, so
8
+ * matching runs in order of reliability:
9
+ * 1. DomainName / Aliases match the discovered router URL host
10
+ * 2. Comment contains the {app}-{stage}- prefix (legacy setups)
11
+ * 3. sst:app + sst:stage tags (authoritative, but one API call per candidate)
6
12
  */
7
13
 
14
+ export interface DistributionHints {
15
+ /** Host of the discovered router URL, e.g. d111abc.cloudfront.net or a custom domain. */
16
+ routerHost?: string;
17
+ appName?: string;
18
+ stage?: string;
19
+ }
20
+
21
+ interface DistributionSummaryLite {
22
+ Id?: string;
23
+ Comment?: string;
24
+ DomainName?: string;
25
+ ARN?: string;
26
+ Aliases?: { Items?: string[] };
27
+ }
28
+
29
+ /** Pure matcher for the cheap passes (domain, alias, legacy comment). Exported for tests. */
30
+ export function matchDistribution(
31
+ items: readonly DistributionSummaryLite[],
32
+ prefix: string,
33
+ routerHost?: string,
34
+ ): string | undefined {
35
+ const host = routerHost?.toLowerCase();
36
+ if (host) {
37
+ for (const dist of items) {
38
+ if (dist.DomainName?.toLowerCase() === host) return dist.Id;
39
+ if (dist.Aliases?.Items?.some((alias) => alias.toLowerCase() === host)) return dist.Id;
40
+ }
41
+ }
42
+ const needle = prefix.toLowerCase();
43
+ for (const dist of items) {
44
+ if ((dist.Comment || '').toLowerCase().includes(needle)) return dist.Id;
45
+ }
46
+ return undefined;
47
+ }
48
+
8
49
  /**
9
- * Find the CloudFront distribution for a stage by matching the Comment field.
10
- * SST sets the Comment to include the app name and stage.
50
+ * Find the CloudFront distribution for a stage.
11
51
  * Best-effort: returns undefined if not found.
12
52
  */
13
53
  export async function discoverDistribution(
14
54
  region: string,
15
55
  prefix: string,
56
+ hints: DistributionHints = {},
16
57
  ): Promise<string | undefined> {
17
- const { CloudFrontClient, ListDistributionsCommand } = await import('@aws-sdk/client-cloudfront');
58
+ const { CloudFrontClient, ListDistributionsCommand, ListTagsForResourceCommand } =
59
+ await import('@aws-sdk/client-cloudfront');
18
60
  const client = new CloudFrontClient({ region });
19
- const distNeedle = prefix.toLowerCase();
20
61
 
62
+ const items: DistributionSummaryLite[] = [];
21
63
  let marker: string | undefined;
22
64
  do {
23
65
  const res = await client.send(
24
66
  new ListDistributionsCommand({ Marker: marker, MaxItems: 50 }),
25
67
  );
26
- for (const dist of res.DistributionList?.Items || []) {
27
- const comment = (dist.Comment || '').toLowerCase();
28
- if (comment.includes(distNeedle)) {
29
- return dist.Id;
30
- }
31
- }
68
+ items.push(...((res.DistributionList?.Items || []) as DistributionSummaryLite[]));
32
69
  marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined;
33
70
  } while (marker);
34
71
 
72
+ const cheap = matchDistribution(items, prefix, hints.routerHost);
73
+ if (cheap) return cheap;
74
+
75
+ // Tag pass — authoritative but one call per distribution, so it runs last.
76
+ if (hints.appName && hints.stage) {
77
+ for (const dist of items) {
78
+ if (!dist.ARN) continue;
79
+ try {
80
+ const res = await client.send(new ListTagsForResourceCommand({ Resource: dist.ARN }));
81
+ const tags = new Map(
82
+ (res.Tags?.Items || []).map((t) => [t.Key, t.Value]),
83
+ );
84
+ if (tags.get('sst:app') === hints.appName && tags.get('sst:stage') === hints.stage) {
85
+ return dist.Id;
86
+ }
87
+ } catch {
88
+ // Tag read denied or transient — keep scanning
89
+ }
90
+ }
91
+ }
92
+
35
93
  return undefined;
36
94
  }
@@ -273,13 +273,20 @@ export async function discoverConfig(
273
273
  .catch(() => undefined),
274
274
  ]);
275
275
 
276
- // Distribution discovery is best-effort (not critical for most CLI operations)
276
+ // Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
277
277
  let distributionId: string | undefined;
278
278
  try {
279
279
  const { discoverDistribution } = await import('./discover-distribution.js');
280
- distributionId = await discoverDistribution(region, prefix);
280
+ const routerHost = lambdaResult.routerUrl
281
+ ? new URL(lambdaResult.routerUrl).host
282
+ : undefined;
283
+ distributionId = await discoverDistribution(region, prefix, {
284
+ routerHost,
285
+ appName,
286
+ stage,
287
+ });
281
288
  } catch {
282
- // Non-fatal — only needed for cache:purge --invalidate
289
+ // Non-fatal
283
290
  }
284
291
 
285
292
  if (!bucketsResult.updatesBucket) {
@@ -329,7 +336,7 @@ export async function discoverConfig(
329
336
  // ---------------------------------------------------------------------------
330
337
 
331
338
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
332
- const CACHE_VERSION = 3; // Bump when CachedOutputs schema changes to invalidate stale caches
339
+ const CACHE_VERSION = 4; // Bump when CachedOutputs schema changes to invalidate stale caches
333
340
 
334
341
  interface CachedOutputs {
335
342
  _cachedAt: number;
package/src/cli/index.ts CHANGED
@@ -9,6 +9,7 @@ import { branchesCommand } from './commands/branches.js';
9
9
  import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand } from './commands/db.js';
10
10
  import { consoleCommand } from './commands/console.js';
11
11
  import { cachePurgeCommand } from './commands/cache.js';
12
+ import { statusCommand } from './commands/status.js';
12
13
  import { diagCommand } from './commands/diag.js';
13
14
  import { analyzeSSRCommand } from './commands/analyze.js';
14
15
  import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
@@ -119,6 +120,9 @@ async function main() {
119
120
  case 'cache:purge':
120
121
  await cachePurgeCommand(flags);
121
122
  break;
123
+ case 'status':
124
+ await statusCommand(flags);
125
+ break;
122
126
  case 'diag': {
123
127
  // Positional URL: first arg after 'diag' that doesn't start with '--'
124
128
  const diagUrl = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
@@ -174,6 +178,7 @@ Usage:
174
178
  everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
175
179
  everystack db:psql [--stage <name>] -c <command> Execute SQL query via Lambda (private RDS)
176
180
  everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
181
+ everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
177
182
  everystack certs:generate [--output ./certs]
178
183
  everystack certs:configure [--input ./certs] [--keyid main]
179
184
  everystack cache:purge [--stage <name>] Bust all cached content (global epoch)