@everystack/cli 0.4.86 → 0.4.88

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/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.88] - 2026-09-21
8
+
9
+ ### Fixed
10
+
11
+ - cache:purge probes a site's own hostname, and takes --host
12
+
13
+ ## [0.4.87] - 2026-09-21
14
+
15
+ ### BREAKING CHANGES
16
+
17
+ - cache:purge --stage works without CDN_URL on the API Lambda; update --stage now refuses when no HOST_URL is found, export HOST_URL to override
18
+
19
+ ### Fixed
20
+
21
+ - the FK-orphan advisory doubled its own '!' on top of warn()'s
22
+ - db:provision detects PUBLIC's CREATE on schema public, revokes it under --confirm
23
+ - the db:provision pointer swallowed every other grantee's revoke line
24
+
7
25
  ## [0.4.86] - 2026-09-16
8
26
 
9
27
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.86",
3
+ "version": "0.4.88",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -182,7 +182,7 @@
182
182
  "react": "19.2.0",
183
183
  "ts-jest": "29.4.9",
184
184
  "zod": "3.25.67",
185
- "@everystack/server": "0.4.31"
185
+ "@everystack/server": "0.4.32"
186
186
  },
187
187
  "scripts": {
188
188
  "test": "jest",
@@ -8,7 +8,7 @@
8
8
  * the heavier `audit:lighthouse` stay separate commands (run them explicitly).
9
9
  */
10
10
 
11
- import { resolveConfig, opsFunction } from '../config';
11
+ import { resolveConfig, opsFunction, probeUrl, ambiguousSiteNote } from '../config';
12
12
  import { runBundleAudit, runBundleWeightAudit, reportBundleAudit } from './bundle';
13
13
  import { runUiAudit, reportUiAudit } from './ui-audit';
14
14
  import { checkRunbook } from './runbook';
@@ -28,11 +28,13 @@ export async function auditCommand(flags: Record<string, string>): Promise<void>
28
28
  fail(err.message);
29
29
  process.exit(1);
30
30
  }
31
- const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
31
+ const base = (flags.host || probeUrl(config)).replace(/\/+$/, '');
32
32
  if (!base) {
33
33
  fail('No router URL to audit. Pass --host or ensure the stage has a deployed URL.');
34
34
  process.exit(1);
35
35
  }
36
+ const ambiguous = flags.host ? undefined : ambiguousSiteNote(config);
37
+ if (ambiguous) info(ambiguous);
36
38
  info(`Region: ${config.region}, URL: ${base}`);
37
39
 
38
40
  let failures = 0;
@@ -48,7 +48,7 @@ import {
48
48
  DEFAULT_UI_BLOAT_BUDGET,
49
49
  type UiBloatBudget,
50
50
  } from '../ui-bloat';
51
- import { resolveConfig } from '../config';
51
+ import { resolveConfig, probeUrl, ambiguousSiteNote } from '../config';
52
52
  import { fail, info, step, success, warn } from '../output';
53
53
 
54
54
  // Expo Router web export puts client chunks under client/_expo/...; a plain
