vibecarbon 0.5.1 → 0.6.0

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 (61) hide show
  1. package/README.md +1 -1
  2. package/carbon/.env.example +3 -0
  3. package/carbon/.github/workflows/deploy.yml +24 -21
  4. package/carbon/.github/workflows/vibecarbon-build.yml +14 -0
  5. package/carbon/Dockerfile +3 -0
  6. package/carbon/cloud-init/k3s/master-init.sh +26 -2
  7. package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
  8. package/carbon/cloud-init/k3s/worker-init.sh +22 -2
  9. package/carbon/content/docs/cli.mdx +1 -1
  10. package/carbon/docker-compose.yml +6 -0
  11. package/carbon/k8s/base/app/deployment.yaml +5 -0
  12. package/carbon/k8s/values/supabase.values.yaml +30 -0
  13. package/carbon/package.json +14 -14
  14. package/carbon/scripts/generate-sitemap.ts +18 -3
  15. package/carbon/src/client/App.tsx +2 -0
  16. package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
  17. package/carbon/src/client/components/PricingSection.tsx +44 -1
  18. package/carbon/src/client/components/SEO.tsx +6 -3
  19. package/carbon/src/client/index.css +9 -1
  20. package/carbon/src/client/index.html +3 -3
  21. package/carbon/src/client/locales/en.json +1 -1
  22. package/carbon/src/client/pages/Pricing.tsx +169 -0
  23. package/carbon/src/client/pages/settings/Billing.tsx +2 -6
  24. package/carbon/src/server/index.ts +23 -4
  25. package/carbon/src/shared/billing-catalog.ts +25 -0
  26. package/carbon/src/shared/billing-catalog.types.ts +35 -0
  27. package/carbon/src/shared/pricing.ts +56 -1
  28. package/package.json +1 -1
  29. package/src/activate.js +1 -1
  30. package/src/backup.js +33 -87
  31. package/src/configure.js +365 -137
  32. package/src/create.js +5 -2
  33. package/src/deploy.js +24 -7
  34. package/src/destroy.js +11 -4
  35. package/src/failover.js +64 -47
  36. package/src/lib/backup-format.js +84 -0
  37. package/src/lib/billing/stripe-catalog.js +152 -0
  38. package/src/lib/billing/write-catalog.js +123 -0
  39. package/src/lib/ci-setup.js +11 -1
  40. package/src/lib/config-registry.js +116 -0
  41. package/src/lib/deploy/compose/build-args.js +6 -0
  42. package/src/lib/deploy/compose/ha.js +25 -25
  43. package/src/lib/deploy/compose/index.js +29 -18
  44. package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
  45. package/src/lib/deploy/k8s/k3s.js +19 -9
  46. package/src/lib/deploy/orchestrator.js +22 -1
  47. package/src/lib/deploy/prompts.js +21 -2
  48. package/src/lib/deploy/utils.js +56 -32
  49. package/src/lib/github-environments.js +29 -0
  50. package/src/lib/licensing/index.js +8 -3
  51. package/src/lib/licensing/tiers.js +0 -3
  52. package/src/lib/pod-backups.js +3 -3
  53. package/src/lib/providers/base.js +1 -1
  54. package/src/lib/providers/hetzner.js +74 -82
  55. package/src/lib/server-types.js +3 -31
  56. package/src/lib/ssh.js +19 -18
  57. package/src/lib/walg-backups.js +81 -0
  58. package/src/restore.js +122 -202
  59. package/src/scale.js +25 -29
  60. package/src/status.js +0 -69
  61. package/src/lib/cost.js +0 -103
package/src/restore.js CHANGED
@@ -19,33 +19,27 @@
19
19
  import { existsSync } from 'node:fs';
20
20
  import { basename } from 'node:path';
21
21
  import * as p from '@clack/prompts';
22
- import { listS3Backups } from './lib/backup-s3.js';
22
+ import { formatInstant } from './lib/backup-format.js';
23
23
  import { renderHelp } from './lib/cli/help.js';
24
24
  import { parseFlags } from './lib/cli/parse-flags.js';
25
25
  import { selectEnvironment } from './lib/cli/select-environment.js';
26
26
  import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
27
27
  import { c, printBanner } from './lib/colors.js';
