@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 +6 -0
- package/package.json +1 -1
- package/src/cli/commands/audit.ts +3 -1
- package/src/cli/commands/bundle.ts +5 -2
- package/src/cli/commands/cache.ts +76 -16
- package/src/cli/commands/db-reconcile.ts +2 -1
- package/src/cli/commands/db-refresh.ts +2 -1
- package/src/cli/commands/db.ts +28 -10
- package/src/cli/commands/diag.ts +3 -1
- package/src/cli/commands/lighthouse.ts +3 -1
- package/src/cli/commands/security-probe.ts +3 -1
- package/src/cli/commands/update.ts +9 -6
- package/src/cli/config.ts +19 -0
- package/src/cli/discover-distribution.ts +15 -3
- package/src/cli/discover.ts +115 -30
- package/src/cli/host-url.ts +18 -5
- package/src/cli/index.ts +1 -0
- package/src/cli/utils/identity-list.ts +28 -0
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
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
137
|
-
|
|
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
|
-
'
|
|
141
|
-
'
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 ${
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
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
|
|
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';
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
package/src/cli/commands/diag.ts
CHANGED
|
@@ -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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/discover.ts
CHANGED
|
@@ -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)
|
|
78
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
250
|
+
appName: string,
|
|
251
|
+
stage: string,
|
|
203
252
|
): Promise<LambdaResult> {
|
|
204
|
-
const matchName = await discoverFunction(region,
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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 = `${
|
|
260
|
-
const mediaBucketNeedle = `${
|
|
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 = `${
|
|
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,
|
|
301
|
-
discoverLambda(region,
|
|
302
|
-
discoverBuckets(region,
|
|
303
|
-
discoverFunction(region,
|
|
304
|
-
discoverFunction(region,
|
|
305
|
-
discoverFunction(region,
|
|
306
|
-
.then(fn => fn || discoverFunction(region,
|
|
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')
|
|
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
|
-
|
|
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 "${
|
|
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 "${
|
|
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 =
|
|
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,
|
package/src/cli/host-url.ts
CHANGED
|
@@ -106,17 +106,30 @@ async function dotenvSetsHostUrl(
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
* Why `update
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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 (
|
|
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
|
+
}
|