vibecarbon 0.5.0 → 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 (63) 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/biome.json +1 -1
  7. package/carbon/cloud-init/k3s/master-init.sh +26 -2
  8. package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
  9. package/carbon/cloud-init/k3s/worker-init.sh +22 -2
  10. package/carbon/content/docs/cli.mdx +1 -1
  11. package/carbon/docker-compose.yml +6 -0
  12. package/carbon/k8s/base/app/deployment.yaml +5 -0
  13. package/carbon/k8s/values/supabase.values.yaml +30 -0
  14. package/carbon/package.json +34 -36
  15. package/carbon/scripts/generate-sitemap.ts +18 -3
  16. package/carbon/src/client/App.tsx +2 -0
  17. package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
  18. package/carbon/src/client/components/PricingSection.tsx +44 -1
  19. package/carbon/src/client/components/SEO.tsx +6 -3
  20. package/carbon/src/client/components/ui/calendar.tsx +1 -1
  21. package/carbon/src/client/index.css +9 -1
  22. package/carbon/src/client/index.html +3 -3
  23. package/carbon/src/client/locales/en.json +1 -1
  24. package/carbon/src/client/pages/Pricing.tsx +169 -0
  25. package/carbon/src/client/pages/settings/Billing.tsx +2 -6
  26. package/carbon/src/server/index.ts +23 -4
  27. package/carbon/src/shared/billing-catalog.ts +25 -0
  28. package/carbon/src/shared/billing-catalog.types.ts +35 -0
  29. package/carbon/src/shared/pricing.ts +56 -1
  30. package/package.json +13 -13
  31. package/src/activate.js +1 -1
  32. package/src/backup.js +33 -87
  33. package/src/configure.js +365 -137
  34. package/src/create.js +5 -2
  35. package/src/deploy.js +24 -7
  36. package/src/destroy.js +11 -4
  37. package/src/failover.js +64 -47
  38. package/src/lib/backup-format.js +84 -0
  39. package/src/lib/billing/stripe-catalog.js +152 -0
  40. package/src/lib/billing/write-catalog.js +123 -0
  41. package/src/lib/ci-setup.js +11 -1
  42. package/src/lib/config-registry.js +116 -0
  43. package/src/lib/deploy/compose/build-args.js +6 -0
  44. package/src/lib/deploy/compose/ha.js +25 -25
  45. package/src/lib/deploy/compose/index.js +29 -18
  46. package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
  47. package/src/lib/deploy/k8s/k3s.js +20 -10
  48. package/src/lib/deploy/orchestrator.js +22 -1
  49. package/src/lib/deploy/prompts.js +21 -2
  50. package/src/lib/deploy/utils.js +56 -32
  51. package/src/lib/github-environments.js +29 -0
  52. package/src/lib/licensing/index.js +8 -3
  53. package/src/lib/licensing/tiers.js +0 -3
  54. package/src/lib/pod-backups.js +3 -3
  55. package/src/lib/providers/base.js +1 -1
  56. package/src/lib/providers/hetzner.js +74 -82
  57. package/src/lib/server-types.js +3 -31
  58. package/src/lib/ssh.js +19 -18
  59. package/src/lib/walg-backups.js +81 -0
  60. package/src/restore.js +122 -202
  61. package/src/scale.js +25 -29
  62. package/src/status.js +0 -69
  63. package/src/lib/cost.js +0 -103
package/src/create.js CHANGED
@@ -1245,6 +1245,9 @@ ADMIN_PASSWORD=${escapeDotenv(variables.ADMIN_PASSWORD)}
1245
1245
 
1246
1246
  # Site URL
1247
1247
  SITE_URL="http://localhost:5173"
1248
+ # Public canonical URL baked into the client (og: tags, sitemap). Overridden at
1249
+ # deploy with the real apex domain; localhost is correct for local dev.
1250
+ VITE_PUBLIC_URL="http://localhost:5173"
1248
1251
 
1249
1252
  # =============================================================================
