@everystack/cli 0.4.88 → 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/package.json +1 -1
- 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/update.ts +9 -6
- package/src/cli/discover-distribution.ts +10 -1
- package/src/cli/discover.ts +79 -27
- package/src/cli/host-url.ts +18 -5
- package/src/cli/utils/identity-list.ts +28 -0
package/package.json
CHANGED
|
@@ -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
|
|
@@ -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;
|
|
@@ -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;
|
|
@@ -112,6 +114,7 @@ export async function discoverDistributionMatch(
|
|
|
112
114
|
// Tag pass — authoritative, so it runs before the Comment guess (a Comment substring can name
|
|
113
115
|
// another app's stage). One call per distribution; SST tags every distribution it makes.
|
|
114
116
|
if (hints.appName && hints.stage) {
|
|
117
|
+
let warnedOnTagReadFailure = false;
|
|
115
118
|
for (const dist of items) {
|
|
116
119
|
if (!dist.ARN) continue;
|
|
117
120
|
try {
|
|
@@ -123,7 +126,13 @@ export async function discoverDistributionMatch(
|
|
|
123
126
|
return toMatch(dist, 'tag');
|
|
124
127
|
}
|
|
125
128
|
} catch {
|
|
126
|
-
// 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
|
+
}
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
138
|
}
|
package/src/cli/discover.ts
CHANGED
|
@@ -79,11 +79,45 @@ export async function parseAppName(configPath?: string): Promise<string> {
|
|
|
79
79
|
return match[1];
|
|
80
80
|
}
|
|
81
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
|
+
|
|
82
115
|
/**
|
|
83
116
|
* Does a deploy-written .sst/outputs.json describe this stage? The file is the last deploy on ANY
|
|
84
117
|
* stage and has no stage field, but its apiFunctionName is `<app>-<stage>-ApiFunction-<hash>`
|
|
85
|
-
* (the convention discovery matches on)
|
|
86
|
-
*
|
|
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.
|
|
87
121
|
*/
|
|
88
122
|
export function outputsDescribeStage(
|
|
89
123
|
outputs: { apiFunctionName?: string } | null | undefined,
|
|
@@ -92,7 +126,8 @@ export function outputsDescribeStage(
|
|
|
92
126
|
): boolean {
|
|
93
127
|
const fn = outputs?.apiFunctionName;
|
|
94
128
|
if (!fn) return false;
|
|
95
|
-
|
|
129
|
+
const prefix = sstPhysicalNamePrefix(appName, stage, 'ApiFunction', 64);
|
|
130
|
+
return fn.toLowerCase().startsWith(`${prefix}-`.toLowerCase());
|
|
96
131
|
}
|
|
97
132
|
|
|
98
133
|
// ---------------------------------------------------------------------------
|
|
@@ -110,7 +145,8 @@ interface CfFunctionResult {
|
|
|
110
145
|
*/
|
|
111
146
|
export async function discoverCfFunction(
|
|
112
147
|
region: string,
|
|
113
|
-
|
|
148
|
+
appName: string,
|
|
149
|
+
stage: string,
|
|
114
150
|
): Promise<CfFunctionResult> {
|
|
115
151
|
const {
|
|
116
152
|
CloudFrontClient,
|
|
@@ -119,7 +155,9 @@ export async function discoverCfFunction(
|
|
|
119
155
|
} = await import('@aws-sdk/client-cloudfront');
|
|
120
156
|
|
|
121
157
|
const client = new CloudFrontClient({ region });
|
|
122
|
-
|
|
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();
|
|
123
161
|
|
|
124
162
|
let marker: string | undefined;
|
|
125
163
|
let matchName: string | undefined;
|
|
@@ -142,7 +180,7 @@ export async function discoverCfFunction(
|
|
|
142
180
|
|
|
143
181
|
if (!matchName) {
|
|
144
182
|
throw new Error(
|
|
145
|
-
`No CloudFront function found matching "${prefix}
|
|
183
|
+
`No CloudFront function found matching "${prefix}-*". ` +
|
|
146
184
|
`Check that the stage has been deployed.`,
|
|
147
185
|
);
|
|
148
186
|
}
|
|
@@ -168,12 +206,14 @@ interface LambdaResult {
|
|
|
168
206
|
}
|
|
169
207
|
|
|
170
208
|
/**
|
|
171
|
-
* 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.
|
|
172
211
|
* Returns the function name if found, undefined if not.
|
|
173
212
|
*/
|
|
174
213
|
export async function discoverFunction(
|
|
175
214
|
region: string,
|
|
176
|
-
|
|
215
|
+
appName: string,
|
|
216
|
+
stage: string,
|
|
177
217
|
resourceName: string,
|
|
178
218
|
): Promise<string | undefined> {
|
|
179
219
|
const { LambdaClient, ListFunctionsCommand } = await importOptionalAws(
|
|
@@ -181,7 +221,7 @@ export async function discoverFunction(
|
|
|
181
221
|
);
|
|
182
222
|
|
|
183
223
|
const client = new LambdaClient({ region });
|
|
184
|
-
const needle =
|
|
224
|
+
const needle = sstPhysicalNamePrefix(appName, stage, resourceName, 64).toLowerCase();
|
|
185
225
|
|
|
186
226
|
let marker: string | undefined;
|
|
187
227
|
|
|
@@ -207,13 +247,15 @@ export async function discoverFunction(
|
|
|
207
247
|
*/
|
|
208
248
|
export async function discoverLambda(
|
|
209
249
|
region: string,
|
|
210
|
-
|
|
250
|
+
appName: string,
|
|
251
|
+
stage: string,
|
|
211
252
|
): Promise<LambdaResult> {
|
|
212
|
-
const matchName = await discoverFunction(region,
|
|
253
|
+
const matchName = await discoverFunction(region, appName, stage, 'ApiFunction');
|
|
213
254
|
|
|
214
255
|
if (!matchName) {
|
|
256
|
+
const prefix = sstPhysicalNamePrefix(appName, stage, 'ApiFunction', 64);
|
|
215
257
|
throw new Error(
|
|
216
|
-
`No API Lambda found for prefix "${prefix}
|
|
258
|
+
`No API Lambda found for prefix "${prefix}-*". ` +
|
|
217
259
|
`Check that the stage has been deployed and you have lambda:ListFunctions permission.`,
|
|
218
260
|
);
|
|
219
261
|
}
|
|
@@ -251,7 +293,8 @@ interface BucketsResult {
|
|
|
251
293
|
*/
|
|
252
294
|
export async function discoverBuckets(
|
|
253
295
|
region: string,
|
|
254
|
-
|
|
296
|
+
appName: string,
|
|
297
|
+
stage: string,
|
|
255
298
|
): Promise<BucketsResult> {
|
|
256
299
|
const { S3Client, ListBucketsCommand } = await importOptionalAws(
|
|
257
300
|
() => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'resource discovery',
|
|
@@ -261,15 +304,20 @@ export async function discoverBuckets(
|
|
|
261
304
|
const res = await client.send(new ListBucketsCommand({}));
|
|
262
305
|
const buckets = res.Buckets || [];
|
|
263
306
|
|
|
264
|
-
|
|
265
|
-
|
|
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();
|
|
266
314
|
// Media bucket resource is named 'Media' (not 'MediaBucket'), so match both patterns
|
|
267
|
-
const mediaNeedle = `${
|
|
268
|
-
const mediaBucketNeedle = `${
|
|
315
|
+
const mediaNeedle = `${bucketPrefix('Media')}-`.toLowerCase();
|
|
316
|
+
const mediaBucketNeedle = `${bucketPrefix('MediaBucket')}-`.toLowerCase();
|
|
269
317
|
// Backups bucket (db:backup/restore/backup:download). Discovered like every other bucket, so the
|
|
270
318
|
// --stage path carries it — db:backup:download presigns CLIENT-side and needs the name locally
|
|
271
319
|
// (db:backup/backups run server-side in the ops Lambda, which is why only download failed).
|
|
272
|
-
const backupsNeedle = `${
|
|
320
|
+
const backupsNeedle = `${bucketPrefix('BackupsBucket')}-`.toLowerCase();
|
|
273
321
|
|
|
274
322
|
let updatesBucket: string | undefined;
|
|
275
323
|
let clientBundlesBucket: string | undefined;
|
|
@@ -301,17 +349,19 @@ export async function discoverConfig(
|
|
|
301
349
|
stage: string,
|
|
302
350
|
region: string,
|
|
303
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).
|
|
304
354
|
const prefix = `${appName}-${stage}-`;
|
|
305
355
|
|
|
306
356
|
// Run CF function + Lambda + S3 discovery in parallel
|
|
307
357
|
const [cfResult, lambdaResult, bucketsResult, opsFn, imageFn, workerFn] = await Promise.all([
|
|
308
|
-
discoverCfFunction(region,
|
|
309
|
-
discoverLambda(region,
|
|
310
|
-
discoverBuckets(region,
|
|
311
|
-
discoverFunction(region,
|
|
312
|
-
discoverFunction(region,
|
|
313
|
-
discoverFunction(region,
|
|
314
|
-
.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'))
|
|
315
365
|
.catch(() => undefined),
|
|
316
366
|
]);
|
|
317
367
|
|
|
@@ -359,15 +409,17 @@ export async function discoverConfig(
|
|
|
359
409
|
: '';
|
|
360
410
|
|
|
361
411
|
if (!bucketsResult.updatesBucket) {
|
|
412
|
+
const bucketPrefix = sstPhysicalNamePrefix(appName, stage, 'UpdatesBucket', 63);
|
|
362
413
|
throw new Error(
|
|
363
|
-
`Could not find S3 bucket "${
|
|
414
|
+
`Could not find S3 bucket "${bucketPrefix}-*". ` +
|
|
364
415
|
`Check that stage "${stage}" has been deployed and you have s3:ListAllMyBuckets permission.`,
|
|
365
416
|
);
|
|
366
417
|
}
|
|
367
418
|
|
|
368
419
|
if (!bucketsResult.clientBundlesBucket) {
|
|
420
|
+
const bucketPrefix = sstPhysicalNamePrefix(appName, stage, 'ClientBundlesBucket', 63);
|
|
369
421
|
throw new Error(
|
|
370
|
-
`Could not find S3 bucket "${
|
|
422
|
+
`Could not find S3 bucket "${bucketPrefix}-*". ` +
|
|
371
423
|
`Check that stage "${stage}" has been deployed.`,
|
|
372
424
|
);
|
|
373
425
|
}
|
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} ` +
|
|
@@ -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
|
+
}
|