@everystack/cli 0.4.19 → 0.4.20
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.ts +110 -13
- package/src/cli/commands/update.ts +58 -4
- package/src/cli/index.ts +1 -1
package/package.json
CHANGED
package/src/cli/commands/db.ts
CHANGED
|
@@ -207,9 +207,54 @@ export function buildProvisionSecretPlan(args: {
|
|
|
207
207
|
return { updates, notes, warnings };
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/** The provisioning core's shape (mirrors @everystack/server/provision — no compile-time dep,
|
|
211
|
+
* same seam as pipeline-loader). */
|
|
212
|
+
export interface ServerProvision {
|
|
213
|
+
runProvision(
|
|
214
|
+
payload: { authPassword?: string; adminPassword?: string },
|
|
215
|
+
deps: {
|
|
216
|
+
execute: (sql: string) => Promise<unknown>;
|
|
217
|
+
connection?: { host: string; port?: number | string; database: string } | null;
|
|
218
|
+
connectProbe?: (url: string) => Promise<void>;
|
|
219
|
+
},
|
|
220
|
+
): Promise<any>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Lazily load the provisioning core from the @everystack/server peer (runtime only —
|
|
225
|
+
* the CLI keeps no compile-time dependency on the server package). A missing peer gets
|
|
226
|
+
* the install remedy, not a raw ERR_MODULE_NOT_FOUND.
|
|
227
|
+
*/
|
|
228
|
+
export async function loadServerProvision(importer?: () => Promise<unknown>): Promise<ServerProvision> {
|
|
229
|
+
const spec = '@everystack/server/provision';
|
|
230
|
+
try {
|
|
231
|
+
return (await (importer ? importer() : import(spec))) as ServerProvision;
|
|
232
|
+
} catch (err: unknown) {
|
|
233
|
+
const code = (err as { code?: string })?.code;
|
|
234
|
+
const message = String((err as { message?: string })?.message ?? err);
|
|
235
|
+
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND' || /Cannot find (package|module)/.test(message)) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
'db:provision --database-url needs @everystack/server (>= 0.4.6) installed in this app — '
|
|
238
|
+
+ 'it carries the provisioning logic:\n pnpm add @everystack/server',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** host/port/database from a postgres URL — the direct venue's connection info. */
|
|
246
|
+
export function parseUrlConnection(url: string): { host: string; port: string; database: string } {
|
|
247
|
+
const u = new URL(url);
|
|
248
|
+
return {
|
|
249
|
+
host: u.hostname,
|
|
250
|
+
port: u.port || '5432',
|
|
251
|
+
database: u.pathname.replace(/^\//, ''),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
210
255
|
export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
|
|
211
256
|
if (!flags.stage) {
|
|
212
|
-
fail('--stage is required for db:provision (it writes the DATABASE_URL secret
|
|
257
|
+
fail('--stage is required for db:provision (it writes the stage\'s DATABASE_URL secret — the new credentials are never displayed, so they must land in the secret store)');
|
|
213
258
|
process.exit(1);
|
|
214
259
|
}
|
|
215
260
|
|
|
@@ -222,7 +267,16 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
222
267
|
process.exit(1);
|
|
223
268
|
}
|
|
224
269
|
|
|
225
|
-
|
|
270
|
+
// Venue: an explicit --database-url runs the role chain over a DIRECT connection — the
|
|
271
|
+
// bring-your-own-RDS shape, where the operator holds the master URL and the deployed app
|
|
272
|
+
// has no dbPlugin/Ops Lambda to dispatch to. --stage still names the secret store the new
|
|
273
|
+
// credentials are written to. No flag = the ops-Lambda venue, unchanged.
|
|
274
|
+
const directUrl = flags['database-url'];
|
|
275
|
+
if (directUrl) {
|
|
276
|
+
info(`Region: ${config.region}, venue: direct connection (bring-your-own database)`);
|
|
277
|
+
} else {
|
|
278
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
279
|
+
}
|
|
226
280
|
info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
|
|
227
281
|
step('Provisioning roles...');
|
|
228
282
|
|
|
@@ -233,17 +287,58 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
233
287
|
const adminPassword = randomBytes(24).toString('hex');
|
|
234
288
|
|
|
235
289
|
let result: any;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
290
|
+
let conn: any;
|
|
291
|
+
if (directUrl) {
|
|
292
|
+
try {
|
|
293
|
+
const { runProvision } = await loadServerProvision();
|
|
294
|
+
const { createUrlRunner } = await import('../db-source.js');
|
|
295
|
+
const { runner, end } = await createUrlRunner(directUrl);
|
|
296
|
+
try {
|
|
297
|
+
result = await runProvision({ authPassword, adminPassword }, {
|
|
298
|
+
execute: async (statement) => runner(statement),
|
|
299
|
+
connection: parseUrlConnection(directUrl),
|
|
300
|
+
// Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
|
|
301
|
+
connectProbe: async (probeUrl) => {
|
|
302
|
+
const { default: postgres } = await import('postgres');
|
|
303
|
+
const probe = postgres(probeUrl, { max: 1, connect_timeout: 5, ssl: 'prefer' });
|
|
304
|
+
try {
|
|
305
|
+
await probe`SELECT 1`;
|
|
306
|
+
await probe.unsafe('CREATE TEMP TABLE _everystack_provision_probe (x int)');
|
|
307
|
+
await probe.unsafe('DROP TABLE _everystack_provision_probe');
|
|
308
|
+
} finally {
|
|
309
|
+
await probe.end({ timeout: 1 });
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
} finally {
|
|
314
|
+
await end?.();
|
|
315
|
+
}
|
|
316
|
+
} catch (err: any) {
|
|
317
|
+
fail(`db:provision failed: ${err.message}`);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
conn = parseUrlConnection(directUrl);
|
|
321
|
+
} else {
|
|
322
|
+
try {
|
|
323
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
|
|
324
|
+
} catch (err: any) {
|
|
325
|
+
fail(`db:provision failed: ${err.message}`);
|
|
326
|
+
if (/Unknown action/i.test(String(err.message))) {
|
|
327
|
+
info('The deployed handler has no dbPlugin (no Ops Lambda). For a bring-your-own database,');
|
|
328
|
+
info('run the DIRECT venue instead: everystack db:provision --stage <stage> --database-url <master-url>');
|
|
329
|
+
}
|
|
330
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
242
333
|
}
|
|
243
334
|
if (result?.error) {
|
|
244
335
|
// Includes the admin-verification refusal: the action set NO authenticator password,
|
|
245
336
|
// so nothing here rewrites DATABASE_URL — the working credentials are untouched.
|
|
246
337
|
fail(`db:provision failed: ${result.error}`);
|
|
338
|
+
if (!directUrl && /Unknown action/i.test(String(result.error))) {
|
|
339
|
+
info('The deployed handler has no dbPlugin (no Ops Lambda). For a bring-your-own database,');
|
|
340
|
+
info('run the DIRECT venue instead: everystack db:provision --stage <stage> --database-url <master-url>');
|
|
341
|
+
}
|
|
247
342
|
process.exit(1);
|
|
248
343
|
}
|
|
249
344
|
success(`Role chain ready: ${result.loginRole} (LOGIN, NOINHERIT, no BYPASSRLS) → can SET ROLE to ${result.appRoles.join(', ')}`);
|
|
@@ -252,11 +347,13 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
252
347
|
}
|
|
253
348
|
|
|
254
349
|
// Resolve host/port/db (not secret) to build the connection strings in memory.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
350
|
+
// Direct venue already knows them from the URL itself.
|
|
351
|
+
if (!directUrl) {
|
|
352
|
+
try {
|
|
353
|
+
conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
|
|
354
|
+
} catch {
|
|
355
|
+
/* handled below */
|
|
356
|
+
}
|
|
260
357
|
}
|
|
261
358
|
if (!conn?.host) {
|
|
262
359
|
fail('Could not resolve the database host to build DATABASE_URL.');
|
|
@@ -32,6 +32,46 @@ export interface UpdateFlags {
|
|
|
32
32
|
export?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The runtimeVersion as a STRING — the only shape the release path can carry.
|
|
37
|
+
* Expo's config allows a policy object; interpolating one into the S3 key
|
|
38
|
+
* produced `releases/dev/[object Object]/…` (a consumer field report). Locally
|
|
39
|
+
* derivable policies resolve here; the build-time ones (fingerprint,
|
|
40
|
+
* nativeVersion — computed by native tooling, undefined for a web publish)
|
|
41
|
+
* are refused with the remedies instead of silently corrupting the key.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveRuntimeVersion(appConfig: {
|
|
44
|
+
runtimeVersion?: unknown;
|
|
45
|
+
version?: string;
|
|
46
|
+
sdkVersion?: string;
|
|
47
|
+
}): string {
|
|
48
|
+
const rv = appConfig.runtimeVersion;
|
|
49
|
+
if (typeof rv === 'string') return rv;
|
|
50
|
+
const policy = (rv as { policy?: string } | undefined)?.policy;
|
|
51
|
+
if (policy === 'appVersion') {
|
|
52
|
+
if (!appConfig.version) {
|
|
53
|
+
throw new Error("runtimeVersion policy 'appVersion' needs a \"version\" field in app.json — add one, or set an explicit string runtimeVersion.");
|
|
54
|
+
}
|
|
55
|
+
return appConfig.version;
|
|
56
|
+
}
|
|
57
|
+
if (policy === 'sdkVersion') {
|
|
58
|
+
if (!appConfig.sdkVersion) {
|
|
59
|
+
throw new Error("runtimeVersion policy 'sdkVersion' needs an \"sdkVersion\" field in app.json — add one, or set an explicit string runtimeVersion.");
|
|
60
|
+
}
|
|
61
|
+
return appConfig.sdkVersion;
|
|
62
|
+
}
|
|
63
|
+
if (policy === 'fingerprint' || policy === 'nativeVersion') {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`runtimeVersion policy '${policy}' is computed by the native build tooling (EAS / @expo/fingerprint) — `
|
|
66
|
+
+ 'everystack update cannot resolve it, and for a web publish it is undefined. '
|
|
67
|
+
+ 'Use an explicit string runtimeVersion, or { "policy": "appVersion" } to track your app version.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Unsupported runtimeVersion ${JSON.stringify(rv)} — use an explicit string, { "policy": "appVersion" }, or { "policy": "sdkVersion" }.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
35
75
|
export async function updateCommand(flags: UpdateFlags & Record<string, string>): Promise<void> {
|
|
36
76
|
// Signal publish context so app.config.js can detect it's not a dev session.
|
|
37
77
|
// Without this, dotenv guards that skip .env.local for EAS_BUILD/EAS_UPDATE
|
|
@@ -98,15 +138,22 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
98
138
|
// Read app config
|
|
99
139
|
step('Reading app config...');
|
|
100
140
|
const appConfig = await loadAppConfig();
|
|
101
|
-
const runtimeVersion = appConfig.runtimeVersion;
|
|
102
141
|
|
|
103
|
-
if (!runtimeVersion) {
|
|
142
|
+
if (!appConfig.runtimeVersion) {
|
|
104
143
|
fail('No runtimeVersion found in app.json/app.config.js');
|
|
105
144
|
info('Add "runtimeVersion" to your app.json, e.g.:');
|
|
106
|
-
info(' { "expo": { "runtimeVersion":
|
|
145
|
+
info(' { "expo": { "runtimeVersion": "1.0.0" } } (explicit string)');
|
|
146
|
+
info(' { "expo": { "runtimeVersion": { "policy": "appVersion" } } } (tracks "version")');
|
|
107
147
|
info('See: https://docs.expo.dev/eas-update/runtime-versions/');
|
|
108
148
|
process.exit(1);
|
|
109
149
|
}
|
|
150
|
+
let runtimeVersion: string;
|
|
151
|
+
try {
|
|
152
|
+
runtimeVersion = resolveRuntimeVersion(appConfig);
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
fail(err.message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
110
157
|
success(`Runtime version: ${runtimeVersion}`);
|
|
111
158
|
|
|
112
159
|
const distDir = path.resolve('dist');
|
|
@@ -561,13 +608,20 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
561
608
|
);
|
|
562
609
|
if (result?.error) {
|
|
563
610
|
info(`DB mirror skipped: ${result.error}`);
|
|
611
|
+
// The dispatcher answers Unknown action as result.error, not a throw — explain
|
|
612
|
+
// there too, or the miss reads like a failure with no remedy.
|
|
613
|
+
if (/Unknown action/i.test(String(result.error))) {
|
|
614
|
+
info('This is optional — the S3 manifests are the source of truth for serving.');
|
|
615
|
+
info('For DB-backed release tracking, add updatesPlugin (from @everystack/cli/plugin) to the deployed handler.');
|
|
616
|
+
}
|
|
564
617
|
} else {
|
|
565
618
|
info(`Release mirrored to DB: channel=${result?.channel || channel}`);
|
|
566
619
|
}
|
|
567
620
|
} catch (err: any) {
|
|
568
621
|
// register-web is optional — the S3 manifests are the source of truth.
|
|
569
622
|
if (err.message?.includes('Unknown action')) {
|
|
570
|
-
info('
|
|
623
|
+
info('DB mirror skipped (optional — S3 manifests are the source of truth).');
|
|
624
|
+
info('For DB-backed release tracking, add updatesPlugin (from @everystack/cli/plugin) to the deployed handler.');
|
|
571
625
|
} else {
|
|
572
626
|
info(`DB mirror skipped: ${err.message}`);
|
|
573
627
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -353,7 +353,7 @@ Usage:
|
|
|
353
353
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
354
354
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
355
355
|
everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
356
|
-
everystack db:provision
|
|
356
|
+
everystack db:provision --stage <name> [--database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). --database-url = DIRECT venue for a bring-your-own database (no Ops Lambda needed); --stage names the secret store either way
|
|
357
357
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
358
358
|
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
359
359
|
everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
|