1250
1253
  # OAUTH PROVIDERS (optional)
@@ -1269,10 +1272,10 @@ STRIPE_WEBHOOK_SECRET=""
1269
1272
 
1270
1273
  # SMTP Email (shared by Supabase Auth and app transactional emails)
1271
1274
  SMTP_HOST=""
1272
- SMTP_PORT=465
1275
+ SMTP_PORT=587
1273
1276
  SMTP_USER=""
1274
1277
  SMTP_PASS=""
1275
- SMTP_ADMIN_EMAIL="noreply@example.com"
1278
+ SMTP_ADMIN_EMAIL=""
1276
1279
  SMTP_SENDER_NAME="${variables.PROJECT_NAME}"
1277
1280
 
1278
1281
  # =============================================================================
package/src/deploy.js CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  setupHA as setupHetznerDnsHA,
51
51
  setupSimple as setupHetznerDnsSimple,
52
52
  } from './lib/hetzner-dns.js';
53
+ import { requireLicense } from './lib/licensing/index.js';
53
54
  import { detectPackageManager } from './lib/project.js';
54
55
  import { assertInProjectDir } from './lib/project-guard.js';
55
56
  import { HetznerS3Provider, sanitizeBucketName } from './lib/providers/hetzner-s3.js';
@@ -101,6 +102,11 @@ const SPEC = {
101
102
  value: '<id>',
102
103
  description: 'Primary Hetzner region (e.g. hel1, nbg1, fsn1)',
103
104
  },