28
- import {
29
- loadBackupS3Config,
30
- loadCredentials,
31
- loadProjectConfig,
32
- loadS3Config,
33
- } from './lib/config.js';
28
+ import { loadCredentials, loadProjectConfig } from './lib/config.js';
34
29
  // Static imports (no cycle: neither compose/index.js nor compose/ha.js imports
35
30
  // restore.js). Aliased so runComposeRestore can default to the real functions
36
31
  // while still accepting injected mocks for unit tests.
37
32
  import { configureStandbyReplication as realConfigureStandbyReplication } from './lib/deploy/compose/ha.js';
38
33
  import { restoreCompose as realRestoreCompose } from './lib/deploy/compose/index.js';
39
- import { getS3Credentials } from './lib/hetzner-guided-setup.js';
40
34
  import { requireLicense } from './lib/licensing/index.js';
41
35
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
42
36
  import { perfAsync } from './lib/perf.js';
43
- import { listPodBackups } from './lib/pod-backups.js';
44
37
  import { assertInProjectDir } from './lib/project-guard.js';
45
- import { getPostgresPod, getSSHKeyPath, sshKubectl, sshRunScript } from './lib/ssh.js';
38
+ import { getPostgresPod, getSSHKeyPath, sshKubectl } from './lib/ssh.js';
46
39
  import { createTracker } from './lib/tracker.js';
47
40
  import { validateBackupFilename } from './lib/validators.js';
48
41
  import { VERSION } from './lib/version.js';
42
+ import { listWalgBackups } from './lib/walg-backups.js';
49
43
 
50
44
  // ============================================================================
51
45
  // COMMAND SPEC — single source of truth for argv parsing AND help output.
@@ -74,24 +68,24 @@ const SPEC = {
74
68
  { name: 'env', value: '<name>', description: 'Environment seed (alternative to positional)' },
75
69
  {
76
70
  name: 'source',
77
- value: '<file-or-name-or-latest>',
78
- description: 'Backup source: local file path, S3 backup name, or `latest`',
71
+ value: '<latest|ISO-timestamp|file>',
72
+ description: 'Restore point: `latest`, an ISO-8601 timestamp (PITR), or a local file path',
79
73
  },
80
74
  ],
81
75
  examples: [
82
- { command: 'vibecarbon restore', description: 'prompts for env and backup' },
76
+ { command: 'vibecarbon restore', description: 'prompts for env and restore point' },
83
77
  { command: 'vibecarbon restore prod -l', description: 'list available backups for prod' },
84
78
  {
85
- command: 'vibecarbon restore prod -source myapp_20260507.tar.gz',
86
- description: 'restore a specific backup from S3',
79
+ command: 'vibecarbon restore prod -y -source latest',
80
+ description: 'restore the most-recent backup, non-interactively',
87
81
  },
88
82
  {
89
- command: 'vibecarbon restore prod -source ./backup.tar.gz -y',
90
- description: 'restore from a local file, skipping confirmation',
83
+ command: 'vibecarbon restore prod -source 2026-06-22T14:30:00Z',
84
+ description: 'point-in-time recovery to a specific moment (wal-g WAL replay)',
91
85
  },
92
86
  {
93
- command: 'vibecarbon restore prod -y -source latest',
94
- description: 'restore the most-recent S3 backup, non-interactively',
87
+ command: 'vibecarbon restore prod -source ./backup.tar.gz -y',
88
+ description: 'restore from a local file, skipping confirmation',
95
89
  },
96
90
  ],
97
91
  };
