@everystack/cli 0.4.60 → 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.
Files changed (116) hide show
  1. package/package.json +19 -2
  2. package/src/backfill.ts +3 -3
  3. package/src/cli/alter-type-dependents.ts +1 -1
  4. package/src/cli/apply-authority.ts +1 -1
  5. package/src/cli/apply-execute.ts +10 -10
  6. package/src/cli/audit-api.ts +4 -4
  7. package/src/cli/audit-source-api.ts +2 -2
  8. package/src/cli/authz-adoption-class.ts +4 -4
  9. package/src/cli/authz-baseline.ts +2 -2
  10. package/src/cli/authz-canonical.ts +2 -2
  11. package/src/cli/authz-compile.ts +3 -3
  12. package/src/cli/authz-contract-io.ts +2 -2
  13. package/src/cli/authz-contract.ts +4 -4
  14. package/src/cli/authz-derive.ts +2 -2
  15. package/src/cli/authz-identity.ts +1 -1
  16. package/src/cli/authz-ownership.ts +1 -1
  17. package/src/cli/authz-reconcile.ts +4 -4
  18. package/src/cli/authz-redteam.ts +1 -1
  19. package/src/cli/authz-render.ts +2 -2
  20. package/src/cli/aws.ts +57 -4
  21. package/src/cli/backfill.ts +3 -3
  22. package/src/cli/branch-db.ts +5 -5
  23. package/src/cli/bundle-weight.ts +2 -2
  24. package/src/cli/commands/analyze.ts +2 -2
  25. package/src/cli/commands/audit.ts +10 -10
  26. package/src/cli/commands/bundle.ts +6 -6
  27. package/src/cli/commands/cache.ts +75 -3
  28. package/src/cli/commands/console.ts +6 -6
  29. package/src/cli/commands/db-apply.ts +25 -25
  30. package/src/cli/commands/db-approvers.ts +4 -4
  31. package/src/cli/commands/db-authz.ts +15 -15
  32. package/src/cli/commands/db-backfill.ts +10 -10
  33. package/src/cli/commands/db-backup.ts +7 -7
  34. package/src/cli/commands/db-branch.ts +8 -8
  35. package/src/cli/commands/db-build.ts +7 -7
  36. package/src/cli/commands/db-check.ts +16 -16
  37. package/src/cli/commands/db-diff.ts +6 -6
  38. package/src/cli/commands/db-exec.ts +8 -8
  39. package/src/cli/commands/db-export.ts +10 -10
  40. package/src/cli/commands/db-fingerprint.ts +14 -14
  41. package/src/cli/commands/db-fork.ts +6 -6
  42. package/src/cli/commands/db-generate.ts +19 -19
  43. package/src/cli/commands/db-plan.ts +25 -25
  44. package/src/cli/commands/db-pull.ts +18 -18
  45. package/src/cli/commands/db-reconcile.ts +14 -14
  46. package/src/cli/commands/db-refresh.ts +7 -7
  47. package/src/cli/commands/db-snapshot.ts +5 -5
  48. package/src/cli/commands/db-swap.ts +26 -26
  49. package/src/cli/commands/db-sync.ts +18 -18
  50. package/src/cli/commands/db.ts +283 -18
  51. package/src/cli/commands/deploy.ts +39 -5
  52. package/src/cli/commands/diag.ts +10 -10
  53. package/src/cli/commands/lighthouse.ts +3 -3
  54. package/src/cli/commands/logs.ts +5 -5
  55. package/src/cli/commands/pipeline-run.ts +7 -7
  56. package/src/cli/commands/runbook.ts +5 -5
  57. package/src/cli/commands/secrets.ts +5 -5
  58. package/src/cli/commands/security-probe.ts +4 -4
  59. package/src/cli/commands/security.ts +6 -6
  60. package/src/cli/commands/status.ts +3 -3
  61. package/src/cli/commands/task-probe.ts +4 -4
  62. package/src/cli/commands/ui-audit.ts +5 -5
  63. package/src/cli/commands/update.ts +64 -8
  64. package/src/cli/component-audit.ts +5 -5
  65. package/src/cli/config.ts +2 -2
  66. package/src/cli/db-build.ts +6 -6
  67. package/src/cli/db-source.ts +2 -2
  68. package/src/cli/declared-derived.ts +5 -5
  69. package/src/cli/declared-diff.ts +5 -5
  70. package/src/cli/deploy-probe.ts +3 -3
  71. package/src/cli/derived-apply.ts +5 -5
  72. package/src/cli/derived-compile.ts +4 -4
  73. package/src/cli/derived-grants.ts +1 -1
  74. package/src/cli/derived-introspect.ts +3 -3
  75. package/src/cli/derived-lint.ts +2 -2
  76. package/src/cli/derived-plan.ts +5 -5
  77. package/src/cli/derived-render.ts +6 -6
  78. package/src/cli/direct-venue.ts +2 -2
  79. package/src/cli/discover.ts +2 -2
  80. package/src/cli/edge-plan.ts +8 -8
  81. package/src/cli/exec-digest.ts +1 -1
  82. package/src/cli/exec-execute.ts +1 -1
  83. package/src/cli/exec-log.ts +1 -1
  84. package/src/cli/exec-run.ts +2 -2
  85. package/src/cli/git-descent.ts +5 -5
  86. package/src/cli/index.ts +48 -47
  87. package/src/cli/migration-compile.ts +7 -7
  88. package/src/cli/migration-generate.ts +6 -6
  89. package/src/cli/model-api.ts +16 -16
  90. package/src/cli/model-render.ts +6 -6
  91. package/src/cli/mutation-lease.ts +1 -1
  92. package/src/cli/observability.ts +1 -1
  93. package/src/cli/refresh-execute.ts +2 -2
  94. package/src/cli/runbook/generate.ts +3 -3
  95. package/src/cli/runbook/index.ts +7 -7
  96. package/src/cli/runbook/sections.ts +2 -2
  97. package/src/cli/schema-compile.ts +4 -4
  98. package/src/cli/schema-diff.ts +1 -1
  99. package/src/cli/schema-fingerprint.ts +7 -7
  100. package/src/cli/schema-introspect.ts +3 -3
  101. package/src/cli/schema-source.ts +1 -1
  102. package/src/cli/schema-swap.ts +4 -4
  103. package/src/cli/security-catalog.ts +1 -1
  104. package/src/cli/stage-backup.ts +3 -3
  105. package/src/cli/stage-read-consistency.ts +6 -6
  106. package/src/cli/state-apply.ts +7 -7
  107. package/src/cli/swap-execute.ts +3 -3
  108. package/src/cli/swap-heartbeat.ts +1 -1
  109. package/src/cli/swap-pair.ts +7 -7
  110. package/src/cli/task-poll.ts +2 -2
  111. package/src/cli/ui-bloat.ts +1 -1
  112. package/src/cli/utils/secrets.ts +1 -1
  113. package/src/exec.ts +7 -7
  114. package/src/reconcile.ts +2 -2
  115. package/src/refresh.ts +2 -2
  116. package/src/sst.ts +1 -1