105
+ {
106
+ name: 'standby-region',
107
+ value: '<id>',
108
+ description: 'HA standby/failover region (defaults to a same-continent partner of -region)',
109
+ },
104
110
  {
105
111
  name: 'mode',
106
112
  value: '<mode>',
@@ -155,11 +161,13 @@ function buildLegacyArgs(values, positional) {
155
161
  compose: mode === 'compose' || mode === 'compose-ha',
156
162
  k8s: mode === 'k8s' || mode === 'k8s-ha',
157
163
  ha: mode === 'compose-ha' || mode === 'k8s-ha',
164
+ // HA standby region — settable via -standby-region, else the interactive
165
+ // prompt / saved config / same-continent default fill it in.
166
+ secondaryRegion: values['standby-region'] || null,
158
167
  // Everything below is no longer settable via CLI flag; the
159
168
  // interactive prompt + `.vibecarbon.json` cover these. Defaults
160
169
  // keep gatherDeploymentConfig's `args.X || envConfig.X || …`
161
170
  // chains working unchanged.
162
- secondaryRegion: null,
163
171
  serverType: null,
164
172
  masterServerType: null,
165
173
  workerServerType: null,
@@ -207,14 +215,23 @@ async function main() {
207
215
  process.exit(0);
208
216
  }
209
217
 
210
- // 0a. Confirm we're inside a Vibecarbon project before doing anything
211
- // that walks the tree. An accidental `vibecarbon deploy` from a parent
212
- // directory (e.g. ~/repos) would otherwise scan every sibling repo and
213
- // dump real tokens to the operator's scrollback before any "no project
214
- // found" message could appear.
218
+ // 0a. Confirm we're inside a Vibecarbon project before anything else.
219
+ // assertInProjectDir() is the documented FIRST action for every
220
+ // project-scoped command (see lib/project-guard.js): an accidental
221
+ // `vibecarbon deploy` from a parent directory (e.g. ~/repos) must print
222
+ // the canonical "not in a project" message and exit non-zero — it must not
223
+ // fall through to the license upsell or a secret-scan that walks sibling
224
+ // repos. This matches scale/backup/restore/failover, which all assert
225
+ // first; deploy previously gated the license first and so exited 0 (the
226
+ // upsell path) outside a project, tripping the not-in-project contract.
215
227
  assertInProjectDir();
216
228
 
217
- // 0b. Refuse to deploy if the working tree contains likely secrets.
229
+ // 0b. Deploying to production requires a paid license. Graphite (free)
230
+ // covers local create/up/test; deploy/scale/backup/etc. need Diamond+.
231
+ // Gate after the project check — help/version already returned above.
232
+ requireLicense('deploy');
233
+
234
+ // 0c. Refuse to deploy if the working tree contains likely secrets.
218
235
  // We push the project's tracked files to GitHub during deploy (CI,
219
236
  // GitOps, throwaway e2e repos), and a leak there is permanent
220
237
  // — GitHub's secret-scanner will email the user even if the repo is
package/src/destroy.js CHANGED
@@ -837,7 +837,12 @@ async function main() {
837
837
 
838
838
  if (!projectConfig.environments || Object.keys(projectConfig.environments).length === 0) {
839
839
  // Check for orphan Pulumi stacks (deployment interrupted before config save)
840
+ const orphanScan = p.spinner();
841
+ orphanScan.start('Scanning for orphan Pulumi stacks...');
840
842
  const orphans = await findOrphanPulumiStacks(projectConfig);
843
+ orphanScan.stop(
844
+ orphans.length > 0 ? `Found ${orphans.length} orphan stack(s)` : 'No orphan stacks found',
845
+ );
841
846
 
842
847
  if (orphans.length > 0) {
843
848
  p.log.warn('No environments found in .vibecarbon.json');
@@ -906,15 +911,17 @@ async function main() {
906
911
  process.exit(1);
907
912
  }
908
913
 
914
+ const orphanSpinner = p.spinner();
909
915
  for (const orphan of orphans) {
916
+ orphanSpinner.start(`Destroying orphan stack ${c.bold(orphan.name)} (pulumi destroy)...`);
910
917
  try {
911
918
  await destroyOrphanPulumiStack(orphan, {
912
919
  apiToken: orphanApiToken,
913
920
  s3Config: orphanS3Config,
914
921
  });
915
- p.log.success(`Destroyed: ${orphan.name}`);
922
+ orphanSpinner.stop(`Destroyed: ${orphan.name}`);
916
923
  } catch (error) {
917
- p.log.error(`Failed to destroy ${orphan.name}: ${error.message}`);
924
+ orphanSpinner.error(`Failed to destroy ${orphan.name}: ${error.message}`);
918
925
  }
919
926
  }
920
927
 
@@ -1056,7 +1063,7 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1056
1063
  try {
1057
1064
  if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
1058
1065
  const { backupCompose } = await import('./lib/deploy/compose/index.js');
1059
- backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
1066
+ await backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
1060
1067
  retain: envConfig.backup?.retentionDays,
1061
1068
  });
1062
1069
  backupSpinner.stop('wal-g base backup pushed to S3');
@@ -1215,7 +1222,7 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
1215
1222
  s.start('Stopping Docker Compose services...');
1216
1223
  try {
1217
1224
  const { destroyCompose } = await import('./lib/deploy/compose/index.js');
1218
- destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
1225
+ await destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
1219
1226
  s.stop('Docker Compose services removed');
1220
1227
  results.servers.push(`vps-${serverIp}`);
1221
1228
  } catch (error) {
package/src/failover.js CHANGED
@@ -18,7 +18,6 @@ import { selectEnvironment } from './lib/cli/select-environment.js';
18
18
  import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
19
19
  import { createDNSRecord } from './lib/cloudflare.js';
20
20
  import { c, printBanner } from './lib/colors.js';
21
- import { runCommand } from './lib/command.js';
22
21
  import { loadCredentials, loadProjectConfig } from './lib/config.js';
23
22
  import {
24
23
  createDNSRecord as hetznerDnsCreateRecord,
@@ -150,10 +149,10 @@ export function identifyServers(envName, envConfig, _projectConfig) {
150
149
  * whether to attempt a pg_basebackup re-seed before promoting the standby.
151
150
  * Primary-dead failovers skip the re-seed (can't; best-effort promote).
152
151
  */
153
- function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
152
+ async function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
154
153
  try {
155
- const pod = getPostgresPod(primaryIp, sshKeyPath);
156
- const result = sshKubectl(primaryIp, sshKeyPath, [
154
+ const pod = await getPostgresPod(primaryIp, sshKeyPath);
155
+ const result = await sshKubectl(primaryIp, sshKeyPath, [
157
156
  'exec',
158
157
  '-n',
159
158
  'vibecarbon',
@@ -184,31 +183,31 @@ function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
184
183
  * true if re-seed succeeded, false if primary was unreachable or re-seed
185
184
  * itself failed (caller proceeds to promote with existing state).
186
185
  */
187
- function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
186
+ async function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
188
187
  if (!primarySupabaseIp) return false;
189
188
  try {
190
189
  // REPL_PASSWORD is written to both primary and standby .env.local at
191
190
  // create time; read it from the standby's filesystem (easier than
192
191
  // threading through the failover args).
193
- const replPassword = sshRun(standbyIp, sshKeyPath, [
192
+ const replPassword = await sshRun(standbyIp, sshKeyPath, [
194
193
  'bash',
195
194
  '-c',
196
195
  'grep -oE "^REPL_PASSWORD=[\\"\']?[^\\"\'\\n]+" /opt/*/.env.local 2>/dev/null | head -1 | sed -E "s/^REPL_PASSWORD=[\\"\']?//; s/[\\"\']?$//"',
197
196
  ]);
198
197
  if (!replPassword?.trim()) return false;
199
198
 
200
- const pod = getPostgresPod(standbyIp, sshKeyPath);
199
+ const pod = await getPostgresPod(standbyIp, sshKeyPath);
201
200
  const script = [
202
201
  `rm -rf /var/lib/postgresql/data/*`,
203
202
  `PGPASSWORD='${replPassword.trim()}' pg_basebackup -h ${primarySupabaseIp} -p 5432 -U replicator -D /var/lib/postgresql/data -Fp -Xs -R -c fast -S vibecarbon_standby_slot`,
204
203
  `echo "primary_slot_name = 'vibecarbon_standby_slot'" >> /var/lib/postgresql/data/postgresql.auto.conf`,
205
204
  `chown -R postgres:postgres /var/lib/postgresql/data`,
206
205
  ].join('\n');
207
- sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
206
+ await sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
208
207
  input: script,
209
208
  });
210
209
  // Restart the pod to pick up the new PGDATA + recovery config.
211
- sshKubectl(standbyIp, sshKeyPath, [
210
+ await sshKubectl(standbyIp, sshKeyPath, [
212
211
  'exec',
213
212
  '-n',
214
213
  'vibecarbon',
@@ -225,22 +224,22 @@ function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
225
224
  }
226
225
  }
227
226
 
228
- function promoteStandbyDatabase(standbyIp, sshKeyPath) {
227
+ async function promoteStandbyDatabase(standbyIp, sshKeyPath) {
229
228
  const _t = perfTimer('failover.promoteStandby');
230
229
  try {
231
- return _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath);
230
+ return await _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath);
232
231
  } finally {
233
232
  _t.end();
234
233
  }
235
234
  }
236
235
 
237
- function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
236
+ async function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
238
237
  // Get the postgres pod name via a separate SSH call (avoids local shell expansion of $())
239
- const pod = getPostgresPod(standbyIp, sshKeyPath);
238
+ const pod = await getPostgresPod(standbyIp, sshKeyPath);
240
239
 
241
240
  // Promote PostgreSQL on standby
242
241
  try {
243
- sshKubectl(standbyIp, sshKeyPath, [
242
+ await sshKubectl(standbyIp, sshKeyPath, [
244
243
  'exec',
245
244
  '-n',
246
245
  'vibecarbon',
@@ -258,7 +257,7 @@ function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
258
257
  // Poll until standby is no longer in recovery
259
258
  for (let i = 0; i < 30; i++) {
260
259
  try {
261
- const result = sshKubectl(standbyIp, sshKeyPath, [
260
+ const result = await sshKubectl(standbyIp, sshKeyPath, [
262
261
  'exec',
263
262
  '-n',
264
263
  'vibecarbon',
@@ -274,16 +273,16 @@ function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
274
273
  } catch {
275
274
  // Retry
276
275
  }
277
- runCommand(['sleep', '2'], { silent: true, ignoreError: true });
276
+ await new Promise((r) => setTimeout(r, 2000));
278
277
  }
279
278
 
280
279
  return false;
281
280
  }
282
281
 
283
- function scaleUpServices(ip, sshKeyPath) {
282
+ async function scaleUpServices(ip, sshKeyPath) {
284
283
  const _t = perfTimer('failover.scaleUpServices');
285
284
  try {
286
- return _scaleUpServicesImpl(ip, sshKeyPath);
285
+ return await _scaleUpServicesImpl(ip, sshKeyPath);
287
286
  } finally {
288
287
  _t.end();
289
288
  }
@@ -317,10 +316,10 @@ const FAILOVER_DEPLOYMENTS = [
317
316
  * the whole failover. Reports each result so the operator can see what
318
317
  * actually scaled.
319
318
  */
320
- function scaleDeployments(ip, sshKeyPath, deployments, replicas, label) {
319
+ async function scaleDeployments(ip, sshKeyPath, deployments, replicas, label) {
321
320
  for (const dep of deployments) {
322
321
  try {
323
- sshKubectl(ip, sshKeyPath, [
322
+ await sshKubectl(ip, sshKeyPath, [
324
323
  'scale',
325
324
  'deployment',
326
325
  dep.name,
@@ -342,7 +341,7 @@ function scaleDeployments(ip, sshKeyPath, deployments, replicas, label) {
342
341
  }
343
342
  }
344
343
 
345
- function _scaleUpServicesImpl(ip, sshKeyPath) {
344
+ async function _scaleUpServicesImpl(ip, sshKeyPath) {
346
345
  // Group by upReplicas so we don't touch the same deployment twice.
347
346
  const byReplicas = new Map();
348
347
  for (const dep of FAILOVER_DEPLOYMENTS) {
@@ -350,20 +349,26 @@ function _scaleUpServicesImpl(ip, sshKeyPath) {
350
349
  byReplicas.get(dep.upReplicas).push(dep);
351
350
  }
352
351
  for (const [replicas, deps] of byReplicas.entries()) {
353
- scaleDeployments(ip, sshKeyPath, deps, replicas, `scale-up standby (replicas=${replicas})`);
352
+ await scaleDeployments(
353
+ ip,
354
+ sshKeyPath,
355
+ deps,
356
+ replicas,
357
+ `scale-up standby (replicas=${replicas})`,
358
+ );
354
359
  }
355
360
  }
356
361
 
357
- function waitForHealth(ip, sshKeyPath) {
362
+ async function waitForHealth(ip, sshKeyPath) {
358
363
  const _t = perfTimer('failover.waitForHealth');
359
364
  try {
360
- return _waitForHealthImpl(ip, sshKeyPath);
365
+ return await _waitForHealthImpl(ip, sshKeyPath);
361
366
  } finally {
362
367
  _t.end();
363
368
  }
364
369
  }
365
370
 
366
- function _waitForHealthImpl(ip, sshKeyPath) {
371
+ async function _waitForHealthImpl(ip, sshKeyPath) {
367
372
  // The previous implementation curled http://localhost:3000/api/health from
368
373
  // the standby master — but K8s app pods don't run on the master's host
369
374
  // network (no hostPort:3000), so localhost:3000 has nothing listening and
@@ -373,7 +378,7 @@ function _waitForHealthImpl(ip, sshKeyPath) {
373
378
  // surfaces the real failure (ImagePullBackOff, CrashLoop, missing secret)
374
379
  // in the kubectl error rather than a generic timeout.
375
380
  try {
376
- sshKubectl(ip, sshKeyPath, [
381
+ await sshKubectl(ip, sshKeyPath, [
377
382
  'rollout',
378
383
  'status',
379
384
  'deployment/app',
@@ -470,9 +475,9 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
470
475
  // disaster failover) — promotion proceeds with whatever state standby
471
476
  // has. Requires primarySupabaseIp from identifyServers (persisted in
472
477
  // envConfig.servers by orchestrator.js post-deploy).
473
- if (isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
478
+ if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
474
479
  s.start('Re-seeding standby from primary (pg_basebackup)');
475
- const reseeded = reseedStandbyFromPrimary(
480
+ const reseeded = await reseedStandbyFromPrimary(
476
481
  servers.standby.ip,
477
482
  sshKeyPath,
478
483
  servers.primary.supabaseIp,
@@ -484,7 +489,7 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
484
489
 
485
490
  // Step 1: Promote standby database
486
491
  s.start('Promoting standby database');
487
- const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
492
+ const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
488
493
  s.stop(
489
494
  promoted
490
495
  ? 'Standby database promoted to primary'
@@ -499,13 +504,19 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
499
504
  // scaleDeployments — don't abort the whole failover on one missing
500
505
  // chart name.
501
506
  s.start('Scaling down old primary services');
502
- scaleDeployments(servers.primary.ip, sshKeyPath, FAILOVER_DEPLOYMENTS, 0, 'scale-down primary');
507
+ await scaleDeployments(
508
+ servers.primary.ip,
509
+ sshKeyPath,
510
+ FAILOVER_DEPLOYMENTS,
511
+ 0,
512
+ 'scale-down primary',
513
+ );
503
514
  s.stop('Old primary services scaled down');
504
515
 
505
516
  // Step 3: Scale up standby services
506
517
  s.start('Scaling up standby services');
507
518
  try {
508
- scaleUpServices(servers.standby.ip, sshKeyPath);
519
+ await scaleUpServices(servers.standby.ip, sshKeyPath);
509
520
  } catch (error) {
510
521
  s.stop('Some services failed to scale');
511
522
  p.log.warn(`Scale error: ${error.message}`);
@@ -514,7 +525,7 @@ async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, t
514
525
 
515
526
  // Wait for health endpoint
516
527
  s.start('Waiting for standby health check');
517
- const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
528
+ const healthy = await waitForHealth(servers.standby.ip, sshKeyPath);
518
529
  s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
519
530
 
520
531
  // Step 4: Switch DNS A record to standby floating IP. proxied:false
@@ -627,9 +638,9 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
627
638
  // disaster failover) — promotion proceeds with whatever state standby
628
639
  // has. Requires primarySupabaseIp from identifyServers (persisted in
629
640
  // envConfig.servers by orchestrator.js post-deploy).
630
- if (isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
641
+ if (await isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
631
642
  s.start('Re-seeding standby from primary (pg_basebackup)');
632
- const reseeded = reseedStandbyFromPrimary(
643
+ const reseeded = await reseedStandbyFromPrimary(
633
644
  servers.standby.ip,
634
645
  sshKeyPath,
635
646
  servers.primary.supabaseIp,
@@ -641,7 +652,7 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
641
652
 
642
653
  // Step 1: Promote standby database
643
654
  s.start('Promoting standby database');
644
- const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
655
+ const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
645
656
  s.stop(
646
657
  promoted
647
658
  ? 'Standby database promoted to primary'
@@ -655,13 +666,19 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
655
666
  // See FAILOVER_DEPLOYMENTS for the chart-canonical names — bare
656
667
  // `auth`/`rest`/etc. silently no-op'd in an earlier version.
657
668
  s.start('Scaling down old primary services');
658
- scaleDeployments(servers.primary.ip, sshKeyPath, FAILOVER_DEPLOYMENTS, 0, 'scale-down primary');
669
+ await scaleDeployments(
670
+ servers.primary.ip,
671
+ sshKeyPath,
672
+ FAILOVER_DEPLOYMENTS,
673
+ 0,
674
+ 'scale-down primary',
675
+ );
659
676
  s.stop('Old primary services scaled down');
660
677
 
661
678
  // Step 3: Scale up standby services
662
679
  s.start('Scaling up standby services');
663
680
  try {
664
- scaleUpServices(servers.standby.ip, sshKeyPath);
681
+ await scaleUpServices(servers.standby.ip, sshKeyPath);
665
682
  } catch (error) {
666
683
  s.stop('Some services failed to scale');
667
684
  p.log.warn(`Scale error: ${error.message}`);
@@ -670,7 +687,7 @@ async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, t
670
687
 
671
688
  // Wait for health endpoint
672
689
  s.start('Waiting for standby health check');
673
- const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
690
+ const healthy = await waitForHealth(servers.standby.ip, sshKeyPath);
674
691
  s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
675
692
 
676
693
  // Step 4: Update DNS A record via Hetzner DNS API
@@ -756,13 +773,13 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
756
773
 
757
774
  // Promote standby database
758
775
  s.start('Promoting standby database');
759
- const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
776
+ const promoted = await promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
760
777
  s.stop(promoted ? 'Standby database promoted' : 'Promotion may still be in progress');
761
778
 
762
779
  // Scale down old primary services to prevent split-brain
763
780
  s.start('Scaling down old primary services');
764
781
  try {
765
- sshKubectl(servers.primary.ip, sshKeyPath, [
782
+ await sshKubectl(servers.primary.ip, sshKeyPath, [
766
783
  'scale',
767
784
  'deployment',
768
785
  'app',
@@ -770,7 +787,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
770
787
  'vibecarbon',
771
788
  '--replicas=0',
772
789
  ]);
773
- sshKubectl(servers.primary.ip, sshKeyPath, [
790
+ await sshKubectl(servers.primary.ip, sshKeyPath, [
774
791
  'scale',
775
792
  'deployment',
776
793
  'auth',
@@ -778,7 +795,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
778
795
  'vibecarbon',
779
796
  '--replicas=0',
780
797
  ]);
781
- sshKubectl(servers.primary.ip, sshKeyPath, [
798
+ await sshKubectl(servers.primary.ip, sshKeyPath, [
782
799
  'scale',
783
800
  'deployment',
784
801
  'rest',
@@ -786,7 +803,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
786
803
  'vibecarbon',
787
804
  '--replicas=0',
788
805
  ]);
789
- sshKubectl(servers.primary.ip, sshKeyPath, [
806
+ await sshKubectl(servers.primary.ip, sshKeyPath, [
790
807
  'scale',
791
808
  'deployment',
792
809
  'realtime',
@@ -802,7 +819,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
802
819
  // Scale up services
803
820
  s.start('Scaling up standby services');
804
821
  try {
805
- scaleUpServices(servers.standby.ip, sshKeyPath);
822
+ await scaleUpServices(servers.standby.ip, sshKeyPath);
806
823
  } catch (error) {
807
824
  s.stop('Some services failed to scale');
808
825
  p.log.warn(`Scale error: ${error.message}`);
@@ -811,7 +828,7 @@ async function failoverHAManual(envName, envConfig, projectConfig, parsed, track
811
828
 
812
829
  // Wait for health
813
830
  s.start('Waiting for standby health check');
814
- const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
831
+ const healthy = await waitForHealth(servers.standby.ip, sshKeyPath);
815
832
  s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
816
833
 
817
834
  // Print DNS instructions
@@ -841,7 +858,7 @@ async function failoverSingleServer(envName, envConfig) {
841
858
  const s = p.spinner();
842
859
  s.start('Checking for available backups');
843
860
  try {
844
- const pod = sshKubectl(serverIp, sshKeyPath, [
861
+ const pod = await sshKubectl(serverIp, sshKeyPath, [
845
862
  'get',
846
863
  'pods',
847
864
  '-n',
@@ -851,7 +868,7 @@ async function failoverSingleServer(envName, envConfig) {
851
868
  '-o',
852
869
  'jsonpath={.items[0].metadata.name}',
853
870
  ]);
854
- const output = sshKubectl(serverIp, sshKeyPath, [
871
+ const output = await sshKubectl(serverIp, sshKeyPath, [
855
872
  'exec',
856
873
  '-n',
857
874
  'vibecarbon',
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Human-friendly formatting for backup listings.
3
+ *
4
+ * Backup filenames embed the canonical backup time as `_YYYYMMDD_HHMMSS_`
5
+ * (e.g. `myproject_20260519_120001_full.tar.gz`). Both `restore` (the picker)
6
+ * and `backup list` parse that and render a relative, scannable label —
7
+ * "Today, 12:00 PM" — instead of the raw S3 key.
8
+ */
9
+
10
+ const TS_RE = /_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})_/;
11
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
12
+
13
+ /**
14
+ * Parse the embedded backup timestamp from a backup filename.
15
+ * @param {string} name
16
+ * @returns {Date|null} local-time Date, or null if no timestamp is present
17
+ */
18
+ export function parseBackupTime(name) {
19
+ const m = typeof name === 'string' ? name.match(TS_RE) : null;
20
+ if (!m) return null;
21
+ const [, y, mo, d, h, mi, s] = m.map(Number);
22
+ const date = new Date(y, mo - 1, d, h, mi, s);
23
+ return Number.isNaN(date.getTime()) ? null : date;
24
+ }
25
+
26
+ function clockTime(date) {
27
+ const h24 = date.getHours();
28
+ const m = date.getMinutes();
29
+ const ampm = h24 < 12 ? 'AM' : 'PM';
30
+ const h = h24 % 12 || 12;
31
+ return `${h}:${String(m).padStart(2, '0')} ${ampm}`;
32
+ }
33
+
34
+ function startOfDay(d) {
35
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
36
+ }
37
+
38
+ /**
39
+ * Render a relative, human-readable label for a backup filename:
40
+ * "Today, 12:00 PM" / "Yesterday, 6:00 PM" / "May 17, 12:00 PM" /
41
+ * "Dec 31 2025, 6:00 PM" (older year). Falls back to the raw name if the
42
+ * timestamp can't be parsed.
43
+ *
44
+ * @param {string} name
45
+ * @param {Date} [now] - injectable for testing; defaults to the current time
46
+ * @returns {string}
47
+ */
48
+ /**
49
+ * Render a Date as a relative, human-readable label.
50
+ * "Today, 12:00 PM" / "Yesterday, 6:00 PM" / "May 17, 12:00 PM" /
51
+ * "Dec 31 2025, 6:00 PM" (older year).
52
+ * @param {Date} date
53
+ * @param {Date} [now]
54
+ * @returns {string}
55
+ */
56
+ export function formatDate(date, now = new Date()) {
57
+ const dayDiff = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
58
+ let day;
59
+ if (dayDiff === 0) day = 'Today';
60
+ else if (dayDiff === 1) day = 'Yesterday';
61
+ else if (date.getFullYear() === now.getFullYear())
62
+ day = `${MONTHS[date.getMonth()]} ${date.getDate()}`;
63
+ else day = `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
64
+
65
+ return `${day}, ${clockTime(date)}`;
66
+ }
67
+
68
+ export function formatBackupTime(name, now = new Date()) {
69
+ const date = parseBackupTime(name);
70
+ if (!date) return name;
71
+ return formatDate(date, now);
72
+ }
73
+
74
+ /**
75
+ * Render an instant (Date or ISO-8601 string, e.g. wal-g's `time` field) using
76
+ * the same relative labels as formatBackupTime. Returns '' for unparseable input.
77
+ * @param {Date|string} value
78
+ * @param {Date} [now]
79
+ * @returns {string}
80
+ */
81
+ export function formatInstant(value, now = new Date()) {
82
+ const date = value instanceof Date ? value : new Date(value);
83
+ return Number.isNaN(date.getTime()) ? '' : formatDate(date, now);
84
+ }