@everystack/cli 0.4.87 → 0.4.89

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,12 @@ 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
+
7
13
  ## [0.4.87] - 2026-09-21
8
14
 
9
15
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.87",
3
+ "version": "0.4.89",
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>",
@@ -8,7 +8,7 @@
8
8
  * the heavier `audit:lighthouse` stay separate commands (run them explicitly).
9
9
  */
10
10
 
11
- import { resolveConfig, opsFunction, probeUrl } 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';
@@ -33,6 +33,8 @@ export async function auditCommand(flags: Record<string, string>): Promise<void>
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, probeUrl } 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 = probeUrl(await resolveConfig(flags.stage));
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>.');
@@ -13,6 +13,9 @@ 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, probeUrl(config), 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
- siteUrl: string,
146
+ siteUrl: string | readonly string[],
115
147
  updatesBucket: string,
116
148
  channel: string,
117
149
  distributionId?: string,
@@ -133,27 +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
- if (!siteUrl) {
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) {
138
173
  return (
139
174
  'No site URL for this stage, so the origin cannot be probed. ' +
140
- 'Set CDN_URL on the API function and redeploy, or tag the distribution sst:app/sst:stage; ' +
141
- 'then re-run with --refresh to discover the stage again. Or pass --force to skip this check.'
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.'
142
177
  );
143
178
  }
144
- try {
145
- const probe = `${siteUrl}?_probe=${Date.now()}`;
146
- const res = await fetch(probe, { redirect: 'follow' });
147
- const serving = res.headers.get('x-everystack-release');
148
- if (serving !== expectedUpdateId) {
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}`;
149
200
  return (
150
- `Origin is serving release ${serving ? serving.slice(0, 12) + '...' : '(none)'} ` +
151
- `but the channel pointer is ${expectedUpdateId.slice(0, 12)}.... Probed ${siteUrl}. ` +
152
- `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>.")
153
204
  );
154
205
  }