@@ -430,7 +430,10 @@ export async function bundleAuditCommand(
430
430
  let target = positional || flags.url;
431
431
  if (!target) {
432
432
  try {
433
- target = (await resolveConfig(flags.stage)).baseUrl;
433
+ const stageConfig = await resolveConfig(flags.stage);
434
+ target = probeUrl(stageConfig);
435
+ const ambiguous = ambiguousSiteNote(stageConfig);
436
+ if (ambiguous) info(ambiguous);
434
437
  } catch (err: any) {
435
438
  fail(err.message);
436
439
  info('Pass a URL positionally or use --stage <name>.');
@@ -9,10 +9,13 @@
9
9
  * Writes directly to CloudFront KVS — no Lambda invoke.
10
10
  */
11
11
 
12
- import { resolveConfig } from '../config';
12
+ import { resolveConfig, probeUrl } from '../config';
13
13
  import { putKvsKey, createInvalidation, getDistributionStatus, getFromS3 } from '../aws';
14
14
  import { step, success, warn, fail, info } from '../output';
15
15
 
16
+ /** One black-holed candidate must not stall the gate for the OS connect timeout. */
17
+ const PROBE_TIMEOUT_MS = 10_000;
18
+
16
19
  const VALID_ORIGINS = ['api', 'media', 'web'] as const;
17
20
  type CacheOrigin = typeof VALID_ORIGINS[number];
18
21
 
@@ -34,10 +37,28 @@ export async function cachePurgeCommand(flags: Record<string, string>): Promise<
34
37
 
35
38
  const force = flags.force === 'true';
36
39
 
40
+ // What the gate probes: --host wins, else every discovered candidate (aliases before the domain).
41
+ let candidates: string[];
42
+ if (flags.host !== undefined) {
43
+ // parseFlags gives a value-less --host the string 'true'; '' is --host= . Neither is a host.
44
+ const host = flags.host === 'true' || flags.host === '' ? undefined : normalizeHost(flags.host);
45
+ if (!host) {
46
+ fail(`--host "${flags.host}" is not a hostname or URL.`);
47
+ info('Pass the site\'s public address, for example --host https://www.example.com');
48
+ process.exit(1);
49
+ }
50
+ if (host.startsWith('http://')) {
51
+ warn('--host is plaintext http://: anyone on the path can forge the release header and pass the gate.');
52
+ }
53
+ candidates = [host];
54
+ } else {
55
+ candidates = config.siteUrls?.length ? config.siteUrls : [probeUrl(config)].filter(Boolean);
56
+ }
57
+
37
58
  // Safety gate: verify the origin is serving the expected release and the distribution is stable.
38
59
  if (!force) {
39
60
  const channel = flags.channel || flags.stage || 'production';
40
- const blocked = await preflight(config.region, config.baseUrl, config.updatesBucket, channel, config.distributionId);
61
+ const blocked = await preflight(config.region, candidates, config.updatesBucket, channel, config.distributionId);
41
62
  if (blocked) {
42
63
  fail(blocked);
43
64
  info('Pass --force to override this check.');
@@ -105,13 +126,24 @@ export async function cachePurgeCommand(flags: Record<string, string>): Promise<
105
126
  }
106
127
  }
107
128
 
129
+ /** `www.example.com` or `https://www.example.com/` to `https://www.example.com`; undefined if it is not a host. */
130
+ function normalizeHost(value: string): string | undefined {
131
+ const withScheme = /^https?:\/\//i.test(value) ? value : `https://${value}`;
132
+ try {
133
+ const u = new URL(withScheme);
134
+ return u.host && !/\s/.test(value) ? `${u.protocol}//${u.host}` : undefined;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
108
140
  /**
109
141
  * Pre-purge safety checks. Returns an error message if blocked, null if clear.
110
142
  * @internal Exported for testing.
111
143
  */
112
144
  export async function preflight(
113
145
  region: string,
114
- baseUrl: string,
146
+ siteUrl: string | readonly string[],
115
147
  updatesBucket: string,
116
148
  channel: string,
117
149
  distributionId?: string,
@@ -133,20 +165,55 @@ export async function preflight(
133
165
  return `Channel manifest for "${channel}" has no updateId.`;
134
166
  }
135
167
 
136
- // 2. Probe the origin for the serving release.
137
- try {
138
- const probe = `${baseUrl}?_probe=${Date.now()}`;
139
- const res = await fetch(probe, { redirect: 'follow' });
140
- const serving = res.headers.get('x-everystack-release');
141
- if (serving !== expectedUpdateId) {
168
+ // 2. Probe for the serving release. The gate passes when ANY candidate serves the expected
169
+ // release: a CloudFront Function can gate by Host, so the raw cloudfront.net address may answer
170
+ // 403 while the site's own hostname answers 200 with x-everystack-release.
171
+ const urls = (typeof siteUrl === 'string' ? [siteUrl] : [...siteUrl]).filter(Boolean);
172
+ if (urls.length === 0) {
173
+ return (
174
+ 'No site URL for this stage, so the origin cannot be probed. ' +
175
+ 'Pass --host <the site\'s public URL>, set CDN_URL on the API function and redeploy, or tag the distribution sst:app/sst:stage ' +
176
+ 'and re-run with --refresh. Or pass --force to skip this check.'
177
+ );
178
+ }
179
+ const outcomes: Array<{ url: string; serving?: string | null; status?: number; error?: string }> = [];
180
+ let served = false;
181
+ for (const url of urls) {
182
+ try {
183
+ const res = await fetch(`${url}?_probe=${Date.now()}`, { redirect: 'follow', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
184
+ const serving = res.headers.get('x-everystack-release');
185
+ if (serving === expectedUpdateId) {
186
+ served = true;
187
+ break;
188
+ }
189
+ outcomes.push({ url, serving, status: res.status });
190
+ } catch (err: any) {
191
+ outcomes.push({ url, error: err.message });
192
+ }
193
+ }
194
+ if (!served) {
195
+ const want = expectedUpdateId.slice(0, 12);
196
+ const stale = 'Purging now would expose stale containers to uncached traffic.';
197
+ if (outcomes.length === 1) {
198
+ const o = outcomes[0];
199
+ if (o.error !== undefined) return `Could not reach origin at ${o.url}: ${o.error}`;
142
200
  return (
143
- `Origin is serving release ${serving ? serving.slice(0, 12) + '...' : '(none)'} ` +
144
- `but the channel pointer is ${expectedUpdateId.slice(0, 12)}.... ` +
145
- `Purging now would expose stale containers to uncached traffic.`
201
+ `Origin is serving release ${o.serving ? o.serving.slice(0, 12) + '...' : '(none)'} ` +
202
+ `but the channel pointer is ${want}.... Probed ${o.url}. ${stale}` +
203
+ (o.serving ? '' : " If the site's public hostname differs, pass --host <url>.")
146
204
  );
147
205
  }
148
- } catch (err: any) {
149
- return `Could not reach origin at ${baseUrl}: ${err.message}`;
206
+ const tried = outcomes
207
+ .map((o) =>
208
+ o.error !== undefined
209
+ ? `${o.url} (unreachable: ${o.error})`
210
+ : `${o.url} (${o.serving ? 'release ' + o.serving.slice(0, 12) + '...' : `HTTP ${o.status}, no x-everystack-release header`})`,
211
+ )
212
+ .join(', ');
213
+ return (
214
+ `No probed URL serves release ${want}...: ${tried}. ${stale} ` +
215
+ "If the site's public hostname is not listed, pass --host <url>."
216
+ );
150
217
  }
151
218
 
152
219
  // 3. Check distribution status (if discoverable).
@@ -26,6 +26,8 @@ function resolveInstanceId(config: CliConfig, flags: Record<string, string>): st
26
26
  ' - If your DB is RDS: add `databaseInstanceId: database.id` to the outputs return block in\n' +
27
27
  ' sst.config.ts and redeploy (run db:snapshot from the app directory so .sst/outputs.json is\n' +
28
28
  ' readable), or pass --instance <id>.\n' +
29
+ ' - .sst/outputs.json is used only when it is THIS stage\'s deploy (its apiFunctionName names the\n' +
30
+ ' stage). If the last `sst deploy` was another stage, redeploy this one, or pass --instance <id>.\n' +
29
31
  ' - If your DB is not RDS (Aurora, a container, self-hosted): use `everystack db:backup` for a portable logical backup.',
30
32
  );
31
33
  process.exit(1);
@@ -1117,7 +1117,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1117
1117
  // The reader's read grants ride this same step, for the same reason ADOPT does: they can stall
1118
1118
  // on a lock, and nothing that can stall may sit between the password swap and the secret write.
1119
1119
  const readerRole = result.readerRole as string | undefined;
1120
- if (!declaredOwners.length && !declaredObjects.length && !readerRole) return;
1120
+ // NO early return on "nothing declared". Convergence also carries the schema "public" posture
1121
+ // check — a property of the DATABASE, not of the Models — and the brownfield app that declares
1122
+ // no owners is exactly the one most likely to carry the pre-PG15 PUBLIC CREATE grant. Gating
1123
+ // this on the declarations made the check unreachable for it.
1121
1124
  const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole, ...(readerRole ? { readerRole, searchPath } : {}) };
1122
1125
  let owned: any = {};
1123
1126
  try {
@@ -1177,6 +1180,24 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1177
1180
  // then fail on every table. Same non-zero exit as an un-converged owner.
1178
1181
  for (const r of reader.refusals) { warn(`Read-only grants: ${r}`); ownershipIncomplete = true; }
1179
1182
  }
1183
+ // The schema "public" posture. Reported only when there IS a gap: a PG15+ database (or one
1184
+ // already hardened) is the expected state and says nothing, exactly like a converged owner.
1185
+ // Absent entirely on a server that predates this check — also silent, never narrated as clean.
1186
+ const posture = owned.publicSchemaPosture;
1187
+ if (posture?.publicCanCreate) {
1188
+ if (posture.revoked) {
1189
+ // The SQL is spelled out rather than imported from @everystack/server: that package is a
1190
+ // lazily-loaded peer here (the ops-Lambda venue never loads it), so a static import for a
1191
+ // display string would be a new hard dependency for a sentence.
1192
+ success('Schema posture: REVOKE CREATE ON SCHEMA public FROM PUBLIC — the pre-PG15 default, which PG15 removed, is gone. Verified by re-reading the catalog.');
1193
+ } else {
1194
+ // NOT counted as ownershipIncomplete, deliberately. This is an advisory hardening on a live
1195
+ // database, and `--confirm` is a shared gate (an operator passes it for ADOPT). Failing a
1196
+ // provisioning run whose credentials succeeded, over a posture the operator has not been
1197
+ // asked about yet, would break every existing pre-PG15 deploy pipeline on upgrade.
1198
+ for (const r of posture.refusals) warn(`Schema posture: ${r}`);
1199
+ }
1200
+ }
1180
1201
  };
1181
1202
 
1182
1203
  // Additive-only: if the login roles already existed as live logins, provision changed no
@@ -13,7 +13,7 @@
13
13
  * everystack diag [url] --hydration # Analyze SSR hydration issues
14
14
  */
15
15
 
16
- import { resolveConfig } from '../config';
16
+ import { resolveConfig, probeUrl, ambiguousSiteNote } from '../config';
17
17
  import { getFromS3 } from '../aws';
18
18
  import { step, success, fail, info, warn } from '../output';
19
19
  import { analyzeHtml, generateHydrationReport } from '../hydration-analyzer';
@@ -53,11 +53,13 @@ export async function diagCommand(
53
53
  // Build the URL: positional arg > baseUrl + --path
54
54
  let url = positionalUrl;
55
55
  if (!url) {
56
- if (!config.baseUrl) {
57
- fail('No URL provided and no baseUrl in config. Pass a URL or use --stage.');
56
+ url = probeUrl(config);
57
+ const ambiguous = ambiguousSiteNote(config);
58
+ if (ambiguous) info(ambiguous);
59
+ if (!url) {
60
+ fail('No URL provided and no site URL for this stage. Pass a URL, set CDN_URL on the API function, or use --stage on a deployed stage.');
58
61
  process.exit(1);
59
62
  }
60
- url = config.baseUrl;
61
63
  }
62
64
  // Strip trailing slash from base URL before appending path
63
65
  url = url.replace(/\/+$/, '');
@@ -21,7 +21,7 @@ import {
21
21
  type ExtractedRun,
22
22
  type LhReport,
23
23
  } from '../lighthouse';
24
- import { resolveConfig } from '../config';
24
+ import { resolveConfig, probeUrl, ambiguousSiteNote } from '../config';
25
25
  import { step, info, fail, warn } from '../output';
26
26
 
27
27
  function runLighthouse(url: string, outPath: string): LhReport {
@@ -69,7 +69,9 @@ export async function auditLighthouseCommand(flags: Record<string, string>): Pro
69
69
  if (!host) {
70
70
  try {
71
71
  const config = await resolveConfig(flags.stage);
72
- host = config.baseUrl;
72
+ host = probeUrl(config);
73
+ const ambiguous = ambiguousSiteNote(config);
74
+ if (ambiguous) info(ambiguous);
73
75
  } catch (err: any) {
74
76
  fail(err.message);
75
77
  info('Pass --host <url> to audit a specific URL without resolving a stage.');
@@ -26,7 +26,7 @@ import {
26
26
  type CatalogGrant,
27
27
  type ProbeResult,
28
28
  } from '../security-probe';
29
- import { resolveConfig, opsFunction } from '../config';
29
+ import { resolveConfig, opsFunction, probeUrl, ambiguousSiteNote } from '../config';
30
30
  import { invokeAction } from '../aws';
31
31
  import { step, info, fail, success, warn } from '../output';
32
32
 
@@ -188,11 +188,13 @@ export async function securityProbeCommand(flags: Record<string, string>): Promi
188
188
  fail(err.message);
189
189
  process.exit(1);
190
190
  }
191
- const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
191
+ const base = (flags.host || probeUrl(config)).replace(/\/+$/, '');
192
192
  if (!base) {
193
193
  fail('No router URL to probe. Pass --host or ensure the stage has a deployed URL.');
194
194
  process.exit(1);
195
195
  }
196
+ const ambiguous = flags.host ? undefined : ambiguousSiteNote(config);
197
+ if (ambiguous) info(ambiguous);
196
198
  info(`Region: ${config.region}, Function: ${opsFunction(config)}, URL: ${base}`);
197
199
 
198
200
  let results: ProbeResult[];
@@ -5,7 +5,8 @@ import zlib from 'node:zlib';
5
5
  import { pipeline } from 'node:stream/promises';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { spawn } from 'node:child_process';
8
- import { resolveConfig, type CliConfig } from '../config';
8
+ import { resolveConfig, probeUrl, type CliConfig } from '../config';
9
+ import { missingHostUrlError } from '../host-url';
9
10
  import { uploadToS3, headS3, invokeAction, rollingRestart } from '../aws';
10
11
  import { step, success, warn, fail, info } from '../output';
11
12
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
@@ -128,6 +129,13 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
128
129
  warn(`Could not load SST secrets: ${err.message}`);
129
130
  info('Secrets may be unavailable during export. Provide env vars from the caller if needed.');
130
131
  }
132
+
133
+ // After the secrets step: a secret may supply HOST_URL.
134
+ const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl });
135
+ if (noHostUrl) {
136
+ fail(noHostUrl);
137
+ process.exit(1);
138
+ }
131
139
  }
132
140
 
133
141
  const branch = flags.branch;
@@ -639,13 +647,17 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
639
647
 
640
648
  // 7. Verify the origin is serving the new release.
641
649
  step('Verifying origin serves new release...');
642
- const verified = await verifyOriginRelease(config.baseUrl, updateId);
650
+ const siteUrl = probeUrl(config);
651
+ const verified = await verifyOriginRelease(siteUrl, updateId);
643
652
  if (verified) {
644
653
  success(`Origin confirmed: ${updateId.slice(0, 12)}...`);
654
+ } else if (!siteUrl) {
655
+ warn('No site URL for this stage, so the origin was not checked.');
656
+ info('Set CDN_URL on the API function, or check the site by hand for the x-everystack-release header.');
645
657
  } else {
646
658
  warn('Origin did not confirm the new release within the timeout.');
647
659
  info('The release may still be propagating. Check manually:');
648
- info(` curl -sI ${config.baseUrl} | grep x-everystack-release`);
660
+ info(` curl -sI ${siteUrl} | grep x-everystack-release`);
649
661
  }
650
662
 
651
663
  // Clean up archive
@@ -822,6 +834,8 @@ export async function verifyOriginRelease(
822
834
  baseUrl: string,
823
835
  expectedUpdateId: string,
824
836
  ): Promise<boolean> {
837
+ // No site URL: every probe would throw and be swallowed, burning the whole timeout.
838
+ if (!baseUrl) return false;
825
839
  const deadline = Date.now() + VERIFY_TIMEOUT_MS;
826
840
  let consecutive = 0;
827
841
 
package/src/cli/config.ts CHANGED
@@ -12,7 +12,14 @@ import path from 'node:path';
12
12
  import { step } from './output';
13
13
 
14
14
  export interface CliConfig {
15
+ /** CDN_URL / routerUrl as deployed. update pins it into OTA bundles: never derived. */
15
16
  baseUrl: string;
17
+ /** What cache:purge and update's release check probe. Falls back to baseUrl when absent. */
18
+ siteUrl?: string;
19
+ /** Candidate URLs to probe, best first (see DiscoveredConfig.siteUrls). Absent on older configs. */
20
+ siteUrls?: string[];
21
+ /** The stage distribution's concrete aliases (empty when CDN_URL is set). */
22
+ siteAliases?: string[];
16
23
  region: string;
17
24
  apiFunctionName: string;
18
25
  /** Dedicated operator function (db/console actions). Falls back to apiFunctionName. */
@@ -30,6 +37,28 @@ export interface CliConfig {
30
37
  backupsBucket?: string;
31
38
  }
32
39
 
40
+ /**
41
+ * The URL commands PROBE (release checks, audits, lighthouse): siteUrl, else baseUrl, else ''.
42
+ * Never use it to write a URL into anything shipped; that is baseUrl's job (HOST_URL).
43
+ */
44
+ export function probeUrl(config: Pick<CliConfig, 'baseUrl' | 'siteUrl'>): string {
45
+ return config.siteUrl || config.baseUrl || '';
46
+ }
47
+
48
+ /**
49
+ * One line for single-probe commands when the stage's distribution has several aliases and no
50
+ * CDN_URL: siteUrl is then the cloudfront.net domain, not a chosen host. A green there is a result
51
+ * for a host users may never hit, so say which one it is, every run. Undefined when unambiguous.
52
+ */
53
+ export function ambiguousSiteNote(config: Pick<CliConfig, 'baseUrl' | 'siteUrl' | 'siteAliases'>): string | undefined {
54
+ const aliases = config.siteAliases ?? [];
55
+ if (config.baseUrl || aliases.length < 2) return undefined;
56
+ return (
57
+ `Site URL is ambiguous: this stage's distribution has aliases ${aliases.join(', ')}. Probing ${probeUrl(config)}. ` +
58
+ 'Choose one with --host <alias> (or pass the URL to diag and bundle:audit).'
59
+ );
60
+ }
61
+
33
62
  /** Operator invoke target — dedicated ops function when deployed, else the API function. */
34
63
  export function opsFunction(config: CliConfig): string {
35
64
  return config.opsFunctionName ?? config.apiFunctionName;
@@ -81,6 +110,22 @@ async function readLocalOutputs(): Promise<Partial<SstOutputs> | null> {
81
110
  }
82
111
  }
83
112
 
113
+ /**
114
+ * The local outputs.json, only if its deploy WAS this stage. It is the last deploy on ANY stage,
115
+ * and databaseInstanceId picks db:apply/db:swap's confirmed rollback snapshot, so another
116
+ * stage's file must never reach a stage's config. Unknown means no.
117
+ */
118
+ async function readOutputsFor(stage: string): Promise<Partial<SstOutputs> | null> {
119
+ const outputs = await readLocalOutputs();
120
+ if (!outputs) return null;
121
+ try {
122
+ const { parseAppName, outputsDescribeStage } = await import('./discover');
123
+ return outputsDescribeStage(outputs, await parseAppName(), stage) ? outputs : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
84
129
  export async function resolveConfig(stage?: string): Promise<CliConfig> {
85
130
  const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
86
131
 
@@ -95,7 +140,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
95
140
 
96
141
  // Check cache first
97
142
  const cached = await getCachedConfig(stage);
98
- if (cached) return enrichConfigFromOutputs(cached, await readLocalOutputs());
143
+ if (cached) return enrichConfigFromOutputs(cached, await readOutputsFor(stage));
99
144
 
100
145
  step(`Discovering resources for stage "${stage}"...`);
101
146
  const appName = await parseAppName();
@@ -103,7 +148,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
103
148
 
104
149
  // Cache for future fast lookups
105
150
  await setCachedConfig(stage, config);
106
- return enrichConfigFromOutputs(config, await readLocalOutputs());
151
+ return enrichConfigFromOutputs(config, await readOutputsFor(stage));
107
152
  }
108
153
 
109
154
  // Default: read .sst/outputs.json (unchanged behavior)
@@ -140,6 +185,8 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
140
185
 
141
186
  return {
142
187
  baseUrl: outputs.routerUrl,
188
+ siteUrl: outputs.routerUrl,
189
+ siteUrls: [outputs.routerUrl],
143
190
  region,
144
191
  apiFunctionName: outputs.apiFunctionName,
145
192
  opsFunctionName: outputs.opsFunctionName,
@@ -7,8 +7,8 @@
7
7
  * SST Routers do not put the app/stage in the distribution Comment, so
8
8
  * matching runs in order of reliability:
9
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)
10
+ * 2. sst:app + sst:stage tags (authoritative, but one API call per candidate)
11
+ * 3. Comment contains the {app}-{stage}- prefix (legacy setups; a substring, so a last resort)
12
12
  */
13
13
 
14
14
  export interface DistributionHints {
@@ -26,35 +26,72 @@ interface DistributionSummaryLite {
26
26
  Aliases?: { Items?: string[] };
27
27
  }
28
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 {
29
+ /**
30
+ * A distribution and how it was found. `host` and `tag` are confirmed matches; `comment` is a
31
+ * substring guess (prefix `app-dev-` also matches the Comment of `my-app`'s dev stage), good
32
+ * enough for --invalidate but not to name the site a purge probes.
33
+ */
34
+ export interface DistributionMatch {
35
+ id: string;
36
+ /** cloudfront.net domain, no scheme. A CloudFront Function may still answer 403 on it. */
37
+ domain?: string;
38
+ /** Concrete custom-domain aliases (wildcards dropped), in CloudFront's order: the site's real hostnames. */
39
+ aliases: string[];
40
+ by: 'host' | 'comment' | 'tag';
41
+ }
42
+
43
+ function toMatch(dist: DistributionSummaryLite, by: DistributionMatch['by']): DistributionMatch | undefined {
44
+ const aliases = (dist.Aliases?.Items ?? []).filter((a) => !a.includes('*'));
45
+ return dist.Id ? { id: dist.Id, domain: dist.DomainName, aliases, by } : undefined;
46
+ }
47
+
48
+ function matchByHost(items: readonly DistributionSummaryLite[], routerHost?: string): DistributionMatch | undefined {
35
49
  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
- }
50
+ if (!host) return undefined;
51
+ for (const dist of items) {
52
+ const hit =
53
+ dist.DomainName?.toLowerCase() === host ||
54
+ dist.Aliases?.Items?.some((alias) => alias.toLowerCase() === host);
55
+ if (hit) return toMatch(dist, 'host');
41
56
  }
57
+ return undefined;
58
+ }
59
+
60
+ function matchByComment(items: readonly DistributionSummaryLite[], prefix: string): DistributionMatch | undefined {
42
61
  const needle = prefix.toLowerCase();
43
62
  for (const dist of items) {
44
- if ((dist.Comment || '').toLowerCase().includes(needle)) return dist.Id;
63
+ if ((dist.Comment || '').toLowerCase().includes(needle)) return toMatch(dist, 'comment');
45
64
  }
46
65
  return undefined;
47
66
  }
48
67
 
68
+ /** Pure matcher for the non-tag passes (domain, alias, legacy comment). Exported for tests. */
69
+ export function matchDistributionBy(
70
+ items: readonly DistributionSummaryLite[],
71
+ prefix: string,
72
+ routerHost?: string,
73
+ ): DistributionMatch | undefined {
74
+ return matchByHost(items, routerHost) ?? matchByComment(items, prefix);
75
+ }
76
+
77
+ /** The distribution id only. Exported for tests. */
78
+ export function matchDistribution(
79
+ items: readonly DistributionSummaryLite[],
80
+ prefix: string,
81
+ routerHost?: string,
82
+ ): string | undefined {
83
+ return matchDistributionBy(items, prefix, routerHost)?.id;
84
+ }
85
+
49
86
  /**
50
- * Find the CloudFront distribution for a stage.
87
+ * Find the CloudFront distribution for a stage, and how it was matched.
51
88
  * Best-effort: returns undefined if not found.
52
89
  */
53
- export async function discoverDistribution(
90
+ export async function discoverDistributionMatch(
54
91
  region: string,
55
92
  prefix: string,
56
93
  hints: DistributionHints = {},
57
- ): Promise<string | undefined> {
94
+ ): Promise<DistributionMatch | undefined> {
58
95
  const { CloudFrontClient, ListDistributionsCommand, ListTagsForResourceCommand } =
59
96
  await import('@aws-sdk/client-cloudfront');
60
97
  const client = new CloudFrontClient({ region });
@@ -69,10 +106,11 @@ export async function discoverDistribution(
69
106
  marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined;
70
107
  } while (marker);
71
108
 
72
- const cheap = matchDistribution(items, prefix, hints.routerHost);
73
- if (cheap) return cheap;
109
+ const byHost = matchByHost(items, hints.routerHost);
110
+ if (byHost) return byHost;
74
111
 
75
- // Tag pass — authoritative but one call per distribution, so it runs last.
112
+ // Tag pass — authoritative, so it runs before the Comment guess (a Comment substring can name
113
+ // another app's stage). One call per distribution; SST tags every distribution it makes.
76
114
  if (hints.appName && hints.stage) {
77
115
  for (const dist of items) {
78
116
  if (!dist.ARN) continue;
@@ -82,7 +120,7 @@ export async function discoverDistribution(
82
120
  (res.Tags?.Items || []).map((t) => [t.Key, t.Value]),
83
121
  );
84
122
  if (tags.get('sst:app') === hints.appName && tags.get('sst:stage') === hints.stage) {
85
- return dist.Id;
123
+ return toMatch(dist, 'tag');
86
124
  }
87
125
  } catch {
88
126
  // Tag read denied or transient — keep scanning
@@ -90,5 +128,6 @@ export async function discoverDistribution(
90
128
  }
91
129
  }
92
130
 
93
- return undefined;
131
+ // Legacy setups whose distribution carries the {app}-{stage}- prefix in its Comment.
132
+ return matchByComment(items, prefix);
94
133
  }
@@ -17,7 +17,24 @@ import { importOptionalAws } from './aws';
17
17
 
18
18
  /** Mirrors CliConfig from config.ts — defined locally to avoid circular imports. */
19
19
  interface DiscoveredConfig {
20
+ /**
21
+ * CDN_URL from the API Lambda, or ''. update pins this into shipped OTA bundles (HOST_URL),
22
+ * so it is NEVER derived from anything else.
23
+ */
20
24
  baseUrl: string;
25
+ /**
26
+ * The URL cache:purge and update's release check probe: baseUrl, else the domain of the stage's
27
+ * tag-confirmed CloudFront distribution, else ''. Probe-only; never shipped.
28
+ */
29
+ siteUrl: string;
30
+ /**
31
+ * Every URL worth probing, best first: CDN_URL alone when set, else the distribution's custom
32
+ * aliases then its cloudfront.net domain. A CloudFront Function can gate by Host and answer 403 on
33
+ * the raw domain, so the gate passes if ANY of these serves the release. siteUrl is the first.
34
+ */
35
+ siteUrls: string[];
36
+ /** The distribution's concrete aliases; two or more make siteUrl a guess, which commands announce. */
37
+ siteAliases: string[];
21
38
  region: string;
22
39
  apiFunctionName: string;
23
40
  opsFunctionName?: string;
@@ -62,6 +79,22 @@ export async function parseAppName(configPath?: string): Promise<string> {
62
79
  return match[1];
63
80
  }
64
81
 
82
+ /**
83
+ * Does a deploy-written .sst/outputs.json describe this stage? The file is the last deploy on ANY
84
+ * stage and has no stage field, but its apiFunctionName is `<app>-<stage>-ApiFunction-<hash>`
85
+ * (the convention discovery matches on). `.sst/stage` is NOT evidence: it is SST's personal-stage
86
+ * marker and can name a different stage than the last deploy. Unknown means no.
87
+ */
88
+ export function outputsDescribeStage(
89
+ outputs: { apiFunctionName?: string } | null | undefined,
90
+ appName: string,
91
+ stage: string,
92
+ ): boolean {
93
+ const fn = outputs?.apiFunctionName;
94
+ if (!fn) return false;
95
+ return fn.toLowerCase().startsWith(`${appName}-${stage}-ApiFunction-`.toLowerCase());
96
+ }
97
+
65
98
  // ---------------------------------------------------------------------------
66
99
  // CloudFront function discovery (→ KVS ARN)
67
100
  // ---------------------------------------------------------------------------
@@ -284,20 +317,47 @@ export async function discoverConfig(
284
317
 
285
318
  // Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
286
319
  let distributionId: string | undefined;
320
+ let distributionHosts: string[] = [];
321
+ let distributionAliases: string[] = [];
322
+ let distributionDomain: string | undefined;
287
323
  try {
288
- const { discoverDistribution } = await import('./discover-distribution');
324
+ const { discoverDistributionMatch } = await import('./discover-distribution');
289
325
  const routerHost = lambdaResult.routerUrl
290
326
  ? new URL(lambdaResult.routerUrl).host
291
327
  : undefined;
292
- distributionId = await discoverDistribution(region, prefix, {
328
+ const match = await discoverDistributionMatch(region, prefix, {
293
329
  routerHost,
294
330
  appName,
295
331
  stage,
296
332
  });
333
+ distributionId = match?.id;
334
+ // A Comment-substring match can name another app's stage; only a confirmed one may say
335
+ // which site a purge probes.
336
+ if (match && match.by !== 'comment') {
337
+ distributionAliases = match.aliases;
338
+ distributionDomain = match.domain;
339
+ distributionHosts = [...match.aliases, ...(match.domain ? [match.domain] : [])];
340
+ }
297
341
  } catch {
298
342
  // Non-fatal
299
343
  }
300
344
 
345
+ // CDN_URL is optional on the API Lambda. Without it the stage's own distribution supplies the
346
+ // probe URL — and only that: baseUrl stays as deployed (see DiscoveredConfig).
347
+ const baseUrl = lambdaResult.routerUrl || '';
348
+ // Gate candidates: every host worth trying, best first. siteUrl is what single-probe commands use
349
+ // with no fallback, so it must not be a guess: CDN_URL, else the ONE alias if there is exactly one,
350
+ // else the cloudfront.net domain (loud on a hostname-gating site, and commands say which host).
351
+ const siteUrls = baseUrl ? [baseUrl] : distributionHosts.map((h) => `https://${h}`);
352
+ const siteAliases = baseUrl ? [] : distributionAliases;
353
+ const siteUrl = baseUrl
354
+ ? baseUrl
355
+ : distributionAliases.length === 1
356
+ ? `https://${distributionAliases[0]}`
357
+ : distributionDomain
358
+ ? `https://${distributionDomain}`
359
+ : '';
360
+
301
361
  if (!bucketsResult.updatesBucket) {
302
362
  throw new Error(
303
363
  `Could not find S3 bucket "${prefix}updatesbucket-*". ` +
@@ -320,15 +380,21 @@ export async function discoverConfig(
320
380
  try {
321
381
  const raw = await fs.readFile(path.resolve('.sst', 'outputs.json'), 'utf8');
322
382
  const outputs = JSON.parse(raw);
323
- if (!mediaBucket && outputs.mediaBucket) mediaBucket = outputs.mediaBucket;
324
- if (!backupsBucket && outputs.backupsBucket) backupsBucket = outputs.backupsBucket;
383
+ // outputs.json is the last deploy on ANY stage: adopt its buckets only if it was this one.
384
+ if (outputsDescribeStage(outputs, appName, stage)) {
385
+ if (!mediaBucket && outputs.mediaBucket) mediaBucket = outputs.mediaBucket;
386
+ if (!backupsBucket && outputs.backupsBucket) backupsBucket = outputs.backupsBucket;
387
+ }
325
388
  } catch {
326
389
  // outputs.json not available — non-fatal
327
390
  }
328
391
  }
329
392
 
330
393
  return {
331
- baseUrl: lambdaResult.routerUrl || '',
394
+ baseUrl,
395
+ siteUrl,
396
+ siteUrls,
397
+ siteAliases,
332
398
  region,
333
399
  apiFunctionName: lambdaResult.functionName,
334
400
  opsFunctionName: opsFn,
@@ -348,12 +414,15 @@ export async function discoverConfig(
348
414
  // ---------------------------------------------------------------------------
349
415
 
350
416
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
351
- const CACHE_VERSION = 5; // Bump when CachedOutputs schema changes to invalidate stale caches (5: +backupsBucket)
417
+ const CACHE_VERSION = 7; // Bump when CachedOutputs schema changes to invalidate stale caches (5: +backupsBucket, 6: +siteUrl, 7: +siteUrls; v6 pinned the cloudfront.net domain, which a hostname-gating function answers 403)
352
418
 
353
419
  interface CachedOutputs {
354
420
  _cachedAt: number;
355
421
  _version?: number;
356
422
  routerUrl?: string;
423
+ siteUrl?: string;
424
+ siteUrls?: string[];
425
+ siteAliases?: string[];
357
426
  apiFunctionName?: string;
358
427
  opsFunctionName?: string;
359
428
  imageFunctionName?: string;
@@ -381,6 +450,9 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
381
450
  }
382
451
  return {
383
452
  baseUrl: cached.routerUrl || '',
453
+ siteUrl: cached.siteUrl ?? cached.routerUrl ?? '',
454
+ siteUrls: cached.siteUrls ?? [],
455
+ siteAliases: cached.siteAliases ?? [],
384
456
  region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
385
457
  apiFunctionName: cached.apiFunctionName,
386
458
  opsFunctionName: cached.opsFunctionName,
@@ -458,6 +530,9 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
458
530
  _cachedAt: Date.now(),
459
531
  _version: CACHE_VERSION,
460
532
  routerUrl: config.baseUrl,
533
+ siteUrl: config.siteUrl,
534
+ siteUrls: config.siteUrls,
535
+ siteAliases: config.siteAliases,
461
536
  apiFunctionName: config.apiFunctionName,
462
537
  opsFunctionName: config.opsFunctionName,
463
538
  imageFunctionName: config.imageFunctionName,
@@ -116,11 +116,13 @@ export interface FkOrphanAdvisoryOptions {
116
116
  /**
117
117
  * The advisory, as lines. Empty when nothing is unproven.
118
118
  *
119
- * Every HEADLINE it returns is a problem line — it starts with `!`, the same mark and the same
120
- * severity tier the ownership report's findings use — so the caller prints whatever comes back
121
- * without having to classify it: at the default verbosity that is headlines only, and `verbose`
122
- * adds the indented detail under each one. The headline states the CONSEQUENCE — enforced
123
- * forward, N rows unchecked — because a bare count reads as an error nobody can size.
119
+ * Every HEADLINE it returns is a problem line, the same severity tier the ownership report's
120
+ * findings use — so the caller `warn()`s whatever comes back without having to classify it: at
121
+ * the default verbosity that is headlines only, and `verbose` adds the indented detail under
122
+ * each one. `warn()` (output.ts) already prepends the `!` mark, so these strings carry none of
123
+ * their own — a caller that printed one with `line()`/`info()` instead of `warn()` would be the
124
+ * bug, not this module doubling the mark on top of it. The headline states the CONSEQUENCE —
125
+ * enforced forward, N rows unchecked — because a bare count reads as an error nobody can size.
124
126
  *
125
127
  * Findings whose column also decides row access get their OWN headline. Same severity, same
126
128
  * non-blocking behaviour: what changes is that the reader is told this is not only a data
@@ -144,7 +146,7 @@ export function renderFkOrphanAdvisory(
144
146
  if (plain.length > 0) {
145
147
  const rows = plain.reduce((sum, f) => sum + f.orphans, 0);
146
148
  lines.push(
147
- `! ${plain.length} foreign ${plural(plain.length, 'key stays', 'keys stay')} NOT VALID: new writes enforced, `
149
+ `${plain.length} foreign ${plural(plain.length, 'key stays', 'keys stay')} NOT VALID: new writes enforced, `
148
150
  + `${count(rows)} existing ${plural(rows, 'row', 'rows')} unchecked `
149
151
  + `(--verbose for detail; ${declareIt}).`,
150
152
  );
@@ -157,7 +159,7 @@ export function renderFkOrphanAdvisory(
157
159
  .map((f) => `${f.table}.${gatingColumns(f, ownership).join('/')}`)
158
160
  .join(', ');
159
161
  lines.push(
160
- `! ROW SECURITY — ${gating.length} unproven foreign ${plural(gating.length, 'key also decides', 'keys also decide')} `
162
+ `ROW SECURITY — ${gating.length} unproven foreign ${plural(gating.length, 'key also decides', 'keys also decide')} `
161
163
  + `who may read or write the row (${where}): `
162
164
  + `${count(rows)} existing ${plural(rows, 'row', 'rows')} point at a parent that is gone, so the owner check `
163
165
  + `on ${plural(gating.length, 'it', 'them')} matches nobody `
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Auto-detect HOST_URL from SST outputs.
3
+ * SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
4
+ * If HOST_URL isn't already set, use the routerUrl from the last deploy.
5
+ *
6
+ * When a stage is specified, checks the stage-specific cached config first.
7
+ * .sst/outputs.json reflects the last deploy (any stage), so with --stage it is used only when
8
+ * its apiFunctionName says that deploy WAS this stage (outputsDescribeStage). Otherwise HOST_URL
9
+ * stays unset: a wrong-stage URL would be baked into the OTA bundle by `update`.
10
+ */
11
+
12
+ import fs from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { createRequire } from 'node:module';
15
+
16
+ export async function loadSstOutputs(stage?: string): Promise<void> {
17
+ if (process.env.HOST_URL) return;
18
+
19
+ // When --stage is provided, try stage-specific cache first
20
+ if (stage) {
21
+ const stagePath = path.resolve('.sst', `outputs.${stage}.json`);
22
+ try {
23
+ const raw = await fs.readFile(stagePath, 'utf8');
24
+ const cached = JSON.parse(raw);
25
+ if (cached.routerUrl) {
26
+ process.env.HOST_URL = cached.routerUrl;
27
+ return;
28
+ }
29
+ } catch {
30
+ // No cached config for this stage — fall through to default
31
+ }
32
+ }
33
+
34
+ const outputsPath = path.resolve('.sst', 'outputs.json');
35
+ try {
36
+ const raw = await fs.readFile(outputsPath, 'utf8');
37
+ const outputs = JSON.parse(raw);
38
+ if (stage && !(await outputsBelongToStage(outputs, stage))) return;
39
+ if (outputs.routerUrl) {
40
+ process.env.HOST_URL = outputs.routerUrl;
41
+ }
42
+ } catch {
43
+ // No .sst/outputs.json — user must set HOST_URL manually or use app.config.js defaults
44
+ }
45
+ }
46
+
47
+ /** Provenance gate for the --stage fall-through; an unreadable app name means unknown, so no. */
48
+ async function outputsBelongToStage(outputs: { apiFunctionName?: string }, stage: string): Promise<boolean> {
49
+ try {
50
+ const { parseAppName, outputsDescribeStage } = await import('./discover');
51
+ return outputsDescribeStage(outputs, await parseAppName(), stage);
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The @expo/env `expo export` will load, found the way it finds it: expo -> @expo/cli -> @expo/env
59
+ * from the project (a package manager that does not hoist still resolves it), else a direct
60
+ * @expo/env. Undefined when the project has no Expo.
61
+ */
62
+ function resolveExpoEnv(from: string): string | undefined {
63
+ const at = (dir: string, id: string) => createRequire(path.join(dir, 'noop.js')).resolve(id);
64
+ try {
65
+ const cli = at(path.dirname(at(from, 'expo/package.json')), '@expo/cli/package.json');
66
+ return at(path.dirname(cli), '@expo/env');
67
+ } catch {
68
+ // fall through
69
+ }
70
+ try {
71
+ return at(from, '@expo/env');
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Does the dotenv loading `expo export` will run set a non-empty HOST_URL? It runs Expo's own
79
+ * loader (@expo/env parseProjectEnv), not a copy of it: file set, precedence,
80
+ * EXPO_NO_DOTENV and variable expansion are Expo's (the mode is fixed to production, as in export), so this sees what the bundle will bake.
81
+ * Non-mutating. Only a boolean escapes; no value is kept or printed. Fails closed: no Expo, or
82
+ * a loader that throws, means no.
83
+ */
84
+ async function dotenvSetsHostUrl(
85
+ cwd: string,
86
+ env: Record<string, string | undefined>,
87
+ resolveFrom: string,
88
+ ): Promise<boolean> {
89
+ const resolved = resolveExpoEnv(resolveFrom);
90
+ if (!resolved) return false;
91
+ try {
92
+ const expoEnv = createRequire(resolved)(resolved) as {
93
+ parseProjectEnv(
94
+ root: string,
95
+ options: { mode?: string; silent?: boolean; systemEnv?: Record<string, string | undefined> },
96
+ ): { env: Record<string, string | undefined> };
97
+ };
98
+ // Upstream behaviour, re-verify on every Expo upgrade: @expo/cli build/src/export/exportApp.js
99
+ // exportAppAsync sets `process.env.NODE_ENV = dev ? 'development' : 'production'`
100
+ // unconditionally, and everystack never passes --dev. So the mode is production, not env.NODE_ENV.
101
+ const parsed = expoEnv.parseProjectEnv(cwd, { mode: 'production', silent: true, systemEnv: env });
102
+ return Boolean(parsed.env.HOST_URL);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Why `update --stage` must stop: nothing supplied a HOST_URL, so the OTA bundle would bake the
110
+ * app's default (often localhost) as updates.url. Devices could then never fetch the recovery
111
+ * publish. Undefined when HOST_URL is set (env or dotenv) or no stage was given. The message
112
+ * names the ways out, including the stage's own site URL when discovery found one.
113
+ */
114
+ export async function missingHostUrlError(
115
+ stage: string | undefined,
116
+ opts: { env?: Record<string, string | undefined>; cwd?: string; siteUrl?: string; resolveFrom?: string } = {},
117
+ ): Promise<string | undefined> {
118
+ const { env = process.env, cwd = process.cwd(), siteUrl, resolveFrom = cwd } = opts;
119
+ if (!stage || env.HOST_URL || (await dotenvSetsHostUrl(cwd, env, resolveFrom))) return undefined;
120
+ const offer = siteUrl ? ` This stage's site URL is ${siteUrl}; export it as HOST_URL if that is its public URL.` : '';
121
+ return (
122
+ `No HOST_URL for stage "${stage}", so the bundle would ship the app's default URL as updates.url.${offer} ` +
123
+ 'Otherwise export HOST_URL=<the stage\'s public URL>, or set CDN_URL on the API function and re-run with --refresh. ' +
124
+ '(A HOST_URL in a dotenv file counts only when the project\'s Expo can be found to read it the way `expo export` does.) ' +
125
+ `(.sst/outputs.json is used only when its apiFunctionName is "<app>-${stage}-ApiFunction-...".)`
126
+ );
127
+ }
package/src/cli/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
3
+ import { loadSstOutputs } from './host-url';
5
4
  import { updateCommand } from './commands/update';
6
5
  import { deployCommand } from './commands/deploy';
7
6
  import { certsCommand } from './commands/certs';
@@ -51,45 +50,6 @@ import { parseFlags } from './parse-flags';
51
50
  const args = process.argv.slice(2);
52
51
  const command = args[0];
53
52
 
54
- /**
55
- * Auto-detect HOST_URL from SST outputs.
56
- * SST writes .sst/outputs.json after every deploy with { routerUrl, apiUrl, ... }.
57
- * If HOST_URL isn't already set, use the routerUrl from the last deploy.
58
- *
59
- * When a stage is specified, checks the stage-specific cached config first.
60
- * .sst/outputs.json reflects the last deploy (any stage) and may be wrong
61
- * when publishing to a different stage.
62
- */
63
- async function loadSstOutputs(stage?: string): Promise<void> {
64
- if (process.env.HOST_URL) return;
65
-
66
- // When --stage is provided, try stage-specific cache first
67
- if (stage) {
68
- const stagePath = path.resolve('.sst', `outputs.${stage}.json`);
69
- try {
70
- const raw = await fs.readFile(stagePath, 'utf8');
71
- const cached = JSON.parse(raw);
72
- if (cached.routerUrl) {
73
- process.env.HOST_URL = cached.routerUrl;
74
- return;
75
- }
76
- } catch {
77
- // No cached config for this stage — fall through to default
78
- }
79
- }
80
-
81
- const outputsPath = path.resolve('.sst', 'outputs.json');
82
- try {
83
- const raw = await fs.readFile(outputsPath, 'utf8');
84
- const outputs = JSON.parse(raw);
85
- if (outputs.routerUrl) {
86
- process.env.HOST_URL = outputs.routerUrl;
87
- }
88
- } catch {
89
- // No .sst/outputs.json — user must set HOST_URL manually or use app.config.js defaults
90
- }
91
- }
92
-
93
53
  async function main() {
94
54
  if (!command || command === '--help' || command === '-h') {
95
55
  printHelp();
@@ -407,6 +367,7 @@ Usage:
407
367
  everystack cache:purge [--stage <name>] --origin api Bust API origin cache (api|media|web)
408
368
  everystack cache:purge [--stage <name>] --path "/api/posts" Bust specific path cache
409
369
  everystack cache:purge [--stage <name>] --invalidate Also create CloudFront edge invalidation
370
+ everystack cache:purge [--stage <name>] --host <url> Probe this public URL for the release check (a site whose CloudFront Function gates by hostname)
410
371
  everystack diag [url] [--path /about] [--channel <name>] [--stage <name>] Diagnose page freshness
411
372
  everystack diag [url] --hydration Analyze SSR hydration issues (fetches full HTML)
412
373
  everystack analyze:ssr [--app ./app] Scan app code for SSR anti-patterns
@@ -248,18 +248,30 @@ export function classifySchema(schema: SchemaDescriptor, waivers: Waivers = {}):
248
248
 
249
249
  if (broad.length > 0) {
250
250
  const who = broad.join(', ');
251
+ // The fix is PER GRANTEE, and the grantees are PARTITIONED — never collapsed into one line.
252
+ //
253
+ // `db:provision --confirm` revokes exactly ONE thing: PUBLIC's CREATE on schema `public`
254
+ // (reviewably — it reports the gap without the flag). Every other (grantee, schema) pair is hand
255
+ // SQL. So the pointer stands in for PUBLIC's own revoke and for nothing else: a schema where BOTH
256
+ // PUBLIC and `authenticated` hold CREATE must still name the second revoke, or an operator runs
257
+ // the one command offered, re-audits, and is still writable with nothing saying why.
258
+ const isPublicPseudoRole = (g: string) => key === 'public' && g.toLowerCase() === 'public';
259
+ const fixes = broad.map((g) => (isPublicPseudoRole(g)
260
+ ? 'REVOKE CREATE ON SCHEMA public FROM PUBLIC — run `everystack db:provision --stage <stage> --confirm`, which does exactly this revoke and verifies it'
261
+ : `REVOKE CREATE ON SCHEMA ${key} FROM ${g}`));
262
+ const fix = `${fixes.join('. ')}.`;
251
263
  if (schema.unpinnedDefinerFunctions > 0) {
252
264
  reasons.push(
253
265
  `${who} can CREATE in schema "${key}", which holds ${schema.unpinnedDefinerFunctions} unpinned SECURITY DEFINER function(s) — `
254
266
  + `that is a complete search_path hijack chain: plant a shadow object, the unpinned function resolves through it and runs it with the owner's rights. `
255
- + `REVOKE CREATE ON SCHEMA ${key} FROM ${who}, and pin those functions.`,
267
+ + `${fix.replace(/\.$/, '')}, and pin those functions.`,
256
268
  );
257
269
  severity = 'red';
258
270
  } else {
259
271
  reasons.push(
260
272
  `${who} can CREATE in schema "${key}" — the pre-PG15 default, which PG15 removed. No unpinned SECURITY DEFINER function is here, `
261
273
  + `but a writable schema on a search path threatens every privileged session that resolves through it, including the build credential `
262
- + `running DDL. REVOKE CREATE ON SCHEMA ${key} FROM ${who}.`,
274
+ + `running DDL. ${fix}`,
263
275
  );
264
276
  severity = raise(severity, 'warn');
265
277
  }
@@ -113,7 +113,8 @@ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecisi
113
113
  mode: 'refuse',
114
114
  reason: 'A physical snapshot needs the RDS instance id, and this stage does not expose one. '
115
115
  + 'Add `databaseInstanceId: database.id` to the outputs return block in sst.config.ts and redeploy '
116
- + '(run db:swap from the app directory so .sst/outputs.json is readable), or pass --snapshot logical '
116
+ + '(run db:swap from the app directory so .sst/outputs.json is readable; it counts only when the last '
117
+ + '`sst deploy` was THIS stage, otherwise redeploy this stage), or pass --snapshot logical '
117
118
  + 'for a pg_dump rollback point instead.',
118
119
  };
119
120
  }