vibecarbon 0.5.1 → 0.6.1

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 (62) 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 +15 -15
  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/server/lib/env.ts +12 -1
  26. package/carbon/src/shared/billing-catalog.ts +25 -0
  27. package/carbon/src/shared/billing-catalog.types.ts +35 -0
  28. package/carbon/src/shared/pricing.ts +56 -1
  29. package/package.json +1 -1
  30. package/src/activate.js +1 -1
  31. package/src/backup.js +33 -87
  32. package/src/configure.js +365 -137
  33. package/src/create.js +5 -2
  34. package/src/deploy.js +24 -7
  35. package/src/destroy.js +11 -4
  36. package/src/failover.js +64 -47
  37. package/src/lib/backup-format.js +84 -0
  38. package/src/lib/billing/stripe-catalog.js +152 -0
  39. package/src/lib/billing/write-catalog.js +123 -0
  40. package/src/lib/ci-setup.js +11 -1
  41. package/src/lib/config-registry.js +116 -0
  42. package/src/lib/deploy/compose/build-args.js +6 -0
  43. package/src/lib/deploy/compose/ha.js +25 -25
  44. package/src/lib/deploy/compose/index.js +29 -18
  45. package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
  46. package/src/lib/deploy/k8s/k3s.js +19 -9
  47. package/src/lib/deploy/orchestrator.js +22 -1
  48. package/src/lib/deploy/prompts.js +21 -2
  49. package/src/lib/deploy/utils.js +56 -32
  50. package/src/lib/github-environments.js +29 -0
  51. package/src/lib/licensing/index.js +8 -3
  52. package/src/lib/licensing/tiers.js +0 -3
  53. package/src/lib/pod-backups.js +3 -3
  54. package/src/lib/providers/base.js +1 -1
  55. package/src/lib/providers/hetzner.js +74 -82
  56. package/src/lib/server-types.js +3 -31
  57. package/src/lib/ssh.js +19 -18
  58. package/src/lib/walg-backups.js +81 -0
  59. package/src/restore.js +122 -202
  60. package/src/scale.js +25 -29
  61. package/src/status.js +0 -69
  62. package/src/lib/cost.js +0 -103
