@everystack/cli 0.4.61 → 0.4.64

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.61",
3
+ "version": "0.4.64",
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>",
@@ -23,70 +23,87 @@
23
23
  ],
24
24
  "exports": {
25
25
  ".": {
26
+ "source": "./src/index.ts",
26
27
  "types": "./src/index.ts",
27
28
  "default": "./src/index.ts"
28
29
  },
29
30
  "./model": {
31
+ "source": "./src/cli/model-api.ts",
30
32
  "types": "./src/cli/model-api.ts",
31
33
  "default": "./src/cli/model-api.ts"
32
34
  },
33
35
  "./audit": {
36
+ "source": "./src/cli/audit-api.ts",
34
37
  "types": "./src/cli/audit-api.ts",
35
38
  "default": "./src/cli/audit-api.ts"
36
39
  },
37
40
  "./apply": {
41
+ "source": "./src/cli/apply-execute.ts",
38
42
  "types": "./src/cli/apply-execute.ts",
39
43
  "default": "./src/cli/apply-execute.ts"
40
44
  },
41
45
  "./db-source": {
46
+ "source": "./src/cli/db-source.ts",
42
47
  "types": "./src/cli/db-source.ts",
43
48
  "default": "./src/cli/db-source.ts"
44
49
  },
45
50
  "./reconcile": {
51
+ "source": "./src/reconcile.ts",
46
52
  "types": "./src/reconcile.ts",
47
53
  "default": "./src/reconcile.ts"
48
54
  },
49
55
  "./refresh": {
56
+ "source": "./src/refresh.ts",
50
57
  "types": "./src/refresh.ts",
51
58
  "default": "./src/refresh.ts"
52
59
  },
53
60
  "./backfill": {
61
+ "source": "./src/backfill.ts",
54
62
  "types": "./src/backfill.ts",
55
63
  "default": "./src/backfill.ts"
56
64
  },
57
65
  "./exec": {
66
+ "source": "./src/exec.ts",
58
67
  "types": "./src/exec.ts",
59
68
  "default": "./src/exec.ts"
60
69
  },
61
70
  "./audit/source": {
71
+ "source": "./src/cli/audit-source-api.ts",
62
72
  "types": "./src/cli/audit-source-api.ts",
63
73
  "default": "./src/cli/audit-source-api.ts"
64
74
  },
65
75
  "./runbook": {
76
+ "source": "./src/cli/runbook/index.ts",
66
77
  "types": "./src/cli/runbook/index.ts",
67
78
  "default": "./src/cli/runbook/index.ts"
68
79
  },
69
80
  "./client": {
81
+ "source": "./src/client/index.ts",
70
82
  "types": "./src/client/index.ts",
71
83
  "default": "./src/client/index.ts"
72
84
  },
73
85
  "./schema": {
86
+ "source": "./src/schema.ts",
74
87
  "types": "./src/schema.ts",
75
88
  "default": "./src/schema.ts"
76
89
  },
77
90
  "./handler": {
91
+ "source": "./src/handler/index.ts",
78
92
  "types": "./src/handler/index.ts",
79
93
  "default": "./src/handler/index.ts"
80
94
  },
81
95
  "./plugin": {
96
+ "source": "./src/plugin.ts",
82
97
  "types": "./src/plugin.ts",
83
98
  "default": "./src/plugin.ts"
84
99
  },
85
100
  "./env": {
101
+ "source": "./src/env.ts",
86
102
  "types": "./src/env.ts",
87
103
  "default": "./src/env.js"
88
104
  },
89
105
  "./sst": {
106
+ "source": "./src/sst.ts",
90
107
  "types": "./src/sst.ts",
91
108
  "default": "./src/sst.ts"
92
109
  }
@@ -160,7 +177,7 @@
160
177
  "jest": "29.7.0",
161
178
  "react": "19.2.0",
162
179
  "ts-jest": "29.4.9",
163
- "@everystack/server": "0.4.22"
180
+ "@everystack/server": "0.4.25"
164
181
  },
