@everystack/cli 0.2.31 → 0.2.34

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.31",
3
+ "version": "0.2.34",
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",
@@ -15,7 +15,7 @@ import * as readline from 'node:readline';
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import * as os from 'node:os';
18
- import { resolveConfig, type CliConfig } from '../config.js';
18
+ import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
19
19
  import { invokeAction } from '../aws.js';
20
20
  import { success, fail, info } from '../output.js';
21
21
  import { formatResult } from '../utils/table.js';
@@ -189,7 +189,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
189
189
  process.exit(1);
190
190
  }
191
191
 
192
- success(`Connected to ${stage} (${config.region}) — ${config.apiFunctionName}`);
192
+ success(`Connected to ${stage} (${config.region}) — ${opsFunction(config)}`);
193
193
  if (sandbox) {
194
194
  info('Sandbox mode: all changes will be rolled back after each expression.');
195
195
  }
@@ -219,7 +219,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
219
219
  if (cachedMeta) return cachedMeta;
220
220
  cachedMeta = await invokeAction(
221
221
  config.region,
222
- config.apiFunctionName,
222
+ opsFunction(config),
223
223
  'console:meta',
224
224
  {},
225
225
  ) as ConsoleMeta;
@@ -342,7 +342,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
342
342
 
343
343
  const loginResult: any = await invokeAction(
344
344
  config.region,
345
- config.apiFunctionName,
345
+ opsFunction(config),
346
346
  'console',
347
347
  loginPayload,
348
348
  );
@@ -423,7 +423,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
423
423
 
424
424
  const result: any = await invokeAction(
425
425
  config.region,
426
- config.apiFunctionName,
426
+ opsFunction(config),
427
427
  'console',
428
428
  payload,
429
429
  );
@@ -5,9 +5,9 @@
5
5
  * No HTTP endpoints, no shared secrets — IAM is the authorization layer.
6
6
  */
7
7
 
8
- import { resolveConfig } from '../config.js';
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...');
@@ -19,13 +19,13 @@ export async function dbMigrateCommand(flags: Record<string, string>): Promise<v
19
19
  process.exit(1);
20
20
  }
21
21
 
22
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
22
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
23
23
  step('Running migrations via Lambda invoke...');
24
24
 
25
25
  try {
26
26
  const result: any = await invokeAction(
27
27
  config.region,
28
- config.apiFunctionName,
28
+ opsFunction(config),
29
29
  'migrate',
30
30
  {},
31
31
  );
@@ -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.');
@@ -51,13 +56,13 @@ export async function dbSeedCommand(flags: Record<string, string>): Promise<void
51
56
  process.exit(1);
52
57
  }
53
58
 
54
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
59
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
55
60
  step('Running seed via Lambda invoke...');
56
61
 
57
62
  try {
58
63
  const result: any = await invokeAction(
59
64
  config.region,
60
- config.apiFunctionName,
65
+ opsFunction(config),
61
66
  'seed',
62
67
  {},
63
68
  );
@@ -87,14 +92,14 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
87
92
  process.exit(1);
88
93
  }
89
94
 
90
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
95
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
91
96
  info('This will DROP every schema (auth, dist, logs, ops, public) and re-run migrations.');
92
97
  step('Resetting database via Lambda invoke...');
93
98
 
94
99
  try {
95
100
  const result: any = await invokeAction(
96
101
  config.region,
97
- config.apiFunctionName,
102
+ opsFunction(config),
98
103
  'db:reset',
99
104
  {},
100
105
  );
@@ -120,7 +125,7 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
120
125
  process.exit(1);
121
126
  }
122
127
 
123
- info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
128
+ info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
124
129
 
125
130
  // If -c flag is provided, execute SQL via Lambda and return results