@@ -100,11 +94,18 @@ const SPEC = {
100
94
  // RESTORE OPERATIONS (k8s mode)
101
95
  // ============================================================================
102
96
 
103
- function scaleDownApp(ip, sshKeyPath) {
104
- sshKubectl(ip, sshKeyPath, ['scale', 'deployment', 'app', '-n', 'vibecarbon', '--replicas=0']);
97
+ async function scaleDownApp(ip, sshKeyPath) {
98
+ await sshKubectl(ip, sshKeyPath, [
99
+ 'scale',
100
+ 'deployment',
101
+ 'app',
102
+ '-n',
103
+ 'vibecarbon',
104
+ '--replicas=0',
105
+ ]);
105
106
 
106
107
  try {
107
- sshKubectl(ip, sshKeyPath, [
108
+ await sshKubectl(ip, sshKeyPath, [
108
109
  'rollout',
109
110
  'status',
110
111
  'deployment/app',
@@ -117,8 +118,15 @@ function scaleDownApp(ip, sshKeyPath) {
117
118
  }
118
119
  }
119
120
 
120
- function scaleUpApp(ip, sshKeyPath) {
121
- sshKubectl(ip, sshKeyPath, ['scale', 'deployment', 'app', '-n', 'vibecarbon', '--replicas=2']);
121
+ async function scaleUpApp(ip, sshKeyPath) {
122
+ await sshKubectl(ip, sshKeyPath, [
123
+ 'scale',
124
+ 'deployment',
125
+ 'app',
126
+ '-n',
127
+ 'vibecarbon',
128
+ '--replicas=2',
129
+ ]);
122
130
  }
123
131
 
124
132
  /**
@@ -147,12 +155,21 @@ export async function verifyPostgres(ip, sshKeyPath, opts = {}) {
147
155
  getPod = getPostgresPod,
148
156
  sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
149
157
  } = opts;
150
- const pod = getPod(ip, sshKeyPath);
158
+ const pod = await getPod(ip, sshKeyPath);
151
159
  const deadline = Date.now() + timeoutMs;
152
160
  let lastErr = '';
153
161
  for (;;) {
154
162
  try {
155
- exec(ip, sshKeyPath, ['exec', '-n', 'vibecarbon', pod, '--', 'pg_isready', '-U', 'postgres']);
163
+ await exec(ip, sshKeyPath, [
164
+ 'exec',
165
+ '-n',
166
+ 'vibecarbon',
167
+ pod,
168
+ '--',
169
+ 'pg_isready',
170
+ '-U',
171
+ 'postgres',
172
+ ]);
156
173
  return; // exit 0 = accepting connections
157
174
  } catch (err) {
158
175
  lastErr = err instanceof Error ? err.message : String(err);
@@ -332,30 +349,18 @@ export async function run(args) {
332
349
  return;
333
350
  }
334
351
 
335
- // Resolve mode-specific S3 config + the chosen backup source.
336
- const s3 = await resolveS3({ isCompose, envName, projectName });
337
- const useS3 = Boolean(s3?.secretKey);
338
-
339
- // `-source latest` is a non-interactive sentinel that routes through the
340
- // same auto-pick path the interactive prompt uses under `-y` the newest
341
- // S3 backup wins. Lets scripted callers (e2e, post-incident
342
- // restores) ask for "the most recent" without first listing + parsing
343
- // names from `vibecarbon backup -l`.
352
+ // Resolve the chosen backup source. Restore is wal-g-native (compose + k8s):
353
+ // wal-g runs server-side and reads its own S3 config from the db container —
354
+ // the operator does NOT need local S3 credentials to restore.
355
+ //
356
+ // `-source latest` (e2e, post-incident scripts) and `-y` both resolve to the
357
+ // LATEST base backup; `-source <ISO-8601>` is a point-in-time target. A bare
358
+ // interactive run opens the restore-point chooser.
344
359
  const wantsLatest = values.source === 'latest';
345
360
  let chosenSource;
346
361
  if (!wantsLatest && values.source) {
347
362
  chosenSource = classifySource(/** @type {string} */ (values.source));
348
363
  } else if (wantsLatest) {
349
- // BOTH compose and k8s restore are now wal-g-native: `latest` resolves to
350
- // the LATEST base backup that wal-g pushed to S3 — k8s via the db pod's
351
- // walg-restore init container, compose via `restoreCompose(..., 'latest')`
352
- // → `wal-g backup-fetch LATEST` (see runK8sRestore / runComposeRestore).
353
- // pickInteractiveSource lists legacy pg_dump `*_full.tar.gz` backups via
354
- // listS3Backups, which CANNOT see wal-g `basebackups_005/base_*` objects, so
355
- // routing `-source latest` through it wrongly exits "No backups found in S3"
356
- // even though a wal-g base backup exists. Hand the `latest` sentinel
357
- // straight to the restore runner and let wal-g resolve it. (RCA 2026-05-31:
358
- // e2e k8s/k8s-ha restore step; compose found via kept-rig manual restore.)
359
364
  chosenSource = { kind: 's3', name: 'latest' };
360
365
  } else {
361
366
  chosenSource = await pickInteractiveSource({
@@ -364,9 +369,7 @@ export async function run(args) {
364
369
  projectName,
365
370
  serverIp,
366
371
  sshKeyPath,
367
- s3,
368
- useS3,
369
- yes: wantsLatest || !!values.y,
372
+ yes: !!values.y,
370
373
  });
371
374
  }
372
375
 
@@ -446,181 +449,98 @@ export async function run(args) {
446
449
  // ORCHESTRATION HELPERS
447
450
  // ============================================================================
448
451
 
449
- async function resolveS3({ isCompose, envName, projectName }) {
450
- if (isCompose) {
451
- const backupS3 = loadBackupS3Config(envName);
452
- if (!backupS3) return null;
453
- const creds = await getS3Credentials(projectName, { save: false });
454
- if (!creds) return null;
455
- return { ...backupS3, accessKey: creds.accessKey, secretKey: creds.secretKey };
456
- }
457
- const backupS3 = loadBackupS3Config(envName);
458
- const storageS3 = loadS3Config(envName);
459
- let s3 = backupS3 || storageS3;
460
- if (s3 && !s3.secretKey) {
461
- const creds = await getS3Credentials(projectName, { save: false });
462
- if (creds) {
463
- s3 = { ...s3, accessKey: creds.accessKey, secretKey: creds.secretKey };
464
- }
465
- }
466
- return s3;
467
- }
468
-
469
452
  async function runList({ isCompose, envName, projectName, serverIp, sshKeyPath }) {
470
- const s3 = await resolveS3({ isCompose, envName, projectName });
471
- const useS3 = Boolean(s3?.secretKey);
472
-
473
- if (useS3) {
474
- try {
475
- const backups = await listS3Backups(s3);
476
- printBackupList(backups, envName);
477
- } catch (error) {
478
- p.log.error(`Failed to list S3 backups: ${error.message}`);
479
- process.exit(1);
480
- }
481
- return;
482
- }
483
-
484
- if (isCompose) {
485
- try {
486
- const output = sshRunScript(
487
- serverIp,
488
- sshKeyPath,
489
- `ls -lt /opt/${projectName}/backups/*_full.tar.gz 2>/dev/null || echo NO_BACKUPS`,
490
- );
491
- if (output === 'NO_BACKUPS' || !output.trim()) {
492
- p.log.info('No backups found.');
493
- p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
494
- return;
495
- }
496
- const lines = output.trim().split('\n').filter(Boolean);
497
- p.log.info(`${c.bold(`Backups for ${envName}`)} (${lines.length} found):`);
498
- for (const line of lines) {
499
- p.log.message(` ${line}`);
500
- }
501
- } catch (error) {
502
- p.log.error(`Failed to list backups: ${error.message}`);
503
- }
504
- return;
505
- }
453
+ const s = p.spinner();
454
+ s.start('Reading wal-g backups');
455
+ const backups = await listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose });
456
+ s.stop('Backups read');
506
457
 
507
- // K8s pod fallback
508
- const backups = listPodBackups(serverIp, sshKeyPath);
509
- printBackupList(backups, envName);
510
- }
511
-
512
- function printBackupList(backups, envName) {
513
- if (!backups || backups.length === 0) {
514
- p.log.info('No backups found.');
458
+ if (backups.length === 0) {
459
+ p.log.info('No wal-g base backups found.');
515
460
  p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
516
461
  return;
517
462
  }
518
- p.log.info(`${c.bold(`Backups for ${envName}`)} (${backups.length} found):`);
519
- for (const backup of backups) {
520
- p.log.message(` ${c.dim(backup.date.padEnd(22))} ${backup.size.padStart(10)} ${backup.name}`);
463
+ p.log.info(
464
+ `${c.bold(`Backups for ${envName}`)} (${backups.length} base backup(s), newest first):`,
465
+ );
466
+ for (const b of backups) {
467
+ p.log.message(` ${formatInstant(b.time).padEnd(22)} ${c.dim(b.name)}`);
521
468
  }
522
469
  }
523
470
 
471
+ // ISO-8601 datetime for point-in-time recovery. MUST stay in sync with
472
+ // composeRestoreScript's ISO_DATETIME_RE (compose/index.js) and the k8s
473
+ // walg-restore init container, which reject any other target format.
474
+ const RESTORE_PITR_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/;
475
+
524
476
  async function pickInteractiveSource({
525
477
  isCompose,
526
478
  envName,
527
479
  projectName,
528
480
  serverIp,
529
481
  sshKeyPath,
530
- s3,
531
- useS3,
532
482
  yes,
533
483
  }) {
534
- const tracker = createTracker('restore.pickSource', { environment: envName });
535
- const s = tracker.spinner();
536
-
537
- if (useS3) {
538
- s.start('Fetching available backups from S3');
539
- const backups = await listS3Backups(s3);
540
- s.stop('Backups retrieved');
541
- if (backups.length === 0) {
542
- p.log.error('No backups found in S3.');
543
- p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
544
- process.exit(1);
545
- }
546
- if (yes) {
547
- const latest = backups[0].name;
548
- p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
549
- return { kind: 's3', name: latest };
550
- }
551
- const selected = await p.select({
552
- message: 'Select backup to restore:',
553
- options: backups.map((b) => ({
554
- value: b.name,
555
- label: b.name,
556
- hint: `${b.size} — ${b.date}`,
557
- })),
558
- });
559
- if (p.isCancel(selected)) {
560
- p.cancel('Operation cancelled.');
561
- process.exit(0);
562
- }
563
- return { kind: 's3', name: /** @type {string} */ (selected) };
484
+ // Restore is wal-g-native for both compose and k8s: it ALWAYS fetches the
485
+ // LATEST base backup and replays WAL — either to the present ("latest") or to
486
+ // a point-in-time target. There is no "restore a specific older base backup",
487
+ // so this is a restore-POINT chooser, not a backup-file picker. (The legacy
488
+ // backups/*_full.tar.gz S3 objects are NOT wal-g backups composeRestoreScript
489
+ // and the k8s walg-restore init container reject any target that isn't
490
+ // "latest" or an ISO-8601 datetime.)
491
+ if (yes) {
492
+ return { kind: 's3', name: 'latest' };
564
493
  }
565
494
 
566
- // Non-S3 fallback: list from server filesystem (compose) or pod (k8s).
567
- if (isCompose) {
568
- s.start('Listing available backups');
569
- const output = sshRunScript(
570
- serverIp,
571
- sshKeyPath,
572
- `ls -t /opt/${projectName}/backups/*_full.tar.gz 2>/dev/null || echo NO_BACKUPS`,
495
+ // Best-effort recovery-window context so point-in-time is meaningful. Never
496
+ // blocks the chooser: if wal-g listing fails (db down, no --json), omit it.
497
+ const tracker = createTracker('restore.window', { environment: envName });
498
+ const s = tracker.spinner();
499
+ s.start('Reading available backups');
500
+ const backups = await listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose });
501
+ if (backups.length > 0) {
502
+ const newest = formatInstant(backups[0].time);
503
+ const oldest = formatInstant(backups[backups.length - 1].time);
504
+ s.stop(
505
+ backups.length === 1
506
+ ? `1 base backup available (${newest})`
507
+ : `${backups.length} base backups — recovery window ${oldest} → ${newest}`,
573
508
  );
574
- s.stop('');
575
- if (output === 'NO_BACKUPS' || !output.trim()) {
576
- p.log.error('No backups found on the server.');
577
- p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
578
- process.exit(1);
579
- }
580
- const names = output
581
- .trim()
582
- .split('\n')
583
- .filter(Boolean)
584
- .map((f) => basename(f));
585
- if (yes) {
586
- const latest = names[0];
587
- p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
588
- return { kind: 's3', name: latest };
589
- }
590
- const selected = await p.select({
591
- message: 'Select backup to restore:',
592
- options: names.map((b) => ({ value: b, label: b })),
593
- });
594
- if (p.isCancel(selected)) {
595
- p.cancel('Operation cancelled.');
596
- process.exit(0);
597
- }
598
- return { kind: 's3', name: /** @type {string} */ (selected) };
509
+ } else {
510
+ s.stop('Backups read');
599
511
  }
600
512
 
601
- // K8s pod fallback
602
- s.start('Fetching available backups');
603
- const backups = listPodBackups(serverIp, sshKeyPath);
604
- s.stop('Backups retrieved');
605
- if (backups.length === 0) {
606
- p.log.error('No backups found on the server.');
607
- p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
608
- process.exit(1);
513
+ const choice = await p.select({
514
+ message: 'Restore point:',
515
+ options: [
516
+ { value: 'latest', label: 'Latest', hint: 'most recent backup + all WAL (recommended)' },
517
+ { value: 'pitr', label: 'Point in time…', hint: 'recover to a specific timestamp' },
518
+ ],
519
+ });
520
+ if (p.isCancel(choice)) {
521
+ p.cancel('Operation cancelled.');
522
+ process.exit(0);
609
523
  }
610
- if (yes) {
611
- const latest = backups[0].name;
612
- p.log.info(`Auto-selected latest backup: ${c.bold(latest)}`);
613
- return { kind: 's3', name: latest };
524
+ if (choice === 'latest') {
525
+ return { kind: 's3', name: 'latest' };
614
526
  }
615
- const selected = await p.select({
616
- message: 'Select backup to restore:',
617
- options: backups.map((b) => ({ value: b.name, label: b.name, hint: `${b.size} ${b.date}` })),
527
+
528
+ // Point-in-time: wal-g fetches the latest base backup and replays WAL up to
529
+ // this instant. Must match the ISO-8601 target validation in
530
+ // composeRestoreScript / the k8s walg-restore init container.
531
+ const ts = await p.text({
532
+ message: 'Recover to (ISO-8601 timestamp):',
533
+ placeholder: '2026-06-22T14:30:00Z',
534
+ validate: (v) =>
535
+ RESTORE_PITR_RE.test((v || '').trim())
536
+ ? undefined
537
+ : 'Enter an ISO-8601 datetime, e.g. 2026-06-22T14:30:00Z',
618
538
  });
619
- if (p.isCancel(selected)) {
539
+ if (p.isCancel(ts)) {
620
540
  p.cancel('Operation cancelled.');
621
541
  process.exit(0);
622
542
  }
623
- return { kind: 's3', name: /** @type {string} */ (selected) };
543
+ return { kind: 's3', name: /** @type {string} */ (ts).trim() };
624
544
  }
625
545
 
626
546
  // compose restore is wal-g-based (S3 pull, no file transfer). Mirrors
@@ -712,8 +632,8 @@ export async function runComposeRestore({
712
632
  const DB_STATEFULSET = 'supabase-supabase-db';
713
633
 
714
634
  /** Set or clear the RESTORE_TARGET marker the init container reads. */
715
- function setRestoreMarker(ip, sshKeyPath, value) {
716
- sshKubectl(ip, sshKeyPath, [
635
+ async function setRestoreMarker(ip, sshKeyPath, value) {
636
+ await sshKubectl(ip, sshKeyPath, [
717
637
  'patch',
718
638
  'secret',
719
639
  'vibecarbon-secrets',
@@ -738,7 +658,7 @@ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath })
738
658
 
739
659
  s.start('Restoring database via wal-g (app will be temporarily unavailable)');
740
660
  // 1. Mark the restore so the db pod's init container fetches on next boot.
741
- setRestoreMarker(serverIp, sshKeyPath, target);
661
+ await setRestoreMarker(serverIp, sshKeyPath, target);
742
662
  // 2. Quiesce the app so it isn't querying mid-swap.
743
663
  await perfAsync('restore.scaleDownApp', async () => scaleDownApp(serverIp, sshKeyPath));
744
664
  try {
@@ -746,14 +666,14 @@ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath })
746
666
  // and runs `wal-g backup-fetch` before postgres starts.
747
667
  s.message('Fetching from S3 + recovering (wal-g)');
748
668
  await perfAsync('restore.walgFetch', async () => {
749
- sshKubectl(serverIp, sshKeyPath, [
669
+ await sshKubectl(serverIp, sshKeyPath, [
750
670
  'rollout',
751
671
  'restart',
752
672
  `statefulset/${DB_STATEFULSET}`,
753
673
  '-n',
754
674
  'vibecarbon',
755
675
  ]);
756
- sshKubectl(serverIp, sshKeyPath, [
676
+ await sshKubectl(serverIp, sshKeyPath, [
757
677
  'rollout',
758
678
  'status',
759
679
  `statefulset/${DB_STATEFULSET}`,
@@ -767,7 +687,7 @@ async function runK8sRestore({ s, chosenSource, envName, serverIp, sshKeyPath })
767
687
  } finally {
768
688
  // 5. Always clear the marker so an unrelated future pod restart does NOT
769
689
  // re-fetch and wipe live data. Runs even if the restore failed.
770
- setRestoreMarker(serverIp, sshKeyPath, '');
690
+ await setRestoreMarker(serverIp, sshKeyPath, '');
771
691
  }
772
692
  // 6. Bring the app back.
773
693
  await perfAsync('restore.scaleUpApp', async () => scaleUpApp(serverIp, sshKeyPath));
package/src/scale.js CHANGED
@@ -23,8 +23,8 @@ import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
23
23
  import { c, printBanner } from './lib/colors.js';
24
24
  import { runCommand } from './lib/command.js';
25
25
  import { loadCredentials, saveProjectConfig } from './lib/config.js';
26
- import { buildCostBreakdown, formatCostLines } from './lib/cost.js';
27
26
  import { useDnsChallenge } from './lib/deploy/acme.js';
27
+ import { collectComposeBuildArgs } from './lib/deploy/compose/build-args.js';
28
28
  import {
29
29
  backupCompose,
30
30
  dockerLoginOnServer,
@@ -45,7 +45,7 @@ import { requireLicense } from './lib/licensing/index.js';
45
45
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
46
46
  import { perfAsync } from './lib/perf.js';
47
47
  import { assertInProjectDir } from './lib/project-guard.js';
48
- import { HetznerProvider } from './lib/providers/hetzner.js';
48
+ import { HETZNER_PRICING_URL, HetznerProvider } from './lib/providers/hetzner.js';
49
49
  import { buildComposeTypeOptions, buildSimpleTypeOptions } from './lib/server-types.js';
50
50
  import { parseDotenv } from './lib/shell.js';
51
51
  import { sshRun, sshRunScript } from './lib/ssh.js';
@@ -149,8 +149,11 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
149
149
  process.exit(1);
150
150
  }
151
151
 
152
- // Fetch live server types for accurate pricing
152
+ // Fetch live server types (drives the per-region type/profile options below)
153
+ const typesSpinner = p.spinner();
154
+ typesSpinner.start('Fetching available server types...');
153
155
  await HetznerProvider.fetchServerTypes(apiToken);
156
+ typesSpinner.stop('Server types loaded');
154
157
 
155
158
  const region = envConfig.region || 'fsn1';
156
159
  const currentType = envConfig.serverType || 'cpx21';
@@ -230,17 +233,9 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
230
233
  return;
231
234
  }
232
235
 
233
- // Cost comparison
234
236
  const ha = envConfig.deployMode === 'compose-ha';
235
237
  const services = projectConfig.services || {};
236
- const costBreakdown = buildCostBreakdown({
237
- isComposeDeploy: true,
238
- ha,
239
- serverType: newType,
240
- region,
241
- services,
242
- });
243
- p.note(formatCostLines(costBreakdown), 'New Estimated Cost');
238
+ p.note(`Current Hetzner pricing: ${HETZNER_PRICING_URL}`, 'Cost');
244
239
 
245
240
  // Confirmation
246
241
  const serverLabel =
@@ -280,7 +275,10 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
280
275
  // name, so the `name` we pass is mostly cosmetic — keep it aligned with
281
276
  // the rest of the codebase.
282
277
  const sshKeyName = `${projectName}-${environment}-key`;
278
+ const prep = p.spinner();
279
+ prep.start('Registering SSH key with Hetzner...');
283
280
  const sshKeyId = await provider.createSSHKey(sshKeyName, sshPubKey);
281
+ prep.stop('SSH key ready');
284
282
 
285
283
  // Blue-green replacement for each target server. For compose-ha we run
286
284
  // primary + standby in parallel — every per-server step (backup, create,
@@ -371,7 +369,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
371
369
  // other keys flow through transparently.
372
370
  let oldEnv = {};
373
371
  try {
374
- const envText = sshRun(server.ip, sshKeyPath, ['cat', `/opt/${projectName}/.env`]);
372
+ const envText = await sshRun(server.ip, sshKeyPath, ['cat', `/opt/${projectName}/.env`]);
375
373
  oldEnv = parseDotenv(envText);
376
374
  } catch {
377
375
  // Old server may be unreachable; renderBundle will fall back to the
@@ -454,8 +452,14 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
454
452
  // Reuses the same code that does direct-mode deploy builds.
455
453
  if (oldAppImage && isLocalOnlyImageTag(oldAppImage)) {
456
454
  s.start(`Building image ${oldAppImage} on new server...`);
455
+ // Pass the VITE_* build args (same as deploy/HA). Vite inlines
456
+ // import.meta.env.VITE_* at build time, so without these the new
457
+ // server's frontend bundle ships empty VITE_SUPABASE_URL/ANON_KEY and
458
+ // the browser throws "Missing Supabase environment variables". Deploy
459
+ // (orchestrator.js) and HA (ha.js) already do this — scale did not.
460
+ const scaleBuildArgs = collectComposeBuildArgs(process.cwd(), { projectName, domain });
457
461
  const built = await perfAsync('scale.sideloadImage', () =>
458
- buildRemote(newIp, sshKeyPath, oldAppImage, process.cwd()),
462
+ buildRemote(newIp, sshKeyPath, oldAppImage, process.cwd(), scaleBuildArgs),
459
463
  );
460
464
  // buildRemote returns false on failure (after its own retries). The app
461
465
  // image is local-only, so if it isn't built on the new server, the
@@ -534,7 +538,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
534
538
  // would force a needless re-issue), so skip it.
535
539
  // Uses an SCP'd script so quoting/redirection stay intact without local shell.
536
540
  if (!dnsChallenge) {
537
- sshRunScript(
541
+ await sshRunScript(
538
542
  newIp,
539
543
  sshKeyPath,
540
544
  `cd ${remoteDir} && docker compose exec -T traefik sh -c 'echo "{}" > /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json'`,
@@ -568,7 +572,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
568
572
  const replOverlayFlag =
569
573
  "$([ -f docker-compose.replication.yml ] && echo '-f docker-compose.replication.yml')";
570
574
  s.start('Recreating all services after restore...');
571
- sshRunScript(
575
+ await sshRunScript(
572
576
  newIp,
573
577
  sshKeyPath,
574
578
  `cd ${remoteDir} && docker compose ${composeFlags} ${replOverlayFlag} up -d --force-recreate 2>&1`,
@@ -580,7 +584,7 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
580
584
  // the db container's env (rendered into .env at deploy), so the cron
581
585
  // just runs compose-backup.sh on schedule — no separate S3 plumbing.
582
586
  if (envConfig.backup) {
583
- setupComposeBackupCron(newIp, sshKeyPath, projectName, envConfig.backup);
587
+ await setupComposeBackupCron(newIp, sshKeyPath, projectName, envConfig.backup);
584
588
  p.log.info('Backup cron restored on new server');
585
589
  }
586
590
 
@@ -1001,19 +1005,8 @@ export async function run(args) {
1001
1005
  (c) => c === 'workerType' || c === 'masterType' || c === 'supabaseType',
1002
1006
  );
1003
1007
 
1004
- // Cost breakdown with new values applied
1005
- const services = projectConfig.services || {};
1006
1008
  const isHA = envConfig.ha?.enabled && envConfig.secondaryRegion;
1007
- const costBreakdown = buildCostBreakdown({
1008
- isComposeDeploy: false,
1009
- ha: !!isHA,
1010
- masterServerType: newValues.masterType || currentMasterType,
1011
- supabaseServerType: newValues.supabaseType || currentSupabaseType,
1012
- workerServerType: newValues.workerType || currentWorkerType,
1013
- region,
1014
- services,
1015
- });
1016
- p.note(formatCostLines(costBreakdown), 'New Estimated Cost');
1009
+ p.note(`Current Hetzner pricing: ${HETZNER_PRICING_URL}`, 'Cost');
1017
1010
 
1018
1011
  // 6. Require API token if infra changes (server type swap) needed
1019
1012
  let apiToken = null;
@@ -1205,6 +1198,8 @@ export async function run(args) {
1205
1198
  // unit tests for this seam (see tests/unit/iac/probe-classify.test.ts).
1206
1199
  let probeOutputs = null;
1207
1200
  let probeError = null;
1201
+ const probeSpinner = p.spinner();
1202
+ probeSpinner.start(`Reading prior cluster state (${label})...`);
1208
1203
  try {
1209
1204
  const probeProgram = buildHetznerK8sProgram(programConfig);
1210
1205
  probeOutputs = await perfAsync(`scale.k8s.${clusterEnv}.probeOutputs`, () =>
@@ -1216,6 +1211,7 @@ export async function run(args) {
1216
1211
  } catch (err) {
1217
1212
  probeError = err instanceof Error ? err : new Error(String(err));
1218
1213
  }
1214
+ probeSpinner.stop(`Prior cluster state read (${label})`);
1219
1215
  const probe = classifyK3sTokenProbe({ outputs: probeOutputs, error: probeError });
1220
1216
  const priorK3sToken = probe.priorK3sToken;
1221
1217
  switch (probe.status) {