165
182
  "scripts": {
166
183
  "test": "jest",
package/src/cli/aws.ts CHANGED
@@ -734,10 +734,14 @@ export async function describeRdsSnapshots(
734
734
  *
735
735
  * The call is a no-code-upload configuration change — it completes in ~2s,
736
736
  * and Lambda drains in-flight requests before retiring each environment.
737
+ *
738
+ * Optional extraEnv merges additional env vars (e.g. rotated credentials).
739
+ * Existing env vars are preserved (read-modify-write).
737
740
  */
738
741
  export async function rollingRestart(
739
742
  region: string,
740
743
  functionName: string,
744
+ extraEnv?: Record<string, string>,
741
745
  ): Promise<{ previousGen: string; newGen: string }> {
742
746
  const client = await getLambda(region);
743
747
  const {
@@ -755,7 +759,7 @@ export async function rollingRestart(
755
759
  await client.send(new UpdateFunctionConfigurationCommand({
756
760
  FunctionName: functionName,
757
761
  Environment: {
758
- Variables: { ...env, EVERYSTACK_GEN: newGen },
762
+ Variables: { ...env, ...extraEnv, EVERYSTACK_GEN: newGen },
759
763
  },
760
764
  }));
761
765
 
@@ -258,7 +258,7 @@ export function buildProvisionSecretPlan(args: {
258
258
  * same seam as pipeline-loader). */
259
259
  export interface ServerProvision {
260
260
  runProvision(
261
- payload: { authPassword?: string; adminPassword?: string; searchPath?: string[] },
261
+ payload: { authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean },
262
262
  deps: {
263
263
  execute: (sql: string) => Promise<unknown>;
264
264
  connection?: { host: string; port?: number | string; database: string } | null;
@@ -471,12 +471,277 @@ export function parseUrlConnection(url: string): { host: string; port: string; d
471
471
  };
472
472
  }
473
473
 
474
+ /**
475
+ * db:provision --rotate — swap the active A/B role for one or both credential slots.
476
+ *
477
+ * Steps: preflight master → detect active → provision inactive → write SSM →
478
+ * rolling restart → verify origin → retire old → drain sessions.
479
+ */
480
+ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
481
+ const stage = flags.stage!;
482
+ const graceMs = (flags.grace ? Number(flags.grace) : 60) * 1000;
483
+
484
+ step('Resolving deployed config...');
485
+ let config;
486
+ try {
487
+ config = await resolveConfig(stage);
488
+ } catch (err: any) {
489
+ fail(err.message);
490
+ process.exit(1);
491
+ }
492
+
493
+ const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets');
494
+ const { parseAppName } = await import('../discover');
495
+ const secretsCtx = { appName: await parseAppName(), stage, region: config.region };
496
+ let stageSecrets: Record<string, string> = {};
497
+ try {
498
+ stageSecrets = await loadSstSecrets(secretsCtx);
499
+ } catch (err: any) {
500
+ fail(`Cannot read stage secrets: ${err.message}`);
501
+ process.exit(1);
502
+ }
503
+
504
+ // Resolve the direct connection (the rotate command always runs direct — it needs
505
+ // to query pg_roles and ALTER ROLE, which the ops action doesn't expose).
506
+ const adminUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl;
507
+ if (!adminUrl) {
508
+ fail('No ADMIN_DATABASE_URL secret found. Run db:provision first to set up the credential split.');
509
+ process.exit(1);
510
+ }
511
+
512
+ // Step 0: preflight — verify the master credential works (break-glass path).
513
+ step('Preflight: verifying master connection...');
514
+ try {
515
+ const result = await invokeAction(config.region, opsFunction(config), 'db:query', { sql: 'SELECT current_user' });
516
+ if ((result as any)?.error) throw new Error((result as any).error);
517
+ success('Master connection verified.');
518
+ } catch (err: any) {
519
+ fail(`Master connection failed: ${err.message}`);
520
+ info('The master credential is the break-glass. Fix it before rotating.');
521
+ process.exit(1);
522
+ }
523
+
524
+ // Connect directly for role management.
525
+ const { createUrlRunner } = await import('../db-source');
526
+ const { runner, end } = await createUrlRunner(adminUrl);
527
+ const deps = {
528
+ query: async (sql: string) => runner(sql) as Promise<any[]>,
529
+ execute: async (sql: string) => { await runner(sql); },
530
+ };
531
+
532
+ try {
533
+ const {
534
+ detectActiveRole,
535
+ provisionInactiveRole,
536
+ retireRole,
537
+ drainRole,
538
+ } = await import('@everystack/server/rotate');
539
+
540
+ // Detect which slots to rotate.
541
+ const slotFlag = flags.slot || 'both';
542
+ if (!['api', 'ops', 'both'].includes(slotFlag)) {
543
+ fail(`Invalid --slot: ${slotFlag}. Use api, ops, or both.`);
544
+ process.exit(1);
545
+ }
546
+ const slots: Array<{ base: string; secretKeys: string[] }> = [];
547
+ if (slotFlag === 'api' || slotFlag === 'both') {
548
+ slots.push({ base: 'authenticator', secretKeys: ['DatabaseUrl', 'ReadDatabaseUrl'] });
549
+ }
550
+ if (slotFlag === 'ops' || slotFlag === 'both') {
551
+ slots.push({ base: 'migrator', secretKeys: ['AdminDatabaseUrl'] });
552
+ }
553
+
554
+ const { randomBytes } = await import('node:crypto');
555
+ const conn = parseUrlConnection(adminUrl);
556
+ const sourceUrl = adminUrl;
557
+
558
+ // Track the old active role for each slot so retire uses the RIGHT role (F1 fix).
559
+ const oldActiveRoles: Array<{ base: string; oldRole: string }> = [];
560
+ // Track credential env overrides to set during rolling restart.
561
+ const credEnvOverrides: Record<string, string> = {};
562
+
563
+ for (const { base, secretKeys } of slots) {
564
+ step(`Rotating ${base}...`);
565
+
566
+ const detected = await detectActiveRole(base, deps);
567
+ if (!detected) {
568
+ fail(`No rotation pairs found for ${base}. Run db:provision --rotation-pairs first.`);
569
+ process.exit(1);
570
+ }
571
+ info(` Active: ${detected.activeRole}, inactive: ${detected.inactiveRole}`);
572
+ oldActiveRoles.push({ base, oldRole: detected.activeRole });
573
+ const active = detected;
574
+
575
+ // Provision the inactive role.
576
+ const password = randomBytes(24).toString('hex');
577
+ await provisionInactiveRole(active, password, deps);
578
+ success(` ${active.inactiveRole} provisioned with new password.`);
579
+
580
+ // Build the new URL.
581
+ const newUrl = preserveConnParams(
582
+ `postgresql://${active.inactiveRole}:${password}@${conn.host}:${conn.port}/${conn.database}`,
583
+ sourceUrl,
584
+ );
585
+
586
+ // Write to the SST secret store (blob-first per Fable ruling — must
587
+ // complete before UpdateFunctionConfiguration so a crash at any point
588
+ // is recovered by re-running rotate or by deploying).
589
+ step(` Updating secrets (${secretKeys.join(', ')})...`);
590
+ try {
591
+ const secrets = { ...stageSecrets };
592
+ for (const key of secretKeys) {
593
+ let credUrl = newUrl;
594
+ if (key === 'ReadDatabaseUrl') {
595
+ const existingReadUrl = stageSecrets.READ_DATABASE_URL ?? stageSecrets.ReadDatabaseUrl;
596
+ if (existingReadUrl) {
597
+ try {
598
+ const u = new URL(newUrl);
599
+ u.hostname = new URL(existingReadUrl).hostname;
600
+ credUrl = u.toString();
601
+ } catch { /* use primary host */ }
602
+ }
603
+ }
604
+ secrets[key] = credUrl;
605
+ credEnvOverrides[`EVERYSTACK_CRED_${key}`] = credUrl;
606
+ }
607
+ await putSstSecrets(secretsCtx, secrets);
608
+ stageSecrets = secrets;
609
+ success(` Secrets written.`);
610
+ } catch (err: any) {
611
+ fail(` Secret write failed: ${err.message}`);
612
+ info(' Ensure your IAM role has s3:PutObject + kms:Encrypt on the SST state bucket.');
613
+ process.exit(1);
614
+ }
615
+ }
616
+
617
+ // Rolling restart all linked functions with credential env overrides.
618
+ // Each function gets only the credentials it links — preserving the split.
619
+ // Track failures on credential-carrying functions — if ANY fail, abort
620
+ // before retire (both roles keep LOGIN = safe re-runnable state).
621
+ const { rollingRestart } = await import('../aws');
622
+ step('Rolling restart...');
623
+ const apiCreds: Record<string, string> = {};
624
+ const opsCreds: Record<string, string> = {};
625
+ for (const [k, v] of Object.entries(credEnvOverrides)) {
626
+ if (k.includes('Admin')) {
627
+ opsCreds[k] = v;
628
+ } else {
629
+ apiCreds[k] = v;
630
+ }
631
+ }
632
+ const allCreds = { ...apiCreds, ...opsCreds };
633
+ const functions: Array<{ name: string; creds: Record<string, string>; critical: boolean }> = [
634
+ { name: config.apiFunctionName, creds: apiCreds, critical: true },
635
+ ...(config.opsFunctionName ? [{ name: config.opsFunctionName, creds: allCreds, critical: true }] : []),
636
+ ...(config.workerFunctionName ? [{ name: config.workerFunctionName, creds: {}, critical: false }] : []),
637
+ ...(config.imageFunctionName ? [{ name: config.imageFunctionName, creds: {}, critical: false }] : []),
638
+ ];
639
+
640
+ let restartFailed = false;
641
+ for (const { name: fn, creds, critical } of functions) {
642
+ try {
643
+ const { newGen } = await rollingRestart(config.region, fn, creds);
644
+ success(` ${fn} → gen ${newGen}`);
645
+ } catch (err: any) {
646
+ if (critical) {
647
+ fail(` ${fn}: ${err.message}`);
648
+ restartFailed = true;
649
+ } else {
650
+ warn(` ${fn}: ${err.message}`);
651
+ }
652
+ }
653
+ }
654
+
655
+ if (restartFailed) {
656
+ fail('Restart failed on a credential-carrying function. Both roles keep LOGIN (safe state).');
657
+ info('Fix the issue and re-run db:provision --rotate. No credential was revoked.');
658
+ process.exit(1);
659
+ }
660
+
661
+ // Verify origin with a login probe — prove the new credential works
662
+ // before revoking the old one.
663
+ step('Verifying new credential...');
664
+ let verifyPassed = false;
665
+ try {
666
+ const { default: pg } = await import('postgres');
667
+ const newApiUrl = credEnvOverrides.EVERYSTACK_CRED_DatabaseUrl;
668
+ if (newApiUrl) {
669
+ const probe = pg(newApiUrl, { max: 1, connect_timeout: 5, ssl: 'prefer' });
670
+ try {
671
+ await probe`SELECT 1`;
672
+ verifyPassed = true;
673
+ success('New credential connects to the database.');
674
+ } finally {
675
+ await probe.end({ timeout: 1 });
676
+ }
677
+ } else {
678
+ verifyPassed = true;
679
+ }
680
+ } catch (err: any) {
681
+ fail(`New credential login failed: ${err.message}`);
682
+ info('Both roles keep LOGIN (safe state). Fix and re-run.');
683
+ process.exit(1);
684
+ }
685
+
686
+ // Origin HTTP check (non-blocking — the login probe is the real gate).
687
+ if (config.baseUrl) {
688
+ try {
689
+ const probe = `${config.baseUrl}?_probe=${Date.now()}`;
690
+ const res = await fetch(probe, { redirect: 'follow' });
691
+ if (res.ok) success('Origin responding.');
692
+ else warn(`Origin returned ${res.status}.`);
693
+ } catch (err: any) {
694
+ warn(`Origin probe failed: ${err.message}`);
695
+ }
696
+ }
697
+
698
+ // Retire old roles and drain sessions — using the roles recorded at
699
+ // detection time, NOT re-detected (F1 fix: both roles have LOGIN now,
700
+ // re-detection would pick the wrong one via the tiebreak).
701
+ for (const { base, oldRole } of oldActiveRoles) {
702
+ step(`Retiring ${oldRole}...`);
703
+ await retireRole(oldRole, deps);
704
+ success(` ${oldRole} set to NOLOGIN.`);
705
+
706
+ // Also retire the base role if it still has LOGIN (F6 fix: a leaked
707
+ // pre-rotation credential must not survive rotation).
708
+ try {
709
+ const baseRows = await deps.query(
710
+ `SELECT rolcanlogin FROM pg_roles WHERE rolname = '${base}'`,
711
+ );
712
+ if (baseRows[0]?.rolcanlogin) {
713
+ await retireRole(base, deps);
714
+ success(` ${base} (base role) set to NOLOGIN.`);
715
+ }
716
+ } catch { /* best-effort */ }
717
+
718
+ step(`Draining ${oldRole} sessions (grace: ${graceMs / 1000}s)...`);
719
+ const { drained, terminated } = await drainRole(oldRole, graceMs, deps);
720
+ if (drained && terminated === 0) {
721
+ success(` ${oldRole} drained cleanly.`);
722
+ } else if (drained) {
723
+ success(` ${oldRole} drained (${terminated} session(s) terminated after grace period).`);
724
+ }
725
+ }
726
+
727
+ success('Credential rotation complete.');
728
+
729
+ } finally {
730
+ await end?.();
731
+ }
732
+ }
733
+
474
734
  export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
475
735
  if (!flags.stage) {
476
736
  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)');
477
737
  process.exit(1);
478
738
  }
479
739
 
740
+ if (flags.rotate) {
741
+ await rotateCredentials(flags);
742
+ return;
743
+ }
744
+
480
745
  step('Resolving deployed config...');
481
746
  let config;
482
747
  try {
@@ -558,7 +823,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
558
823
  const { createUrlRunner } = await import('../db-source');
559
824
  const { runner, end } = await createUrlRunner(url);
560
825
  try {
561
- return await runProvision({ authPassword, adminPassword, searchPath }, {
826
+ return await runProvision({ authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] }, {
562
827
  execute: async (statement) => runner(statement),
563
828
  connection: parseUrlConnection(url),
564
829
  // Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
@@ -591,7 +856,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
591
856
  conn = parseUrlConnection(directUrl);
592
857
  } else {
593
858
  try {
594
- result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath });
859
+ result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] });
595
860
  } catch (err: any) {
596
861
  // Auto-fallback: no Ops Lambda (Unknown action), but an ADMIN_DATABASE_URL secret exists —
597
862
  // provision directly over it. Master never crosses the CLI; the operator sets nothing new.
@@ -85,7 +85,7 @@ export async function diagCommand(
85
85
  cacheStatus: response.headers.get('x-cache') ?? undefined,
86
86
  cacheAge: response.headers.get('age') ?? undefined,
87
87
  cacheControl: response.headers.get('cache-control') ?? undefined,
88
- serverDebug: response.headers.get('x-server-debug') ?? undefined,
88
+ serverDebug: response.headers.get('x-everystack-release') ?? response.headers.get('x-everystack-created') ?? undefined,
89
89
  };
90
90
 
91
91
  // Fetch full HTML if hydration analysis requested
@@ -138,7 +138,7 @@ export async function diagCommand(
138
138
  if (diag.serverDebug) {
139
139
  console.log(` Serving Release: ${diag.serverDebug}`);
140
140
  } else {
141
- warn(' No X-Server-Debug header found. Update @everystack/server to get release timestamp on SSR responses.');
141
+ warn(' No x-everystack-release header found. Update @everystack/server (>= 0.4.22) to get release headers on SSR responses.');
142
142
  }
143
143
  console.log('');
144
144
 
@@ -168,15 +168,15 @@ export async function diagCommand(
168
168
  // 4. Version comparison
169
169
  if (!manifest) {
170
170
  warn(' Cannot compare: no manifest available.');
171
- } else if (diag.serverDebug && manifest.createdAt) {
172
- if (diag.serverDebug === manifest.createdAt) {
171
+ } else if (diag.serverDebug && manifest.updateId) {
172
+ if (diag.serverDebug === manifest.updateId) {
173
173
  success(' Version match: serving the latest release');
174
174
  } else {
175
- fail(` Version mismatch: serving ${diag.serverDebug}, latest is ${manifest.createdAt}`);
175
+ fail(` Version mismatch: serving ${diag.serverDebug}, latest is ${manifest.updateId}`);
176
176
  info(' Run `everystack cache:purge --origin web` to bust the CDN cache.');
177
177
  }
178
178
  } else {
179
- warn(' Cannot compare: missing X-Server-Debug header or manifest timestamp.');
179
+ warn(' Cannot compare: missing x-everystack-release header or manifest updateId.');
180
180
  }
181
181
 
182
182
  console.log('');
package/src/cli/index.ts CHANGED
@@ -353,7 +353,8 @@ 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>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
356
- everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are set as the ROLE default (ALTER ROLE … SET search_path) — the secret stays credentials-only, and the URL is libpq-clean
356
+ everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation
357
+ everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
357
358
  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
359
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
359
360
  everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket as a restorable backup — the credential-free break-glass local→stage full replace (then: db:restore --from <id> --stage <target>)