@@ -0,0 +1,169 @@
1
+ import {
2
+ type CatalogTier,
3
+ catalogTiers,
4
+ formatPlanPrice,
5
+ formatTierPrice,
6
+ type Plan,
7
+ plans,
8
+ } from '@shared/pricing';
9
+ import { ArrowRight, Check, X } from 'lucide-react';
10
+ import { Link } from 'react-router-dom';
11
+ import { Wordmark } from '@/components/Logo';
12
+ import { Nav } from '@/components/Nav';
13
+ import { SEO } from '@/components/SEO';
14
+ import { Badge } from '@/components/ui/badge';
15
+ import { Button } from '@/components/ui/button';
16
+ import { cn } from '@/lib/utils';
17
+
18
+ interface PricingCardProps {
19
+ name: string;
20
+ description: string | null;
21
+ price: string;
22
+ /** Feature rows. Catalog tiers are all included; demo plans mark exclusions. */
23
+ features: { text: string; included: boolean }[];
24
+ ctaLabel: string;
25
+ popular?: boolean;
26
+ }
27
+
28
+ function PricingCard({
29
+ name,
30
+ description,
31
+ price,
32
+ features,
33
+ ctaLabel,
34
+ popular = false,
35
+ }: PricingCardProps) {
36
+ return (
37
+ <div
38
+ className={cn(
39
+ 'relative flex flex-col rounded-2xl border p-6 transition-shadow',
40
+ popular ? 'border-primary shadow-lg shadow-primary/10' : 'border-border'
41
+ )}
42
+ >
43
+ {popular && (
44
+ <div className="absolute -top-3 left-1/2 -translate-x-1/2">
45
+ <Badge>Most popular</Badge>
46
+ </div>
47
+ )}
48
+
49
+ <div className="mb-4">
50
+ <h2 className="text-lg font-semibold">{name}</h2>
51
+ {description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
52
+ </div>
53
+
54
+ <div className="mb-6">
55
+ <span className="text-4xl font-bold">{price}</span>
56
+ </div>
57
+
58
+ <ul className="mb-8 flex-1 space-y-3">
59
+ {features.map((feature) => (
60
+ <li key={feature.text} className="flex items-start gap-2.5 text-sm">
61
+ {feature.included ? (
62
+ <Check className="mt-0.5 size-4 shrink-0 text-primary" />
63
+ ) : (
64
+ <X className="mt-0.5 size-4 shrink-0 text-muted-foreground/50" />
65
+ )}
66
+ <span className={feature.included ? '' : 'text-muted-foreground/50'}>
67
+ {feature.text}
68
+ </span>
69
+ </li>
70
+ ))}
71
+ </ul>
72
+
73
+ <Button variant={popular ? 'default' : 'outline'} size="lg" asChild className="w-full">
74
+ <Link to="/signup" className="inline-flex items-center gap-2">
75
+ {ctaLabel}
76
+ <ArrowRight className="size-4 shrink-0" />
77
+ </Link>
78
+ </Button>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ /** Cap the column count at the number of cards so 1–2 tiers don't stretch oddly. */
84
+ const gridColsByCount: Record<number, string> = {
85
+ 1: 'sm:max-w-md sm:mx-auto',
86
+ 2: 'sm:grid-cols-2 sm:max-w-3xl sm:mx-auto',
87
+ 3: 'sm:grid-cols-2 lg:grid-cols-3',
88
+ 4: 'sm:grid-cols-2 lg:grid-cols-4',
89
+ };
90
+
91
+ export default function Pricing() {
92
+ const useCatalog = catalogTiers.length > 0;
93
+ const count = useCatalog ? catalogTiers.length : plans.length;
94
+ const gridCols = gridColsByCount[Math.min(count, 4)] ?? 'sm:grid-cols-2 lg:grid-cols-4';
95
+
96
+ return (
97
+ <div className="min-h-screen bg-background">
98
+ <Nav />
99
+ <SEO
100
+ title="Pricing"
101
+ description="Simple, transparent pricing. Start free and scale as you grow."
102
+ />
103
+
104
+ <div className="mx-auto max-w-6xl px-6 pt-32 pb-24">
105
+ {/* Header */}
106
+ <div className="mx-auto mb-16 max-w-2xl text-center">
107
+ <h1 className="mb-4 text-4xl font-black tracking-tight md:text-5xl">
108
+ Simple, transparent pricing
109
+ </h1>
110
+ <p className="text-lg text-muted-foreground">
111
+ Start free and upgrade when you're ready to ship. No hidden fees.
112
+ </p>
113
+ </div>
114
+
115
+ {/* Plan cards */}
116
+ <div className={cn('grid items-stretch gap-6', gridCols)}>
117
+ {useCatalog
118
+ ? catalogTiers.map((tier: CatalogTier) => (
119
+ <PricingCard
120
+ key={tier.priceId}
121
+ name={tier.name}
122
+ description={tier.description}
123
+ price={formatTierPrice(tier)}
124
+ features={tier.features.map((text) => ({ text, included: true }))}
125
+ ctaLabel={tier.amount === 0 ? 'Get started' : `Choose ${tier.name}`}
126
+ />
127
+ ))
128
+ : plans.map((plan: Plan) => (
129
+ <PricingCard
130
+ key={plan.id}
131
+ name={plan.name}
132
+ description={plan.description}
133
+ price={formatPlanPrice(plan)}
134
+ features={plan.features}
135
+ ctaLabel={plan.id === 'free' ? 'Get started' : `Choose ${plan.name}`}
136
+ popular={plan.popular}
137
+ />
138
+ ))}
139
+ </div>
140
+ </div>
141
+
142
+ <footer className="border-t border-border py-8">
143
+ <div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-6 sm:flex-row">
144
+ <Wordmark size="sm" />
145
+ <div className="flex items-center gap-6">
146
+ <Link
147
+ to="/"
148
+ className="text-sm text-muted-foreground transition-colors hover:text-foreground"
149
+ >
150
+ Home
151
+ </Link>
152
+ <Link
153
+ to="/privacy"
154
+ className="text-sm text-muted-foreground transition-colors hover:text-foreground"
155
+ >
156
+ Privacy
157
+ </Link>
158
+ <Link
159
+ to="/terms"
160
+ className="text-sm text-muted-foreground transition-colors hover:text-foreground"
161
+ >
162
+ Terms
163
+ </Link>
164
+ </div>
165
+ </div>
166
+ </footer>
167
+ </div>
168
+ );
169
+ }
@@ -1,4 +1,4 @@
1
- import { type Plan, type PlanId, plans } from '@shared/pricing';
1
+ import { formatPlanPrice, type Plan, type PlanId, plans } from '@shared/pricing';
2
2
  import { useMutation, useQuery } from '@tanstack/react-query';
3
3
  import { AlertCircle, Check, CreditCard, ExternalLink, Loader2, X } from 'lucide-react';
4
4
  import { useTranslation } from 'react-i18next';
@@ -287,7 +287,6 @@ export default function Billing() {
287
287
  {displayPlans.map((plan) => {
288
288
  const isCurrentPlan = plan.id === currentPlanId;
289
289
  const priceId = getPriceId(plan);
290
- const price = plan.price.monthly;
291
290
 
292
291
  return (
293
292
  <div
@@ -306,10 +305,7 @@ export default function Billing() {
306
305
  <p className="text-sm text-muted-foreground mt-1">{plan.description}</p>
307
306
  </div>
308
307
  <div className="mb-4">
309
- <span className="text-3xl font-bold">
310
- {price === 0 ? t('common.free') : `$${(price / 100).toFixed(0)}`}
311
- </span>
312
- {price > 0 && <span className="text-muted-foreground text-sm">/mo</span>}
308
+ <span className="text-3xl font-bold">{formatPlanPrice(plan)}</span>
313
309
  </div>
314
310
  <ul className="space-y-2 mb-6 flex-1">
315
311
  {plan.features.map((feature) => (
@@ -506,11 +506,30 @@ if (process.env.NODE_ENV === 'production') {
506
506
  })
507
507
  );
508
508
 
509
- // Serve static files from dist/client
510
- app.use('/*', serveStatic({ root: './dist/client' }));
509
+ // Serve static files from dist/client. HTML (index.html for `/`) must NOT be
510
+ // heuristically cached, or returning visitors keep loading a stale index that
511
+ // references old hash-busted asset URLs after a deploy. `no-cache` still allows
512
+ // 304 revalidation, so it's cheap — only the tiny HTML is re-checked.
513
+ app.use(
514
+ '/*',
515
+ serveStatic({
516
+ root: './dist/client',
517
+ onFound: (path, c) => {
518
+ if (path.endsWith('.html')) c.header('Cache-Control', 'no-cache');
519
+ },
520
+ })
521
+ );
511
522
 
512
- // SPA fallback - serve index.html for client-side routing
513
- app.get('/*', serveStatic({ path: './dist/client/index.html' }));
523
+ // SPA fallback - serve index.html for client-side routing (always revalidate)
524
+ app.get(
525
+ '/*',
526
+ serveStatic({
527
+ path: './dist/client/index.html',
528
+ onFound: (_path, c) => {
529
+ c.header('Cache-Control', 'no-cache');
530
+ },
531
+ })
532
+ );
514
533
  } else {
515
534
  // Development: redirect root to API docs (frontend served by Vite dev server)
516
535
  app.get('/', (c) => c.redirect('/api/docs'));
@@ -94,8 +94,19 @@ if (process.env.NODE_ENV !== 'production' && portOffset !== 0) {
94
94
  }
95
95
  }
96
96
 
97
+ // Treat empty-string env vars as unset before validating. Docker Compose
98
+ // `env_file` (and other env sources) surface an unset optional var as "" rather
99
+ // than omitting it; zod's `.optional()` permits `undefined` but NOT "", so an
100
+ // empty SMTP_ADMIN_EMAIL / REDIS_URL / SMTP_PORT would fail .email()/.url()/
101
+ // .number() and crash boot with "Server configuration error". Stripping blanks
102
+ // makes "" mean "not set", matching operator intent (and how the k8s Secret
103
+ // path already skips empty values).
104
+ const cleanedEnv = Object.fromEntries(
105
+ Object.entries(process.env).filter(([, value]) => value !== ''),
106
+ );
107
+
97
108
  // Validate environment variables
98
- const parsed = envSchema.safeParse(process.env);
109
+ const parsed = envSchema.safeParse(cleanedEnv);
99
110
 
100
111
  if (!parsed.success) {
101
112
  // In production, don't expose which specific environment variables failed validation
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Billing catalog snapshot — single source of truth for the operator's activated
3
+ * provider products (the dynamic, price-sorted tier list).
4
+ *
5
+ * GENERATED by `vibecarbon configure` (Payments → Stripe): it fetches the live
6
+ * products/prices, lets the operator pick which to activate, and writes them here
7
+ * so the public pricing surface renders accurate pricing WITHOUT calling the
8
+ * provider API on every page load. Re-run `vibecarbon configure` to refresh, then
9
+ * redeploy.
10
+ *
11
+ * Contains NO secrets — product names, amounts, and price IDs are all public.
12
+ * Ships empty; `pricing.ts` falls back to the generic free/starter/pro demo plans
13
+ * until this is populated.
14
+ *
15
+ * Types live in `billing-catalog.types.ts` (not regenerated). Do not hand-edit
16
+ * the data below — re-run `vibecarbon configure` instead.
17
+ */
18
+
19
+ import type { BillingCatalog } from './billing-catalog.types';
20
+
21
+ export const billingCatalog: BillingCatalog = {
22
+ provider: null,
23
+ generatedAt: null,
24
+ tiers: [],
25
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Type definitions for the billing-catalog snapshot. Kept separate from the
3
+ * generated data file (`billing-catalog.ts`) so `vibecarbon configure` can
4
+ * regenerate the data without touching these stable types.
5
+ *
6
+ * The catalog is a price-sorted list of the products the operator activated in
7
+ * their provider (via `vibecarbon configure` → Payments). Each activated product
8
+ * becomes a tier shown on the public pricing surface. The template's generic
9
+ * free/starter/pro demo (in `pricing.ts`) is separate and used when the catalog
10
+ * is empty.
11
+ */
12
+
13
+ export interface CatalogTier {
14
+ /** Provider price ID (e.g. Stripe `price_...`). */
15
+ priceId: string;
16
+ /** Product name — the tier's display name. */
17
+ name: string;
18
+ description: string | null;
19
+ /** Feature bullets, from the product's provider "marketing features". */
20
+ features: string[];
21
+ /** Amount in the currency's smallest unit (e.g. cents). 0 = free. */
22
+ amount: number;
23
+ /** ISO 4217 currency code, lowercase (e.g. `usd`). */
24
+ currency: string;
25
+ interval: 'month' | 'year' | 'one_time';
26
+ type: 'recurring' | 'one_time';
27
+ }
28
+
29
+ export interface BillingCatalog {
30
+ provider: 'stripe' | 'paddle' | 'polar' | null;
31
+ /** ISO 8601 timestamp of the last `vibecarbon configure` snapshot. */
32
+ generatedAt: string | null;
33
+ /** Activated tiers, sorted by `amount` ascending. */
34
+ tiers: CatalogTier[];
35
+ }
@@ -11,6 +11,11 @@
11
11
  * Server-side: import { getStripePriceId } from '@/shared/pricing' (resolves env vars)
12
12
  */
13
13
 
14
+ import { billingCatalog } from './billing-catalog';
15
+ import type { CatalogTier } from './billing-catalog.types';
16
+
17
+ export type { CatalogTier };
18
+
14
19
  export type PlanId = 'free' | 'starter' | 'pro';
15
20
 
16
21
  export interface PlanFeature {
@@ -32,6 +37,12 @@ export interface Plan {
32
37
  apiRequestsPerMinute: number;
33
38
  };
34
39
  popular?: boolean;
40
+ // Dynamic fields, populated from the billing-catalog snapshot when the
41
+ // provider has been configured. Undefined falls back to the hardcoded price.
42
+ priceId?: string;
43
+ currency?: string; // ISO 4217, lowercase (e.g. 'usd')
44
+ interval?: 'month' | 'year' | 'one_time';
45
+ type?: 'recurring' | 'one_time';
35
46
  }
36
47
 
37
48
  /**
@@ -57,7 +68,7 @@ function buildFeatures(included: string[]): PlanFeature[] {
57
68
  }));
58
69
  }
59
70
 
60
- export const plans: Plan[] = [
71
+ const basePlans: Plan[] = [
61
72
  {
62
73
  id: 'free',
63
74
  name: 'Free',
@@ -120,10 +131,54 @@ export const plans: Plan[] = [
120
131
  },
121
132
  ];
122
133
 
134
+ /**
135
+ * The template's generic SaaS demo plans (free/starter/pro). Used as the fallback
136
+ * on the pricing surface when no provider catalog has been configured.
137
+ */
138
+ export const plans: Plan[] = basePlans;
139
+
123
140
  export function getPlan(planId: PlanId): Plan | undefined {
124
141
  return plans.find((p) => p.id === planId);
125
142
  }
126
143
 
144
+ /**
145
+ * The activated provider products (price-sorted), synced by `vibecarbon configure`
146
+ * → Payments. When non-empty, these drive the public pricing surface instead of
147
+ * the generic demo `plans`. Sorted defensively by amount in case the snapshot isn't.
148
+ */
149
+ export const catalogTiers: CatalogTier[] = [...billingCatalog.tiers].sort(
150
+ (a, b) => a.amount - b.amount
151
+ );
152
+
153
+ function priceSuffix(interval: CatalogTier['interval'], type: CatalogTier['type']): string {
154
+ if (type === 'one_time' || interval === 'one_time') return '';
155
+ if (interval === 'year') return '/yr';
156
+ return '/mo';
157
+ }
158
+
159
+ function formatAmount(amount: number, currency: string): string {
160
+ const value = amount / 100;
161
+ return new Intl.NumberFormat('en-US', {
162
+ style: 'currency',
163
+ currency: (currency || 'usd').toUpperCase(),
164
+ minimumFractionDigits: 0,
165
+ maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
166
+ }).format(value);
167
+ }
168
+
169
+ /** Currency- and interval-aware price label for an activated catalog tier. */
170
+ export function formatTierPrice(tier: CatalogTier): string {
171
+ if (tier.amount === 0) return 'Free';
172
+ return `${formatAmount(tier.amount, tier.currency)}${priceSuffix(tier.interval, tier.type)}`;
173
+ }
174
+
175
+ /** Currency- and interval-aware price label for a demo plan. */
176
+ export function formatPlanPrice(plan: Plan): string {
177
+ const cents = plan.price.monthly;
178
+ if (cents === 0) return 'Free';
179
+ return `${formatAmount(cents, plan.currency ?? 'usd')}${priceSuffix(plan.interval ?? 'month', plan.type ?? 'recurring')}`;
180
+ }
181
+
127
182
  export function formatPrice(cents: number): string {
128
183
  if (cents === 0) return 'Free';
129
184
  return `$${(cents / 100).toFixed(0)}/mo`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
package/src/activate.js CHANGED
@@ -111,7 +111,7 @@ export async function runActivate(args) {
111
111
  spinner.stop('License validation failed');
112
112
  p.log.error(c.error(`Error: ${result.error}`));
113
113
  p.log.info('');
114
- p.log.info(`${c.dim('Purchase a license at')} ${c.info('https://vibecarbon.dev/pricing')}`);
114
+ p.log.info(`${c.dim('Purchase a license at')} ${c.info('https://vibecarbon.com/#pricing')}`);
115
115
  process.exit(1);
116
116
  }
117
117
 
package/src/backup.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { existsSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
15
  import * as p from '@clack/prompts';
16
+ import { formatInstant } from './lib/backup-format.js';
16
17
  import { downloadS3Backup, listS3Backups } from './lib/backup-s3.js';
17
18
  import { renderHelp } from './lib/cli/help.js';
18
19
  import { parseFlags } from './lib/cli/parse-flags.js';
@@ -30,7 +31,6 @@ import { getS3Credentials } from './lib/hetzner-guided-setup.js';
30
31
  import { requireLicense } from './lib/licensing/index.js';
31
32
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
32
33
  import { perfAsync } from './lib/perf.js';
33
- import { listPodBackups } from './lib/pod-backups.js';
34
34
  import { assertInProjectDir } from './lib/project-guard.js';
35
35
  import {
36
36
  getPostgresPod,
@@ -43,6 +43,7 @@ import {
43
43
  import { createTracker } from './lib/tracker.js';
44
44
  import { validateBackupFilename } from './lib/validators.js';
45
45
  import { VERSION } from './lib/version.js';
46
+ import { listWalgBackups } from './lib/walg-backups.js';
46
47
 
47
48
  // ============================================================================
48
49
  // COMMAND SPEC — single source of truth for argv parsing AND help output.
@@ -103,17 +104,21 @@ const ACTION_CHOICES = [
103
104
  // ============================================================================
104
105
 
105
106
  async function downloadPodBackup(ip, sshKeyPath, filename) {
106
- const pod = getPostgresPod(ip, sshKeyPath);
107
+ const pod = await getPostgresPod(ip, sshKeyPath);
107
108
 
108
109
  // Copy from pod to host
109
- sshKubectl(ip, sshKeyPath, ['cp', `vibecarbon/${pod}:/backups/${filename}`, `/tmp/${filename}`]);
110
+ await sshKubectl(ip, sshKeyPath, [
111
+ 'cp',
112
+ `vibecarbon/${pod}:/backups/${filename}`,
113
+ `/tmp/${filename}`,
114
+ ]);
110
115
 
111
116
  // SCP from host to local
112
117
  const localPath = join(process.cwd(), filename);
113
- scpDownload(ip, sshKeyPath, `/tmp/${filename}`, localPath);
118
+ await scpDownload(ip, sshKeyPath, `/tmp/${filename}`, localPath);
114
119
 
115
120
  // Clean up remote temp file
116
- sshRun(ip, sshKeyPath, ['rm', '-f', `/tmp/${filename}`]);
121
+ await sshRun(ip, sshKeyPath, ['rm', '-f', `/tmp/${filename}`]);
117
122
 
118
123
  return localPath;
119
124
  }
@@ -122,7 +127,7 @@ async function downloadPodBackup(ip, sshKeyPath, filename) {
122
127
  // K8S JOB BACKUP (triggers CronJob-based backup)
123
128
  // ============================================================================
124
129
 
125
- function triggerBackupJob(ip, sshKeyPath) {
130
+ async function triggerBackupJob(ip, sshKeyPath) {
126
131
  const timestamp = new Date()
127
132
  .toISOString()
128
133
  .replace(/[:.]/g, '-')
@@ -132,7 +137,7 @@ function triggerBackupJob(ip, sshKeyPath) {
132
137
  const jobName = `backup-manual-${timestamp}`;
133
138
 
134
139
  // Create a one-off Job from the CronJob template
135
- sshKubectl(ip, sshKeyPath, [
140
+ await sshKubectl(ip, sshKeyPath, [
136
141
  'create',
137
142
  'job',
138
143
  `--from=cronjob/backup`,
@@ -156,7 +161,7 @@ function triggerBackupJob(ip, sshKeyPath) {
156
161
  // diagnostic returned "No pod matching" because controller-manager already
157
162
  // GC'd the failed Pod within the ~1s window between SSH sessions).
158
163
  try {
159
- sshRunScript(
164
+ await sshRunScript(
160
165
  ip,
161
166
  sshKeyPath,
162
167
  `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
@@ -220,7 +225,7 @@ exit 1`,
220
225
  // run kubectl describe for runtime context that bare logs miss.
221
226
  let podDiag = '';
222
227
  try {
223
- podDiag = sshRunScript(
228
+ podDiag = await sshRunScript(
224
229
  ip,
225
230
  sshKeyPath,
226
231
  `export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
@@ -418,7 +423,7 @@ export async function run(args) {
418
423
  const s = tracker.spinner();
419
424
 
420
425
  if (action === 'list') {
421
- await runList({ s, isCompose, useS3, s3Config, serverIp, sshKeyPath, projectName, envName });
426
+ await runList({ s, isCompose, serverIp, sshKeyPath, projectName, envName });
422
427
  p.outro('');
423
428
  return;
424
429
  }
@@ -517,83 +522,23 @@ async function resolveS3Config({ isCompose, envName, projectName }) {
517
522
  return s3Config;
518
523
  }
519
524
 
520
- async function runList({
521
- s,
522
- isCompose,
523
- useS3,
524
- s3Config,
525
- serverIp,
526
- sshKeyPath,
527
- projectName,
528
- envName,
529
- }) {
530
- s.start('Listing backups');
531
-
532
- if (isCompose) {
533
- if (useS3) {
534
- try {
535
- const backups = await listS3Backups(s3Config);
536
- s.stop('Backups retrieved from S3');
537
- printBackupList(backups, envName);
538
- } catch (error) {
539
- s.stop('Failed to list S3 backups');
540
- p.log.error(error.message);
541
- }
542
- } else {
543
- try {
544
- const output = sshRunScript(
545
- serverIp,
546
- sshKeyPath,
547
- `ls -lhS /opt/${projectName}/backups/*_full.tar.gz 2>/dev/null || echo NO_BACKUPS`,
548
- );
549
- s.stop('Backups retrieved');
550
-
551
- if (output === 'NO_BACKUPS' || !output.trim()) {
552
- p.log.info('No backups found.');
553
- p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
554
- } else {
555
- const lines = output.trim().split('\n').filter(Boolean);
556
- p.log.info(`${c.bold(`Backups for ${envName}`)} (${lines.length} found):`);
557
- for (const line of lines) {
558
- p.log.message(` ${line}`);
559
- }
560
- }
561
- } catch (error) {
562
- s.stop('Failed to list backups');
563
- p.log.error(error.message);
564
- }
565
- }
566
- return;
567
- }
568
-
569
- // K8s
570
- if (useS3) {
571
- try {
572
- const backups = await listS3Backups(s3Config);
573
- s.stop('Backups retrieved from S3');
574
- printBackupList(backups, envName);
575
- } catch (error) {
576
- s.stop('Failed to list S3 backups');
577
- p.log.error(error.message);
578
- process.exit(1);
579
- }
580
- return;
581
- }
582
-
583
- const backups = listPodBackups(serverIp, sshKeyPath, { sort: 'largest' });
584
- s.stop('Backups retrieved');
585
- printBackupList(backups, envName);
586
- }
525
+ async function runList({ s, isCompose, serverIp, sshKeyPath, projectName, envName }) {
526
+ // List the actual wal-g base backups (the restorable ones) — NOT the legacy
527
+ // backups/*_full.tar.gz S3 objects, which wal-g neither writes nor restores.
528
+ s.start('Reading wal-g backups');
529
+ const backups = await listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose });
530
+ s.stop('Backups read');
587
531
 
588
- function printBackupList(backups, envName) {
589
- if (!backups || backups.length === 0) {
590
- p.log.info('No backups found.');
532
+ if (backups.length === 0) {
533
+ p.log.info('No wal-g base backups found.');
591
534
  p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
592
535
  return;
593
536
  }
594
- p.log.info(`${c.bold(`Backups for ${envName}`)} (${backups.length} found):`);
595
- for (const backup of backups) {
596
- p.log.message(` ${c.dim(backup.date.padEnd(22))} ${backup.size.padStart(10)} ${backup.name}`);
537
+ p.log.info(
538
+ `${c.bold(`Backups for ${envName}`)} (${backups.length} base backup(s), newest first):`,
539
+ );
540
+ for (const b of backups) {
541
+ p.log.message(` ${formatInstant(b.time).padEnd(22)} ${c.dim(b.name)}`);
597
542
  }
598
543
  }
599
544
 
@@ -614,7 +559,7 @@ async function runDownload({
614
559
  if (useS3) {
615
560
  await downloadS3Backup(s3Config, `backups/${source}`, localPath);
616
561
  } else {
617
- scpDownload(serverIp, sshKeyPath, `/opt/${projectName}/backups/${source}`, localPath);
562
+ await scpDownload(serverIp, sshKeyPath, `/opt/${projectName}/backups/${source}`, localPath);
618
563
  }
619
564
  s.stop('Download complete');
620
565
  p.log.success(`Saved to ${c.bold(localPath)}`);
@@ -665,7 +610,7 @@ async function runCreate({
665
610
  s.start('Creating wal-g base backup...');
666
611
  try {
667
612
  const { backupCompose } = await import('./lib/deploy/compose/index.js');
668
- backupCompose(serverIp, sshKeyPath, projectName, { retain: backupRetain });
613
+ await backupCompose(serverIp, sshKeyPath, projectName, { retain: backupRetain });
669
614
  // "uploaded to S3" wording matches the k8s path (line ~689) and the e2e
670
615
  // backup-step verification (which greps /uploaded to s3/i to confirm the
671
616
  // backup is durable before allowing destroy → restore).
@@ -683,8 +628,9 @@ async function runCreate({
683
628
  // K8s — trigger via CronJob template
684
629
  s.start('Creating backup (triggering Kubernetes Job)');
685
630
  try {
686
- const jobName = await perfAsync('backup.triggerJob', async () =>
687
- triggerBackupJob(serverIp, sshKeyPath),
631
+ const jobName = await perfAsync(
632
+ 'backup.triggerJob',
633
+ async () => await triggerBackupJob(serverIp, sshKeyPath),
688
634
  );
689
635
  s.stop('Backup created');
690
636