126
131
  if (flags.c) {
@@ -128,7 +133,7 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
128
133
  try {
129
134
  const result: any = await invokeAction(
130
135
  config.region,
131
- config.apiFunctionName,
136
+ opsFunction(config),
132
137
  'db:query',
133
138
  { sql: flags.c },
134
139
  );
@@ -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
+ }
package/src/cli/config.ts CHANGED
@@ -15,6 +15,8 @@ export interface CliConfig {
15
15
  baseUrl: string;
16
16
  region: string;
17
17
  apiFunctionName: string;
18
+ /** Dedicated operator function (db/console actions). Falls back to apiFunctionName. */
19
+ opsFunctionName?: string;
18
20
  imageFunctionName?: string;
19
21
  workerFunctionName?: string;
20
22
  updatesBucket: string;
@@ -24,11 +26,17 @@ export interface CliConfig {
24
26
  distributionId?: string;
25
27
  }
26
28
 
29
+ /** Operator invoke target — dedicated ops function when deployed, else the API function. */
30
+ export function opsFunction(config: CliConfig): string {
31
+ return config.opsFunctionName ?? config.apiFunctionName;
32
+ }
33
+
27
34
  interface SstOutputs {
28
35
  routerUrl?: string;
29
36
  apiUrl?: string;
30
37
  imageUrl?: string;
31
38
  apiFunctionName?: string;
39
+ opsFunctionName?: string;
32
40
  imageFunctionName?: string;
33
41
  workerFunctionName?: string;
34
42
  updatesBucket?: string;
@@ -99,6 +107,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
99
107
  baseUrl: outputs.routerUrl,
100
108
  region,
101
109
  apiFunctionName: outputs.apiFunctionName,
110
+ opsFunctionName: outputs.opsFunctionName,
102
111
  imageFunctionName: outputs.imageFunctionName,
103
112
  workerFunctionName: outputs.workerFunctionName,
104
113
  updatesBucket: outputs.updatesBucket,
@@ -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
  }
@@ -19,6 +19,7 @@ interface DiscoveredConfig {
19
19
  baseUrl: string;
20
20
  region: string;
21
21
  apiFunctionName: string;
22
+ opsFunctionName?: string;
22
23
  imageFunctionName?: string;
23
24
  workerFunctionName?: string;
24
25
  updatesBucket: string;
@@ -261,23 +262,31 @@ export async function discoverConfig(
261
262
  const prefix = `${appName}-${stage}-`;
262
263
 
263
264
  // Run CF function + Lambda + S3 discovery in parallel
264
- const [cfResult, lambdaResult, bucketsResult, imageFn, workerFn] = await Promise.all([
265
+ const [cfResult, lambdaResult, bucketsResult, opsFn, imageFn, workerFn] = await Promise.all([
265
266
  discoverCfFunction(region, prefix),
266
267
  discoverLambda(region, prefix),
267
268
  discoverBuckets(region, prefix),
269
+ discoverFunction(region, prefix, 'OpsFunction').catch(() => undefined),
268
270
  discoverFunction(region, prefix, 'ImageFunction').catch(() => undefined),
269
271
  discoverFunction(region, prefix, 'WorkerFunction')
270
272
  .then(fn => fn || discoverFunction(region, prefix, 'JobsSubscriber'))
271
273
  .catch(() => undefined),
272
274
  ]);
273
275
 
274
- // Distribution discovery is best-effort (not critical for most CLI operations)
276
+ // Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
275
277
  let distributionId: string | undefined;
276
278
  try {
277
279
  const { discoverDistribution } = await import('./discover-distribution.js');
278
- 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
+ });
279
288
  } catch {
280
- // Non-fatal — only needed for cache:purge --invalidate
289
+ // Non-fatal
281
290
  }
282
291
 
283
292
  if (!bucketsResult.updatesBucket) {
@@ -311,6 +320,7 @@ export async function discoverConfig(
311
320
  baseUrl: lambdaResult.routerUrl || '',
312
321
  region,
313
322
  apiFunctionName: lambdaResult.functionName,
323
+ opsFunctionName: opsFn,
314
324
  imageFunctionName: imageFn,
315
325
  workerFunctionName: workerFn,
316
326
  updatesBucket: bucketsResult.updatesBucket,
@@ -326,13 +336,14 @@ export async function discoverConfig(
326
336
  // ---------------------------------------------------------------------------
327
337
 
328
338
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
329
- const CACHE_VERSION = 2; // Bump when CachedOutputs schema changes to invalidate stale caches
339
+ const CACHE_VERSION = 4; // Bump when CachedOutputs schema changes to invalidate stale caches
330
340
 
331
341
  interface CachedOutputs {
332
342
  _cachedAt: number;
333
343
  _version?: number;
334
344
  routerUrl?: string;
335
345
  apiFunctionName?: string;
346
+ opsFunctionName?: string;
336
347
  imageFunctionName?: string;
337
348
  workerFunctionName?: string;
338
349
  updatesBucket?: string;
@@ -359,6 +370,7 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
359
370
  baseUrl: cached.routerUrl || '',
360
371
  region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
361
372
  apiFunctionName: cached.apiFunctionName,
373
+ opsFunctionName: cached.opsFunctionName,
362
374
  imageFunctionName: cached.imageFunctionName,
363
375
  workerFunctionName: cached.workerFunctionName,
364
376
  updatesBucket: cached.updatesBucket,
@@ -378,6 +390,7 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
378
390
  _version: CACHE_VERSION,
379
391
  routerUrl: config.baseUrl,
380
392
  apiFunctionName: config.apiFunctionName,
393
+ opsFunctionName: config.opsFunctionName,
381
394
  imageFunctionName: config.imageFunctionName,
382
395
  workerFunctionName: config.workerFunctionName,
383
396
  updatesBucket: config.updatesBucket,
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)
package/src/env.js CHANGED
@@ -120,14 +120,25 @@ function filterEnv(surface, config) {
120
120
  * way to deliver env values to web client code is via EXPO_PUBLIC_* vars,
121
121
  * which Metro inlines as literal strings at compile time.
122
122
  *
123
- * Sets EXPO_PUBLIC_* for ALL filtered keys so consumers can use either
124
- * delivery path (extra on native, EXPO_PUBLIC_* on web).
123
+ * When `webKeys` is provided, only those keys get EXPO_PUBLIC_* vars.
124
+ * The inline path exists solely because web has no `extra` so it must
125
+ * carry only web-tier values. Mobile-tier keys are delivered via `extra`
126
+ * on native and must never reach the Metro inline path: a mis-surfaced
127
+ * web export would otherwise bake them into public JS.
125
128
  */
126
- function setExpoPublicVars(filtered) {
129
+ function setExpoPublicVars(filtered, webKeys) {
130
+ const allowed = webKeys ? new Set(webKeys) : null;
127
131
  for (const [key, value] of Object.entries(filtered)) {
132
+ if (allowed && !allowed.has(key))
133
+ continue;
128
134
  process.env[`EXPO_PUBLIC_${key}`] = value;
129
135
  }
130
136
  }
137
+ /** Stages where the permissive `local` tier set is acceptable. */
138
+ const NON_DEPLOYABLE_STAGES = new Set(['', 'development', 'dev', 'local', 'test']);
139
+ function isDeployableStage(stage) {
140
+ return !NON_DEPLOYABLE_STAGES.has((stage ?? '').toLowerCase());
141
+ }
131
142
  /**
132
143
  * Load and filter environment variables for the current build surface.
133
144
  *
@@ -148,7 +159,23 @@ function setExpoPublicVars(filtered) {
148
159
  function load(options) {
149
160
  const config = loadEnvConfig(options?.path);
150
161
  const surface = detectSurface();
162
+ // Fail closed: surface fell through to `local` (the most permissive tier
163
+ // set) while ENVIRONMENT names a deployable stage. Mixed signals mean a
164
+ // misconfigured build — refuse to bundle rather than risk shipping the
165
+ // mobile tier in a web export. EVERYSTACK_SURFACE=local is the explicit
166
+ // escape hatch for debugging.
167
+ if (surface === 'local' && isDeployableStage(process.env.ENVIRONMENT)) {
168
+ if (process.env.EVERYSTACK_SURFACE === 'local') {
169
+ console.warn(`[everystack/env] ENVIRONMENT=${process.env.ENVIRONMENT} but EVERYSTACK_SURFACE=local — `
170
+ + 'bundling with the local tier set (mobile keys included). Debug builds only.');
171
+ }
172
+ else {
173
+ throw new Error(`[everystack/env] Surface resolved to 'local' but ENVIRONMENT=${process.env.ENVIRONMENT}. `
174
+ + "Deployable builds must run under 'everystack update' (EVERYSTACK_UPDATE) or EAS (EAS_BUILD). "
175
+ + 'Refusing to bundle with the local tier set. Set EVERYSTACK_SURFACE=local to override for debugging.');
176
+ }
177
+ }
151
178
  const filtered = filterEnv(surface, config);
152
- setExpoPublicVars(filtered);
179
+ setExpoPublicVars(filtered, config.web ?? []);
153
180
  return { extra: filtered };
154
181
  }
package/src/env.ts CHANGED
@@ -148,15 +148,30 @@ export function filterEnv(surface: Surface, config: EnvConfig): Record<string, s
148
148
  * way to deliver env values to web client code is via EXPO_PUBLIC_* vars,
149
149
  * which Metro inlines as literal strings at compile time.
150
150
  *
151
- * Sets EXPO_PUBLIC_* for ALL filtered keys so consumers can use either
152
- * delivery path (extra on native, EXPO_PUBLIC_* on web).
151
+ * When `webKeys` is provided, only those keys get EXPO_PUBLIC_* vars.
152
+ * The inline path exists solely because web has no `extra` so it must
153
+ * carry only web-tier values. Mobile-tier keys are delivered via `extra`
154
+ * on native and must never reach the Metro inline path: a mis-surfaced
155
+ * web export would otherwise bake them into public JS.
153
156
  */
154
- export function setExpoPublicVars(filtered: Record<string, string>): void {
157
+ export function setExpoPublicVars(
158
+ filtered: Record<string, string>,
159
+ webKeys?: string[]
160
+ ): void {
161
+ const allowed = webKeys ? new Set(webKeys) : null;
155
162
  for (const [key, value] of Object.entries(filtered)) {
163
+ if (allowed && !allowed.has(key)) continue;
156
164
  process.env[`EXPO_PUBLIC_${key}`] = value;
157
165
  }
158
166
  }
159
167
 
168
+ /** Stages where the permissive `local` tier set is acceptable. */
169
+ const NON_DEPLOYABLE_STAGES = new Set(['', 'development', 'dev', 'local', 'test']);
170
+
171
+ function isDeployableStage(stage: string | undefined): boolean {
172
+ return !NON_DEPLOYABLE_STAGES.has((stage ?? '').toLowerCase());
173
+ }
174
+
160
175
  /**
161
176
  * Load and filter environment variables for the current build surface.
162
177
  *
@@ -177,7 +192,28 @@ export function setExpoPublicVars(filtered: Record<string, string>): void {
177
192
  export function load(options?: { path?: string }): { extra: Record<string, string> } {
178
193
  const config = loadEnvConfig(options?.path);
179
194
  const surface = detectSurface();
195
+
196
+ // Fail closed: surface fell through to `local` (the most permissive tier
197
+ // set) while ENVIRONMENT names a deployable stage. Mixed signals mean a
198
+ // misconfigured build — refuse to bundle rather than risk shipping the
199
+ // mobile tier in a web export. EVERYSTACK_SURFACE=local is the explicit
200
+ // escape hatch for debugging.
201
+ if (surface === 'local' && isDeployableStage(process.env.ENVIRONMENT)) {
202
+ if (process.env.EVERYSTACK_SURFACE === 'local') {
203
+ console.warn(
204
+ `[everystack/env] ENVIRONMENT=${process.env.ENVIRONMENT} but EVERYSTACK_SURFACE=local — `
205
+ + 'bundling with the local tier set (mobile keys included). Debug builds only.'
206
+ );
207
+ } else {
208
+ throw new Error(
209
+ `[everystack/env] Surface resolved to 'local' but ENVIRONMENT=${process.env.ENVIRONMENT}. `
210
+ + "Deployable builds must run under 'everystack update' (EVERYSTACK_UPDATE) or EAS (EAS_BUILD). "
211
+ + 'Refusing to bundle with the local tier set. Set EVERYSTACK_SURFACE=local to override for debugging.'
212
+ );
213
+ }
214
+ }
215
+
180
216
  const filtered = filterEnv(surface, config);
181
- setExpoPublicVars(filtered);
217
+ setExpoPublicVars(filtered, config.web ?? []);
182
218
  return { extra: filtered };
183
219
  }