@everystack/cli 0.4.87 → 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,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.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>",
@@ -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).
@@ -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[];
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,
@@ -33,13 +33,16 @@ interface DistributionSummaryLite {
33
33
  */
34
34
  export interface DistributionMatch {
35
35
  id: string;
36
- /** cloudfront.net domain, no scheme — the host this distribution always answers on. */
36
+ /** cloudfront.net domain, no scheme. A CloudFront Function may still answer 403 on it. */
37
37
  domain?: string;
38
+ /** Concrete custom-domain aliases (wildcards dropped), in CloudFront's order: the site's real hostnames. */
39
+ aliases: string[];
38
40
  by: 'host' | 'comment' | 'tag';
39
41
  }
40
42
 
41
43
  function toMatch(dist: DistributionSummaryLite, by: DistributionMatch['by']): DistributionMatch | undefined {
42
- return dist.Id ? { id: dist.Id, domain: dist.DomainName, by } : undefined;
44
+ const aliases = (dist.Aliases?.Items ?? []).filter((a) => !a.includes('*'));
45
+ return dist.Id ? { id: dist.Id, domain: dist.DomainName, aliases, by } : undefined;
43
46
  }
44
47
 
45
48
  function matchByHost(items: readonly DistributionSummaryLite[], routerHost?: string): DistributionMatch | undefined {
@@ -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;
@@ -309,6 +317,8 @@ export async function discoverConfig(
309
317
 
310
318
  // Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
311
319
  let distributionId: string | undefined;
320
+ let distributionHosts: string[] = [];
321
+ let distributionAliases: string[] = [];
312
322
  let distributionDomain: string | undefined;
313
323
  try {
314
324
  const { discoverDistributionMatch } = await import('./discover-distribution');
@@ -323,7 +333,11 @@ export async function discoverConfig(
323
333
  distributionId = match?.id;
324
334
  // A Comment-substring match can name another app's stage; only a confirmed one may say
325
335
  // which site a purge probes.
326
- if (match && match.by !== 'comment') distributionDomain = match.domain;
336
+ if (match && match.by !== 'comment') {
337
+ distributionAliases = match.aliases;
338
+ distributionDomain = match.domain;
339
+ distributionHosts = [...match.aliases, ...(match.domain ? [match.domain] : [])];
340
+ }
327
341
  } catch {
328
342
  // Non-fatal
329
343
  }
@@ -331,7 +345,18 @@ export async function discoverConfig(
331
345
  // CDN_URL is optional on the API Lambda. Without it the stage's own distribution supplies the
332
346
  // probe URL — and only that: baseUrl stays as deployed (see DiscoveredConfig).
333
347
  const baseUrl = lambdaResult.routerUrl || '';
334
- const siteUrl = baseUrl || (distributionDomain ? `https://${distributionDomain}` : '');
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
+ : '';
335
360
 
336
361
  if (!bucketsResult.updatesBucket) {
337
362
  throw new Error(
@@ -368,6 +393,8 @@ export async function discoverConfig(
368
393
  return {
369
394
  baseUrl,
370
395
  siteUrl,
396
+ siteUrls,
397
+ siteAliases,
371
398
  region,
372
399
  apiFunctionName: lambdaResult.functionName,
373
400
  opsFunctionName: opsFn,
@@ -387,13 +414,15 @@ export async function discoverConfig(
387
414
  // ---------------------------------------------------------------------------
388
415
 
389
416
  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)
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)
391
418
 
392
419
  interface CachedOutputs {
393
420
  _cachedAt: number;
394
421
  _version?: number;
395
422
  routerUrl?: string;
396
423
  siteUrl?: string;
424
+ siteUrls?: string[];
425
+ siteAliases?: string[];
397
426
  apiFunctionName?: string;
398
427
  opsFunctionName?: string;
399
428
  imageFunctionName?: string;
@@ -422,6 +451,8 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
422
451
  return {
423
452
  baseUrl: cached.routerUrl || '',
424
453
  siteUrl: cached.siteUrl ?? cached.routerUrl ?? '',
454
+ siteUrls: cached.siteUrls ?? [],
455
+ siteAliases: cached.siteAliases ?? [],
425
456
  region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
426
457
  apiFunctionName: cached.apiFunctionName,
427
458
  opsFunctionName: cached.opsFunctionName,
@@ -500,6 +531,8 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
500
531
  _version: CACHE_VERSION,
501
532
  routerUrl: config.baseUrl,
502
533
  siteUrl: config.siteUrl,
534
+ siteUrls: config.siteUrls,
535
+ siteAliases: config.siteAliases,
503
536
  apiFunctionName: config.apiFunctionName,
504
537
  opsFunctionName: config.opsFunctionName,
505
538
  imageFunctionName: config.imageFunctionName,
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