155
- } catch (err: any) {
156
- return `Could not reach origin at ${siteUrl}: ${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
+ );
157
217
  }
158
218
 
159
219
  // 3. Check distribution status (if discoverable).
@@ -61,6 +61,7 @@ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws';
61
61
  import { modelContractPreflight } from '../model-wire-lane';
62
62
  import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../../wire';
63
63
  import { step, success, fail, info, warn } from '../output';
64
+ import { splitIdentityList } from '../utils/identity-list';
64
65
 
65
66
  // ---------------------------------------------------------------------------
66
67
  // The testable core.
@@ -622,7 +623,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
622
623
 
623
624
  // --only <a,b>: restrict the reconcile to the named identities (surgical). With --rebuild it is
624
625
  // the recovery path for a self-consistent-but-wrong provenance row (a mistaken --rebaseline).
625
- const only = flags.only ? flags.only.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
626
+ const only = flags.only ? splitIdentityList(flags.only) : undefined;
626
627
  // An empty/whitespace --only would silently degrade to a FULL reconcile — refuse it, since the
627
628
  // operator clearly meant to scope the run.
628
629
  if (flags.only !== undefined && !only?.length) {
@@ -30,6 +30,7 @@ import { resolveConfig, opsFunction } from '../config';
30
30
  import { resolveOperatorUrlViaStage } from '../direct-venue';
31
31
  import { invokeAction } from '../aws';
32
32
  import { step, success, fail, info } from '../output';
33
+ import { splitIdentityList } from '../utils/identity-list';
33
34
 
34
35
  export async function dbRefreshCommand(flags: Record<string, string>): Promise<void> {
35
36
  // The declared matviews, in dependency order (a dependent matview reads its parents' fresh rows).
@@ -40,7 +41,7 @@ export async function dbRefreshCommand(flags: Record<string, string>): Promise<v
40
41
  fail(err.message);
41
42
  process.exit(1);
42
43
  }
43
- const only = flags.only ? flags.only.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
44
+ const only = flags.only ? splitIdentityList(flags.only) : undefined;
44
45
  // --verify-nonempty: after refreshing, gate on populated-but-zero-rows matviews (the dark-panel
45
46
  // check) — credential-free, a scoped EXISTS on only what was just refreshed.
46
47
  const verifyNonempty = flags['verify-nonempty'] === 'true';
@@ -5,7 +5,7 @@
5
5
  * No HTTP endpoints, no shared secrets — IAM is the authorization layer.
6
6
  */
7
7
 
8
- import { resolveConfig, opsFunction } from '../config';
8
+ import { resolveConfig, opsFunction, probeUrl, type CliConfig } from '../config';
9
9
  import { invokeAction } from '../aws';
10
10
  import { step, success, fail, info, warn } from '../output';
11
11
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
@@ -613,6 +613,29 @@ export function parseUrlConnection(url: string): { host: string; port: string; d
613
613
  };
614
614
  }
615
615
 
616
+ /**
617
+ * Rotation's non-blocking origin check. probeUrl falls back to siteUrl when there is no
618
+ * CDN_URL, so a stage without one still gets checked here instead of silently skipping the
619
+ * check (reading config.baseUrl directly did that). Exported for testing without invoking the
620
+ * whole rotation flow.
621
+ */
622
+ export async function probeRotationOrigin(
623
+ config: Pick<CliConfig, 'baseUrl' | 'siteUrl'>,
624
+ fetchImpl: typeof fetch = fetch,
625
+ ): Promise<{ ok: boolean; message: string } | undefined> {
626
+ const url = probeUrl(config);
627
+ if (!url) return undefined;
628
+ try {
629
+ const probe = `${url}?_probe=${Date.now()}`;
630
+ const res = await fetchImpl(probe, { redirect: 'follow' });
631
+ return res.ok
632
+ ? { ok: true, message: 'Origin responding.' }
633
+ : { ok: false, message: `Origin returned ${res.status}.` };
634
+ } catch (err: any) {
635
+ return { ok: false, message: `Origin probe failed: ${err.message}` };
636
+ }
637
+ }
638
+
616
639
  /**
617
640
  * db:provision --rotate — swap the active A/B role for one or both credential slots.
618
641
  *
@@ -812,15 +835,10 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
812
835
  }
813
836
 
814
837
  // Origin HTTP check (non-blocking — the login probe is the real gate).
815
- if (config.baseUrl) {
816
- try {
817
- const probe = `${config.baseUrl}?_probe=${Date.now()}`;
818
- const res = await fetch(probe, { redirect: 'follow' });
819
- if (res.ok) success('Origin responding.');
820
- else warn(`Origin returned ${res.status}.`);
821
- } catch (err: any) {
822
- warn(`Origin probe failed: ${err.message}`);
823
- }
838
+ const originResult = await probeRotationOrigin(config);
839
+ if (originResult) {
840
+ if (originResult.ok) success(originResult.message);
841
+ else warn(originResult.message);
824
842
  }
825
843
 
826
844
  // Retire old roles and drain sessions — using the roles recorded at
@@ -13,7 +13,7 @@
13
13
  * everystack diag [url] --hydration # Analyze SSR hydration issues
14
14
  */
15
15
 
16
- import { resolveConfig, probeUrl } 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';
@@ -54,6 +54,8 @@ export async function diagCommand(
54
54
  let url = positionalUrl;
55
55
  if (!url) {
56
56
  url = probeUrl(config);
57
+ const ambiguous = ambiguousSiteNote(config);
58
+ if (ambiguous) info(ambiguous);
57
59
  if (!url) {
58
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.');
59
61
  process.exit(1);
@@ -21,7 +21,7 @@ import {
21
21
  type ExtractedRun,
22
22
  type LhReport,
23
23
  } from '../lighthouse';
24
- import { resolveConfig, probeUrl } 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 {
@@ -70,6 +70,8 @@ export async function auditLighthouseCommand(flags: Record<string, string>): Pro
70
70
  try {
71
71
  const config = await resolveConfig(flags.stage);
72
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, probeUrl } 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
 
@@ -193,6 +193,8 @@ export async function securityProbeCommand(flags: Record<string, string>): Promi
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[];
@@ -129,13 +129,16 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
129
129
  warn(`Could not load SST secrets: ${err.message}`);
130
130
  info('Secrets may be unavailable during export. Provide env vars from the caller if needed.');
131
131
  }
132
+ }
132
133
 
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
- }
134
+ // Runs whether or not --stage was given: the harm (baking the app's default URL into a
135
+ // published channel's bundle) is the same either way, and the documented day-to-day flow is
136
+ // `update --channel production` with no --stage at all. A secret loaded above may supply
137
+ // HOST_URL, which is why this runs after that step rather than before it.
138
+ const noHostUrl = await missingHostUrlError(flags.stage, { siteUrl: resolvedConfig?.siteUrl });
139
+ if (noHostUrl) {
140
+ fail(noHostUrl);
141
+ process.exit(1);
139
142
  }
140
143
 
141
144
  const branch = flags.branch;
package/src/cli/config.ts CHANGED
@@ -16,6 +16,10 @@ export interface CliConfig {
16
16
  baseUrl: string;
17
17
  /** What cache:purge and update's release check probe. Falls back to baseUrl when absent. */
18
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[];
19
23
  region: string;
20
24
  apiFunctionName: string;
21
25
  /** Dedicated operator function (db/console actions). Falls back to apiFunctionName. */
@@ -41,6 +45,20 @@ export function probeUrl(config: Pick<CliConfig, 'baseUrl' | 'siteUrl'>): string
41
45
  return config.siteUrl || config.baseUrl || '';
42
46
  }
43
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
+
44
62
  /** Operator invoke target — dedicated ops function when deployed, else the API function. */
45
63
  export function opsFunction(config: CliConfig): string {
46
64
  return config.opsFunctionName ?? config.apiFunctionName;
@@ -168,6 +186,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
168
186
  return {
169
187
  baseUrl: outputs.routerUrl,
170
188
  siteUrl: outputs.routerUrl,
189
+ siteUrls: [outputs.routerUrl],
171
190
  region,
172
191
  apiFunctionName: outputs.apiFunctionName,
173
192
  opsFunctionName: outputs.opsFunctionName,
@@ -11,6 +11,8 @@
11
11
  * 3. Comment contains the {app}-{stage}- prefix (legacy setups; a substring, so a last resort)
12
12
  */
13
13
 
14
+ import { warn } from './output';
15
+
14
16
  export interface DistributionHints {
15
17
  /** Host of the discovered router URL, e.g. d111abc.cloudfront.net or a custom domain. */
16
18
  routerHost?: string;
@@ -33,13 +35,16 @@ interface DistributionSummaryLite {
33
35
  */
34
36
  export interface DistributionMatch {
35
37
  id: string;
36
- /** cloudfront.net domain, no scheme — the host this distribution always answers on. */
38
+ /** cloudfront.net domain, no scheme. A CloudFront Function may still answer 403 on it. */
37
39
  domain?: string;
40
+ /** Concrete custom-domain aliases (wildcards dropped), in CloudFront's order: the site's real hostnames. */
41
+ aliases: string[];
38
42
  by: 'host' | 'comment' | 'tag';
39
43
  }
40
44
 
41
45
  function toMatch(dist: DistributionSummaryLite, by: DistributionMatch['by']): DistributionMatch | undefined {
42
- return dist.Id ? { id: dist.Id, domain: dist.DomainName, by } : undefined;
46
+ const aliases = (dist.Aliases?.Items ?? []).filter((a) => !a.includes('*'));
47
+ return dist.Id ? { id: dist.Id, domain: dist.DomainName, aliases, by } : undefined;
43
48
  }
44
49
 
45
50
  function matchByHost(items: readonly DistributionSummaryLite[], routerHost?: string): DistributionMatch | undefined {
@@ -109,6 +114,7 @@ export async function discoverDistributionMatch(
109
114
  // Tag pass — authoritative, so it runs before the Comment guess (a Comment substring can name
110
115
  // another app's stage). One call per distribution; SST tags every distribution it makes.
111
116
  if (hints.appName && hints.stage) {
117
+ let warnedOnTagReadFailure = false;
112
118
  for (const dist of items) {
113
119
  if (!dist.ARN) continue;
114
120
  try {
@@ -120,7 +126,13 @@ export async function discoverDistributionMatch(
120
126
  return toMatch(dist, 'tag');
121
127
  }
122
128
  } catch {
123
- // Tag read denied or transient — keep scanning
129
+ // Tag read denied or transient — keep scanning, but say so once. Silent here means
130
+ // siteUrl quietly degrades to empty and distributionId falls to the Comment guess,
131
+ // with nothing in the output naming why.
132
+ if (!warnedOnTagReadFailure) {
133
+ warnedOnTagReadFailure = true;
134
+ warn('cloudfront:ListTagsForResource failed during distribution discovery — falling back to Comment matching, which is a substring guess.');
135
+ }
124
136
  }
125
137
  }
126
138
  }
@@ -27,6 +27,14 @@ interface DiscoveredConfig {
27
27
  * tag-confirmed CloudFront distribution, else ''. Probe-only; never shipped.
28
28
  */
29
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[];
30
38
  region: string;
31
39
  apiFunctionName: string;
32
40
  opsFunctionName?: string;
@@ -71,11 +79,45 @@ export async function parseAppName(configPath?: string): Promise<string> {
71
79
  return match[1];
72
80
  }
73
81
 
82
+ /**
83
+ * The prefix SST's `prefixName` produces for a resource named `resourceName` (e.g.
84
+ * "ApiFunction"), given the budget `physicalName(maxLen, name)` leaves after its trailing
85
+ * "-<8-char hash>" (maxLen - 9). Ports the SST platform's naming.ts prefixName exactly
86
+ * (see apps/example/.sst/platform/src/components/naming.ts): the STAGE is never truncated,
87
+ * the APP segment is cut to fit once app+stage+name would overflow the budget, and dropped
88
+ * entirely if even stage+name does.
89
+ * A naive "<app>-<stage>-<resourceName>" false-negatives on long app/stage names because it
90
+ * skips this truncation.
91
+ *
92
+ * `maxLen` is the physicalName length ceiling for the AWS resource TYPE being matched — it is
93
+ * NOT the same for every resource. SST's own naming table (component.ts namingRules) pins:
94
+ * Lambda functions and CloudFront functions at 64 (`aws:lambda/function:Function`,
95
+ * `aws:cloudfront/function:Function`), S3 buckets at 63 (`aws:s3/bucket:Bucket`, one character
96
+ * tighter). Reusing the Lambda budget for a bucket needle silently mis-truncates the app segment
97
+ * by one character once truncation actually kicks in.
98
+ */
99
+ function sstPhysicalNamePrefix(
100
+ appName: string,
101
+ stage: string,
102
+ resourceName: string,
103
+ maxLen: number,
104
+ ): string {
105
+ const max = maxLen - 9;
106
+ const nameLen = resourceName.length;
107
+ const stageLen = stage.length;
108
+ if (nameLen + 1 >= max) return resourceName.substring(0, max);
109
+ if (nameLen + stageLen + 2 >= max) {
110
+ return `${stage.substring(0, max - nameLen - 1)}-${resourceName}`;
111
+ }
112
+ return `${appName.substring(0, max - stageLen - nameLen - 2)}-${stage}-${resourceName}`;
113
+ }
114
+
74
115
  /**
75
116
  * Does a deploy-written .sst/outputs.json describe this stage? The file is the last deploy on ANY
76
117
  * stage and has no stage field, but its apiFunctionName is `<app>-<stage>-ApiFunction-<hash>`
77
- * (the convention discovery matches on). `.sst/stage` is NOT evidence: it is SST's personal-stage
78
- * marker and can name a different stage than the last deploy. Unknown means no.
118
+ * (the convention discovery matches on) — with the app segment possibly truncated by SST's
119
+ * prefixName (see sstPhysicalNamePrefix). `.sst/stage` is NOT evidence: it is SST's
120
+ * personal-stage marker and can name a different stage than the last deploy. Unknown means no.
79
121
  */
80
122
  export function outputsDescribeStage(
81
123
  outputs: { apiFunctionName?: string } | null | undefined,
@@ -84,7 +126,8 @@ export function outputsDescribeStage(
84
126
  ): boolean {
85
127
  const fn = outputs?.apiFunctionName;
86
128
  if (!fn) return false;
87
- return fn.toLowerCase().startsWith(`${appName}-${stage}-ApiFunction-`.toLowerCase());
129
+ const prefix = sstPhysicalNamePrefix(appName, stage, 'ApiFunction', 64);
130
+ return fn.toLowerCase().startsWith(`${prefix}-`.toLowerCase());
88
131
  }
89
132
 
90
133
  // ---------------------------------------------------------------------------
@@ -102,7 +145,8 @@ interface CfFunctionResult {
102
145
  */
103
146
  export async function discoverCfFunction(
104
147
  region: string,
105
- prefix: string,
148
+ appName: string,
149
+ stage: string,
106
150
  ): Promise<CfFunctionResult> {
107
151
  const {
108
152
  CloudFrontClient,
@@ -111,7 +155,9 @@ export async function discoverCfFunction(
111
155
  } = await import('@aws-sdk/client-cloudfront');
112
156
 
113
157
  const client = new CloudFrontClient({ region });
114
- const needle = `${prefix}RouterCloudfrontFunctionRequest-`.toLowerCase();
158
+ // CloudFront functions share Lambda's 64-char physicalName ceiling (naming.ts namingRules).
159
+ const prefix = sstPhysicalNamePrefix(appName, stage, 'RouterCloudfrontFunctionRequest', 64);
160
+ const needle = `${prefix}-`.toLowerCase();
115
161
 
116
162
  let marker: string | undefined;
117
163
  let matchName: string | undefined;
@@ -134,7 +180,7 @@ export async function discoverCfFunction(
134
180
 
135
181
  if (!matchName) {
136
182
  throw new Error(
137
- `No CloudFront function found matching "${prefix}RouterCloudfrontFunctionRequest-*". ` +
183
+ `No CloudFront function found matching "${prefix}-*". ` +
138
184
  `Check that the stage has been deployed.`,
139
185
  );
140
186
  }
@@ -160,12 +206,14 @@ interface LambdaResult {
160
206
  }
161
207
 
162
208
  /**
163
- * Find a Lambda function by SST resource name prefix.
209
+ * Find a Lambda function by SST resource name, truncating the app segment the way SST's
210
+ * physicalName does (see sstPhysicalNamePrefix) so a long app name still matches.
164
211
  * Returns the function name if found, undefined if not.
165
212
  */
166
213
  export async function discoverFunction(
167
214
  region: string,
168
- prefix: string,
215
+ appName: string,
216
+ stage: string,
169
217
  resourceName: string,
170
218
  ): Promise<string | undefined> {
171
219
  const { LambdaClient, ListFunctionsCommand } = await importOptionalAws(
@@ -173,7 +221,7 @@ export async function discoverFunction(
173
221
  );
174
222
 
175
223
  const client = new LambdaClient({ region });
176
- const needle = `${prefix}${resourceName}`.toLowerCase();
224
+ const needle = sstPhysicalNamePrefix(appName, stage, resourceName, 64).toLowerCase();
177
225
 
178
226
  let marker: string | undefined;
179
227
 
@@ -199,13 +247,15 @@ export async function discoverFunction(
199
247
  */
200
248
  export async function discoverLambda(
201
249
  region: string,
202
- prefix: string,
250
+ appName: string,
251
+ stage: string,
203
252
  ): Promise<LambdaResult> {
204
- const matchName = await discoverFunction(region, prefix, 'ApiFunction');
253
+ const matchName = await discoverFunction(region, appName, stage, 'ApiFunction');
205
254
 
206
255
  if (!matchName) {
256
+ const prefix = sstPhysicalNamePrefix(appName, stage, 'ApiFunction', 64);
207
257
  throw new Error(
208
- `No API Lambda found for prefix "${prefix}ApiFunction-*". ` +
258
+ `No API Lambda found for prefix "${prefix}-*". ` +
209
259
  `Check that the stage has been deployed and you have lambda:ListFunctions permission.`,
210
260
  );
211
261
  }
@@ -243,7 +293,8 @@ interface BucketsResult {
243
293
  */
244
294
  export async function discoverBuckets(
245
295
  region: string,
246
- prefix: string,
296
+ appName: string,
297
+ stage: string,
247
298
  ): Promise<BucketsResult> {
248
299
  const { S3Client, ListBucketsCommand } = await importOptionalAws(
249
300
  () => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'resource discovery',
@@ -253,15 +304,20 @@ export async function discoverBuckets(
253
304
  const res = await client.send(new ListBucketsCommand({}));
254
305
  const buckets = res.Buckets || [];
255
306
 
256
- const updatesNeedle = `${prefix}updatesbucket-`.toLowerCase();
257
- const clientNeedle = `${prefix}clientbundlesbucket-`.toLowerCase();
307
+ // S3 buckets physicalName-cap at 63 chars (naming.ts namingRules: "aws:s3/bucket:Bucket"),
308
+ // one tighter than Lambda/CloudFront functions' 64 — reusing the function budget here would
309
+ // mis-truncate the app segment by a character once truncation actually kicks in.
310
+ const bucketPrefix = (resourceName: string) =>
311
+ sstPhysicalNamePrefix(appName, stage, resourceName, 63);
312
+ const updatesNeedle = `${bucketPrefix('UpdatesBucket')}-`.toLowerCase();
313
+ const clientNeedle = `${bucketPrefix('ClientBundlesBucket')}-`.toLowerCase();
258
314
  // Media bucket resource is named 'Media' (not 'MediaBucket'), so match both patterns
259
- const mediaNeedle = `${prefix}media-`.toLowerCase();
260
- const mediaBucketNeedle = `${prefix}mediabucket-`.toLowerCase();
315
+ const mediaNeedle = `${bucketPrefix('Media')}-`.toLowerCase();
316
+ const mediaBucketNeedle = `${bucketPrefix('MediaBucket')}-`.toLowerCase();
261
317
  // Backups bucket (db:backup/restore/backup:download). Discovered like every other bucket, so the
262
318
  // --stage path carries it — db:backup:download presigns CLIENT-side and needs the name locally
263
319
  // (db:backup/backups run server-side in the ops Lambda, which is why only download failed).
264
- const backupsNeedle = `${prefix}backupsbucket-`.toLowerCase();
320
+ const backupsNeedle = `${bucketPrefix('BackupsBucket')}-`.toLowerCase();
265
321
 
266
322
  let updatesBucket: string | undefined;
267
323
  let clientBundlesBucket: string | undefined;
@@ -293,22 +349,26 @@ export async function discoverConfig(
293
349
  stage: string,
294
350
  region: string,
295
351
  ): Promise<DiscoveredConfig> {
352
+ // Naive, untruncated — only for discoverDistributionMatch (Comment/tag matching, not the
353
+ // physicalName convention the other discover* calls below now truncate correctly for).
296
354
  const prefix = `${appName}-${stage}-`;
297
355
 
298
356
  // Run CF function + Lambda + S3 discovery in parallel
299
357
  const [cfResult, lambdaResult, bucketsResult, opsFn, imageFn, workerFn] = await Promise.all([
300
- discoverCfFunction(region, prefix),
301
- discoverLambda(region, prefix),
302
- discoverBuckets(region, prefix),
303
- discoverFunction(region, prefix, 'OpsFunction').catch(() => undefined),
304
- discoverFunction(region, prefix, 'ImageFunction').catch(() => undefined),
305
- discoverFunction(region, prefix, 'WorkerFunction')
306
- .then(fn => fn || discoverFunction(region, prefix, 'JobsSubscriber'))
358
+ discoverCfFunction(region, appName, stage),
359
+ discoverLambda(region, appName, stage),
360
+ discoverBuckets(region, appName, stage),
361
+ discoverFunction(region, appName, stage, 'OpsFunction').catch(() => undefined),
362
+ discoverFunction(region, appName, stage, 'ImageFunction').catch(() => undefined),
363
+ discoverFunction(region, appName, stage, 'WorkerFunction')
364
+ .then(fn => fn || discoverFunction(region, appName, stage, 'JobsSubscriber'))
307
365
  .catch(() => undefined),
308
366
  ]);
309
367
 
310
368
  // Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
311
369
  let distributionId: string | undefined;
370
+ let distributionHosts: string[] = [];
371
+ let distributionAliases: string[] = [];
312
372
  let distributionDomain: string | undefined;
313
373
  try {
314
374
  const { discoverDistributionMatch } = await import('./discover-distribution');
@@ -323,7 +383,11 @@ export async function discoverConfig(
323
383
  distributionId = match?.id;
324
384
  // A Comment-substring match can name another app's stage; only a confirmed one may say
325
385
  // which site a purge probes.
326
- if (match && match.by !== 'comment') distributionDomain = match.domain;
386
+ if (match && match.by !== 'comment') {
387
+ distributionAliases = match.aliases;
388
+ distributionDomain = match.domain;
389
+ distributionHosts = [...match.aliases, ...(match.domain ? [match.domain] : [])];
390
+ }
327
391
  } catch {
328
392
  // Non-fatal
329
393
  }
@@ -331,18 +395,31 @@ export async function discoverConfig(
331
395
  // CDN_URL is optional on the API Lambda. Without it the stage's own distribution supplies the
332
396
  // probe URL — and only that: baseUrl stays as deployed (see DiscoveredConfig).
333
397
  const baseUrl = lambdaResult.routerUrl || '';
334
- const siteUrl = baseUrl || (distributionDomain ? `https://${distributionDomain}` : '');
398
+ // Gate candidates: every host worth trying, best first. siteUrl is what single-probe commands use
399
+ // with no fallback, so it must not be a guess: CDN_URL, else the ONE alias if there is exactly one,
400
+ // else the cloudfront.net domain (loud on a hostname-gating site, and commands say which host).
401
+ const siteUrls = baseUrl ? [baseUrl] : distributionHosts.map((h) => `https://${h}`);
402
+ const siteAliases = baseUrl ? [] : distributionAliases;
403
+ const siteUrl = baseUrl
404
+ ? baseUrl
405
+ : distributionAliases.length === 1
406
+ ? `https://${distributionAliases[0]}`
407
+ : distributionDomain
408
+ ? `https://${distributionDomain}`
409
+ : '';
335
410
 
336
411
  if (!bucketsResult.updatesBucket) {
412
+ const bucketPrefix = sstPhysicalNamePrefix(appName, stage, 'UpdatesBucket', 63);
337
413
  throw new Error(
338
- `Could not find S3 bucket "${prefix}updatesbucket-*". ` +
414
+ `Could not find S3 bucket "${bucketPrefix}-*". ` +
339
415
  `Check that stage "${stage}" has been deployed and you have s3:ListAllMyBuckets permission.`,
340
416
  );
341
417
  }
342
418
 
343
419
  if (!bucketsResult.clientBundlesBucket) {
420
+ const bucketPrefix = sstPhysicalNamePrefix(appName, stage, 'ClientBundlesBucket', 63);
344
421
  throw new Error(
345
- `Could not find S3 bucket "${prefix}clientbundlesbucket-*". ` +
422
+ `Could not find S3 bucket "${bucketPrefix}-*". ` +
346
423
  `Check that stage "${stage}" has been deployed.`,
347
424
  );
348
425
  }
@@ -368,6 +445,8 @@ export async function discoverConfig(
368
445
  return {
369
446
  baseUrl,
370
447
  siteUrl,
448
+ siteUrls,
449
+ siteAliases,
371
450
  region,
372
451
  apiFunctionName: lambdaResult.functionName,
373
452
  opsFunctionName: opsFn,
@@ -387,13 +466,15 @@ export async function discoverConfig(
387
466
  // ---------------------------------------------------------------------------
388
467
 
389
468
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
390
- const CACHE_VERSION = 6; // Bump when CachedOutputs schema changes to invalidate stale caches (5: +backupsBucket, 6: +siteUrl; getCachedConfig drops v5 files, an empty siteUrl is cached as a fact)
469
+ 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)
391
470
 
392
471
  interface CachedOutputs {
393
472
  _cachedAt: number;
394
473
  _version?: number;
395
474
  routerUrl?: string;
396
475
  siteUrl?: string;
476
+ siteUrls?: string[];
477
+ siteAliases?: string[];
397
478
  apiFunctionName?: string;
398
479
  opsFunctionName?: string;
399
480
  imageFunctionName?: string;
@@ -422,6 +503,8 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
422
503
  return {
423
504
  baseUrl: cached.routerUrl || '',
424
505
  siteUrl: cached.siteUrl ?? cached.routerUrl ?? '',
506
+ siteUrls: cached.siteUrls ?? [],
507
+ siteAliases: cached.siteAliases ?? [],
425
508
  region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
426
509
  apiFunctionName: cached.apiFunctionName,
427
510
  opsFunctionName: cached.opsFunctionName,
@@ -500,6 +583,8 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
500
583
  _version: CACHE_VERSION,
501
584
  routerUrl: config.baseUrl,
502
585
  siteUrl: config.siteUrl,
586
+ siteUrls: config.siteUrls,
587
+ siteAliases: config.siteAliases,
503
588
  apiFunctionName: config.apiFunctionName,
504
589
  opsFunctionName: config.opsFunctionName,
505
590
  imageFunctionName: config.imageFunctionName,
@@ -106,17 +106,30 @@ async function dotenvSetsHostUrl(
106
106
  }
107
107
 
108
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.
109
+ * Why `update` must stop: nothing supplied a HOST_URL, so the OTA bundle would bake the app's
110
+ * default (often localhost) as updates.url. Devices could then never fetch the recovery publish.
111
+ * Undefined when HOST_URL is set (env or dotenv). Applies with OR without --stage — the
112
+ * documented day-to-day flow (getting-started.md) is `update --channel production` with no
113
+ * --stage at all, so a missing HOST_URL there is the SAME harm, not a smaller one: whatever
114
+ * bakes into that channel's bundle ships to real devices just the same. Without a stage there is
115
+ * no auto-discovery to fall back on (no --stage means no CDN_URL lookup, no site URL to offer),
116
+ * so the message says so plainly instead of naming a stage that was never given.
113
117
  */
114
118
  export async function missingHostUrlError(
115
119
  stage: string | undefined,
116
120
  opts: { env?: Record<string, string | undefined>; cwd?: string; siteUrl?: string; resolveFrom?: string } = {},
117
121
  ): Promise<string | undefined> {
118
122
  const { env = process.env, cwd = process.cwd(), siteUrl, resolveFrom = cwd } = opts;
119
- if (!stage || env.HOST_URL || (await dotenvSetsHostUrl(cwd, env, resolveFrom))) return undefined;
123
+ if (env.HOST_URL || (await dotenvSetsHostUrl(cwd, env, resolveFrom))) return undefined;
124
+ if (!stage) {
125
+ return (
126
+ 'No HOST_URL is set (env, dotenv, or a stage\'s .sst/outputs.json), so the bundle would ship ' +
127
+ 'the app\'s default URL as updates.url. Export HOST_URL=<the public URL devices should fetch ' +
128
+ 'updates from>, or re-run with --stage <stage> to discover it from the deployed API function\'s ' +
129
+ 'CDN_URL. (A HOST_URL in a dotenv file counts only when the project\'s Expo can be found to ' +
130
+ 'read it the way `expo export` does.)'
131
+ );
132
+ }
120
133
  const offer = siteUrl ? ` This stage's site URL is ${siteUrl}; export it as HOST_URL if that is its public URL.` : '';
121
134
  return (
122
135
  `No HOST_URL for stage "${stage}", so the bundle would ship the app's default URL as updates.url.${offer} ` +
package/src/cli/index.ts CHANGED
@@ -367,6 +367,7 @@ Usage:
367
367
  everystack cache:purge [--stage <name>] --origin api Bust API origin cache (api|media|web)
368
368
  everystack cache:purge [--stage <name>] --path "/api/posts" Bust specific path cache
369
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)
370
371
  everystack diag [url] [--path /about] [--channel <name>] [--stage <name>] Diagnose page freshness
371
372
  everystack diag [url] --hydration Analyze SSR hydration issues (fetches full HTML)
372
373
  everystack analyze:ssr [--app ./app] Scan app code for SSR anti-patterns
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Splits a `--only a,b,c` flag value into identities.
3
+ *
4
+ * A naive `.split(',')` breaks a function identity like `auth.change_password(text,
5
+ * text)` into two garbage fragments — the argument list's own commas are not
6
+ * separators. This only treats a comma as a separator when it sits outside any
7
+ * parentheses, so `schema.fn(text, text)` survives as one identity.
8
+ */
9
+ export function splitIdentityList(input: string): string[] {
10
+ const identities: string[] = [];
11
+ let depth = 0;
12
+ let current = '';
13
+
14
+ for (const ch of input) {
15
+ if (ch === '(') depth++;
16
+ else if (ch === ')') depth = Math.max(0, depth - 1);
17
+
18
+ if (ch === ',' && depth === 0) {
19
+ identities.push(current.trim());
20
+ current = '';
21
+ continue;
22
+ }
23
+ current += ch;
24
+ }
25
+ identities.push(current.trim());
26
+
27
+ return identities.filter(Boolean);
28
+ }