@@ -5,10 +5,10 @@
5
5
  * No HTTP endpoints, no shared secrets — IAM is the authorization layer.
6
6
  */
7
7
 
8
- import { resolveConfig, opsFunction } from '../config.js';
9
- import { invokeAction } from '../aws.js';
10
- import { step, success, fail, info, warn } from '../output.js';
11
- import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
8
+ import { resolveConfig, opsFunction } from '../config';
9
+ import { invokeAction } from '../aws';
10
+ import { step, success, fail, info, warn } from '../output';
11
+ import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
12
12
 
13
13
  /** Secret-store keys that may hold the privileged (operator) connection URL,
14
14
  * in precedence order. Names vary by deployment; --secret-name overrides. */
@@ -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 {
@@ -488,8 +753,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
488
753
 
489
754
  // Load the stage secrets once — the direct venue reads the privileged URL from here (never
490
755
  // from argv), and the writer below reuses them.
491
- const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
492
- const { parseAppName } = await import('../discover.js');
756
+ const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets');
757
+ const { parseAppName } = await import('../discover');
493
758
  const secretsCtx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
494
759
  let stageSecrets: Record<string, string> = {};
495
760
  try {
@@ -527,12 +792,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
527
792
  // all-public app sets no path (unchanged behavior).
528
793
  let declaredSchemas: string[] = [];
529
794
  try {
530
- const { resolveModelsPath } = await import('../models-path.js');
531
- const { loadModels } = await import('./db-generate.js');
795
+ const { resolveModelsPath } = await import('../models-path');
796
+ const { loadModels } = await import('./db-generate');
532
797
  const models = await loadModels(resolveModelsPath(flags.models));
533
798
  let derivedObjects: Array<{ schema: string }> = [];
534
799
  try {
535
- const { loadDeclaredDerived } = await import('../declared-derived.js');
800
+ const { loadDeclaredDerived } = await import('../declared-derived');
536
801
  const declared = await loadDeclaredDerived(flags.models);
537
802
  if (declared) derivedObjects = declared.objects;
538
803
  } catch {
@@ -555,10 +820,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
555
820
  // and the auto-fallback below.
556
821
  const provisionDirect = async (url: string): Promise<any> => {
557
822
  const { runProvision } = await loadServerProvision();
558
- const { createUrlRunner } = await import('../db-source.js');
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.
@@ -746,7 +1011,7 @@ function printDoctorReport(report: any): void {
746
1011
  */
747
1012
  async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string): Promise<boolean> {
748
1013
  const { runDoctorProbe } = await loadServerDoctor();
749
- const { createUrlRunner } = await import('../db-source.js');
1014
+ const { createUrlRunner } = await import('../db-source');
750
1015
  step('Probing database connections (api + operator)...');
751
1016
  const apiConn = await createUrlRunner(apiUrl);
752
1017
  const opsConn = await createUrlRunner(adminUrl);
@@ -797,8 +1062,8 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
797
1062
 
798
1063
  // Load the stage secrets once — the direct venue (--direct, and the no-Ops auto-fallback)
799
1064
  // reads DATABASE_URL + ADMIN_DATABASE_URL from here, never from argv.
800
- const { loadSstSecrets } = await import('../utils/secrets.js');
801
- const { parseAppName } = await import('../discover.js');
1065
+ const { loadSstSecrets } = await import('../utils/secrets');
1066
+ const { parseAppName } = await import('../discover');
802
1067
  let stageSecrets: Record<string, string> = {};
803
1068
  try {
804
1069
  stageSecrets = await loadSstSecrets({ appName: await parseAppName(), stage: flags.stage, region: config.region });
@@ -957,8 +1222,8 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
957
1222
  step('Resolving the admin connection from the secret store...');
958
1223
  let secrets: Record<string, string>;
959
1224
  try {
960
- const { loadSstSecrets } = await import('../utils/secrets.js');
961
- const { parseAppName } = await import('../discover.js');
1225
+ const { loadSstSecrets } = await import('../utils/secrets');
1226
+ const { parseAppName } = await import('../discover');
962
1227
  const appName = await parseAppName();
963
1228
  secrets = await loadSstSecrets({ appName, stage: flags.stage, region: config.region });
964
1229
  } catch (err: any) {
@@ -11,10 +11,11 @@
11
11
  */
12
12
 
13
13
  import { spawn } from 'node:child_process';
14
- import { step, success, fail, info, warn } from '../output.js';
15
- import { invalidateCachedConfig } from '../discover.js';
16
- import { resolveConfig } from '../config.js';
17
- import { probeDeployedFunctions } from '../deploy-probe.js';
14
+ import { step, success, fail, info, warn } from '../output';
15
+ import { invalidateCachedConfig } from '../discover';
16
+ import { resolveConfig } from '../config';
17
+ import { probeDeployedFunctions } from '../deploy-probe';
18
+ import { getDistributionStatus } from '../aws';
18
19
 
19
20
  /** The `sst` argv `everystack deploy` runs. Pure, so the mapping is unit-tested. */
20
21
  export function buildDeployArgs(flags: Record<string, string>): string[] {
@@ -68,8 +69,9 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
68
69
  // `sst deploy` exiting 0 only proves the code UPLOADED — a bundle that crashes at INIT still
69
70
  // deploys "green" and takes the whole ops plane down until the next verb trips over it. Probe
70
71
  // the deployed functions now so the operator learns here, with the real cause attached.
72
+ let config: Awaited<ReturnType<typeof resolveConfig>> | undefined;
71
73
  try {
72
- const config = await resolveConfig(stage);
74
+ config = await resolveConfig(stage);
73
75
  const { fatal } = await probeDeployedFunctions(stage, config);
74
76
  if (fatal) {
75
77
  fail('Deploy landed, but a deployed function cannot serve — every --stage verb will fail until this is fixed and redeployed.');
@@ -81,8 +83,40 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
81
83
  info(`Skipped the post-deploy probe: ${err?.message ?? err}`);
82
84
  }
83
85
 
86
+ // Wait for CloudFront distribution to finish propagating.
87
+ if (config?.distributionId) {
88
+ step('Waiting for CloudFront distribution...');
89
+ const deployed = await waitForDistribution(config.region, config.distributionId);
90
+ if (deployed) {
91
+ success('Distribution deployed.');
92
+ } else {
93
+ warn('Distribution did not reach Deployed within timeout. It may still be propagating.');
94
+ }
95
+ }
96
+
84
97
  // The 0.4 flow: schema changes ride db:plan → db:apply. Migration-based apps still have
85
98
  // db:migrate, but the copy-paste hint must advertise the canonical path — this exact
86
99
  // line got copied into consumer runbooks with the retired verb.
87
100
  info('Next: schema changes via `everystack db:plan --stage ' + stage + '` → `db:apply`; `everystack update` to publish the app bundle.');
88
101
  }
102
+
103
+ const DISTRIBUTION_POLL_MS = 10_000;
104
+ const DISTRIBUTION_TIMEOUT_MS = 300_000;
105
+
106
+ /** @internal Exported for testing. */
107
+ export async function waitForDistribution(
108
+ region: string,
109
+ distributionId: string,
110
+ ): Promise<boolean> {
111
+ const deadline = Date.now() + DISTRIBUTION_TIMEOUT_MS;
112
+ while (Date.now() < deadline) {
113
+ try {
114
+ const status = await getDistributionStatus(region, distributionId);
115
+ if (status === 'Deployed') return true;
116
+ } catch {
117
+ // Permission error or transient — keep trying.
118
+ }
119
+ await new Promise((r) => setTimeout(r, DISTRIBUTION_POLL_MS));
120
+ }
121
+ return false;
122
+ }
@@ -13,10 +13,10 @@
13
13
  * everystack diag [url] --hydration # Analyze SSR hydration issues
14
14
  */
15
15
 
16
- import { resolveConfig } from '../config.js';
17
- import { getFromS3 } from '../aws.js';
18
- import { step, success, fail, info, warn } from '../output.js';
19
- import { analyzeHtml, generateHydrationReport } from '../hydration-analyzer.js';
16
+ import { resolveConfig } from '../config';
17
+ import { getFromS3 } from '../aws';
18
+ import { step, success, fail, info, warn } from '../output';
19
+ import { analyzeHtml, generateHydrationReport } from '../hydration-analyzer';
20
20
 
21
21
  interface DiagResult {
22
22
  url: string;
@@ -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('');
@@ -20,9 +20,9 @@ import {
20
20
  formatLighthouseReport,
21
21
  type ExtractedRun,
22
22
  type LhReport,
23
- } from '../lighthouse.js';
24
- import { resolveConfig } from '../config.js';
25
- import { step, info, fail, warn } from '../output.js';
23
+ } from '../lighthouse';
24
+ import { resolveConfig } from '../config';
25
+ import { step, info, fail, warn } from '../output';
26
26
 
27
27
  function runLighthouse(url: string, outPath: string): LhReport {
28
28
  // Warm the edge so we measure the real-user (cached-HTML) experience.
@@ -10,11 +10,11 @@
10
10
  * immune to the Lambda hash rotating on redeploy.
11
11
  */
12
12
 
13
- import { resolveConfig } from '../config.js';
14
- import { resolveLogGroup, fetchRecentLogEvents, importOptionalAws } from '../aws.js';
15
- import { summarizeRuntimeFailures } from '../ops-diagnostics.js';
16
- import { queryLogArchive, type ObservabilityQuery } from '../observability.js';
17
- import { step, success, fail, info } from '../output.js';
13
+ import { resolveConfig } from '../config';
14
+ import { resolveLogGroup, fetchRecentLogEvents, importOptionalAws } from '../aws';
15
+ import { summarizeRuntimeFailures } from '../ops-diagnostics';
16
+ import { queryLogArchive, type ObservabilityQuery } from '../observability';
17
+ import { step, success, fail, info } from '../output';
18
18
 
19
19
  /** Default S3 key prefix for the observability archive (loggingPlugin default). */
20
20
  const DEFAULT_PREFIX = '_observability';
@@ -18,19 +18,19 @@ import { randomUUID } from 'node:crypto';
18
18
  import { existsSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { pathToFileURL } from 'node:url';
21
- import { resolveDbSource, createUrlPipelineRunner, connectingVia, type DbSource } from '../db-source.js';
22
- import { resolvePipelinePath, PIPELINE_HOMES } from '../pipeline-path.js';
23
- import { resolveConfig, opsFunction } from '../config.js';
24
- import { invokeAction } from '../aws.js';
25
- import { currentGitRef } from '../state-apply.js';
26
- import { step, success, fail, info, warn } from '../output.js';
21
+ import { resolveDbSource, createUrlPipelineRunner, connectingVia, type DbSource } from '../db-source';
22
+ import { resolvePipelinePath, PIPELINE_HOMES } from '../pipeline-path';
23
+ import { resolveConfig, opsFunction } from '../config';
24
+ import { invokeAction } from '../aws';
25
+ import { currentGitRef } from '../state-apply';
26
+ import { step, success, fail, info, warn } from '../output';
27
27
  import {
28
28
  loadServerPipeline,
29
29
  type Pipeline,
30
30
  type PipelineLane,
31
31
  type RunPipelineOptions,
32
32
  type PipelineRunOutcome,
33
- } from '../pipeline-loader.js';
33
+ } from '../pipeline-loader';
34
34
 
35
35
  /** --rebuild → rebuild, --curate → curate, neither → all. Both is a contradiction. */
36
36
  export function pipelineLaneFromFlags(flags: Record<string, string>): PipelineLane {
@@ -16,11 +16,11 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { pathToFileURL } from 'node:url';
19
- import { detectProjectReality } from '../runbook/detect.js';
20
- import { generateRunbook, type RunbookResult } from '../runbook/generate.js';
21
- import { renderDataModelSection } from '../runbook/model-section.js';
22
- import { parseRunbook, sha8 } from '../runbook/markers.js';
23
- import { fail, info, step, success, warn } from '../output.js';
19
+ import { detectProjectReality } from '../runbook/detect';
20
+ import { generateRunbook, type RunbookResult } from '../runbook/generate';
21
+ import { renderDataModelSection } from '../runbook/model-section';
22
+ import { parseRunbook, sha8 } from '../runbook/markers';
23
+ import { fail, info, step, success, warn } from '../output';
24
24
 
25
25
  const OUT_REL = path.join('docs', 'RUNBOOK.md');
26
26
 
@@ -15,10 +15,10 @@
15
15
 
16
16
  import fs from 'node:fs/promises';
17
17
  import path from 'node:path';
18
- import { step, success, fail, info, warn } from '../output.js';
19
- import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
20
- import { parseDotenv, serializeDotenv } from '../utils/dotenv.js';
21
- import { isMissingOptionalDependencyError } from '../aws.js';
18
+ import { step, success, fail, info, warn } from '../output';
19
+ import { loadSstSecrets, putSstSecrets } from '../utils/secrets';
20
+ import { parseDotenv, serializeDotenv } from '../utils/dotenv';
21
+ import { isMissingOptionalDependencyError } from '../aws';
22
22
 
23
23
  interface SecretsContext {
24
24
  appName: string;
@@ -34,7 +34,7 @@ async function resolveContext(flags: Record<string, string>): Promise<SecretsCon
34
34
  process.exit(1);
35
35
  }
36
36
 
37
- const { parseAppName } = await import('../discover.js');
37
+ const { parseAppName } = await import('../discover');
38
38
  const appName = await parseAppName();
39
39
  const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
40
40
 
@@ -25,10 +25,10 @@ import {
25
25
  type CatalogColumn,
26
26
  type CatalogGrant,
27
27
  type ProbeResult,
28
- } from '../security-probe.js';
29
- import { resolveConfig, opsFunction } from '../config.js';
30
- import { invokeAction } from '../aws.js';
31
- import { step, info, fail, success, warn } from '../output.js';
28
+ } from '../security-probe';
29
+ import { resolveConfig, opsFunction } from '../config';
30
+ import { invokeAction } from '../aws';
31
+ import { step, info, fail, success, warn } from '../output';
32
32
 
33
33
  const SYS_SCHEMAS = "('pg_catalog','information_schema','pg_toast')";
34
34
 
@@ -22,17 +22,17 @@ import {
22
22
  type AuditReport,
23
23
  type FunctionDescriptor,
24
24
  type ViewDescriptor,
25
- } from '../security-audit.js';
25
+ } from '../security-audit';
26
26
  import {
27
27
  FUNCTIONS_SQL,
28
28
  RELATIONS_SQL,
29
29
  catalogFunctionToDescriptor,
30
30
  catalogRelationToDescriptor,
31
- } from '../security-catalog.js';
32
- import { SCHEMA_ACL_SQL, parseSchemaAcl } from '../authz-contract.js';
33
- import { resolveConfig, opsFunction } from '../config.js';
34
- import { invokeAction } from '../aws.js';
35
- import { step, success, fail, info, warn } from '../output.js';
31
+ } from '../security-catalog';
32
+ import { SCHEMA_ACL_SQL, parseSchemaAcl } from '../authz-contract';
33
+ import { resolveConfig, opsFunction } from '../config';
34
+ import { invokeAction } from '../aws';
35
+ import { step, success, fail, info, warn } from '../output';
36
36
 
37
37
  const MIGRATION_DIRS = ['drizzle', 'db/migrations', 'migrations', 'db/drizzle'];
38
38
  const WAIVER_FILES = ['security-audit.json', '.security-audit.json'];
@@ -11,9 +11,9 @@
11
11
  * CDN and Lambda metrics still work with nothing but IAM credentials.
12
12
  */
13
13
 
14
- import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
15
- import { invokeAction, importOptionalAws } from '../aws.js';
16
- import { fail, info, step } from '../output.js';
14
+ import { resolveConfig, opsFunction, type CliConfig } from '../config';
15
+ import { invokeAction, importOptionalAws } from '../aws';
16
+ import { fail, info, step } from '../output';
17
17
 
18
18
  export interface MetricSpec {
19
19
  namespace: string;