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
@@ -10,6 +10,10 @@
10
10
  import { fetchWithRetry } from '../fetch-retry.js';
11
11
  import { BaseProvider } from './base.js';
12
12
 
13
+ // Canonical link to Hetzner's live pricing. We don't hard-code prices anywhere
14
+ // (they change), so surface this wherever a user would want current costs.
15
+ export const HETZNER_PRICING_URL = 'https://www.hetzner.com/cloud/';
16
+
13
17
  export class HetznerProvider extends BaseProvider {
14
18
  static NAME = 'Hetzner Cloud';
15
19
  static API_BASE = 'https://api.hetzner.cloud/v1';
@@ -22,33 +26,68 @@ export class HetznerProvider extends BaseProvider {
22
26
  hil: 'Hillsboro, Oregon, USA',
23
27
  };
24
28
 
29
+ // Continent grouping for HA standby selection. The goal is to keep a primary
30
+ // and its failover standby on the same continent so cross-region replication
31
+ // (wal-g/S3 + Postgres streaming) doesn't pay a transatlantic RTT.
32
+ static REGION_CONTINENT = {
33
+ fsn1: 'eu',
34
+ nbg1: 'eu',
35
+ hel1: 'eu',
36
+ ash: 'na',
37
+ hil: 'na',
38
+ };
39
+
40
+ /**
41
+ * Default HA standby region for a given primary: a DIFFERENT region on the
42
+ * same continent, so failover stays intra-continent (a US primary fails over
43
+ * to the other US region, not across the Atlantic). Used as the pre-selected
44
+ * value in the interactive standby prompt and as the headless default.
45
+ * @param {string} primaryRegion
46
+ * @returns {string}
47
+ */
48
+ static getDefaultStandbyRegion(primaryRegion) {
49
+ const continent = HetznerProvider.REGION_CONTINENT[primaryRegion];
50
+ const sameContinent = Object.keys(HetznerProvider.REGION_CONTINENT).filter(
51
+ (r) => r !== primaryRegion && HetznerProvider.REGION_CONTINENT[r] === continent,
52
+ );
53
+ if (sameContinent.length > 0) {
54
+ // Keep the conventional nbg1<->fsn1 EU pairing where it applies.
55
+ if (primaryRegion === 'nbg1') return 'fsn1';
56
+ return sameContinent[0];
57
+ }
58
+ // No same-continent partner (e.g. a future single-region continent) —
59
+ // fall back to the robust EU default, but never to the primary itself.
60
+ return primaryRegion === 'nbg1' ? 'fsn1' : 'nbg1';
61
+ }
62
+
25
63
  // Offline fallback catalog — used when the API is unreachable or no token is available.
26
- // Prices and availability are approximate; live data from fetchServerTypes() takes precedence.
64
+ // Specs only; availability is approximate and live data from fetchServerTypes() takes
65
+ // precedence. Prices are intentionally not hard-coded here — see HETZNER_PRICING_URL.
27
66
  static FALLBACK_SERVER_TYPES = {
28
- cx23: { vcpu: 2, ram: 4, disk: 40, price: '€4.19/mo' },
29
- cx33: { vcpu: 4, ram: 8, disk: 80, price: '€7.09/mo' },
30
- cx43: { vcpu: 8, ram: 16, disk: 160, price: '€13.49/mo' },
31
- cx53: { vcpu: 16, ram: 32, disk: 320, price: '€27.49/mo' },
32
- cx22: { vcpu: 2, ram: 4, disk: 40, price: '€3.99/mo' },
33
- cx32: { vcpu: 4, ram: 8, disk: 80, price: '€6.49/mo' },
34
- cx42: { vcpu: 8, ram: 16, disk: 160, price: '€11.99/mo' },
35
- cx52: { vcpu: 16, ram: 32, disk: 320, price: '€22.49/mo' },
36
- cpx11: { vcpu: 2, ram: 2, disk: 40, price: '€7.99/mo' },
37
- cpx21: { vcpu: 3, ram: 4, disk: 80, price: '€13.99/mo' },
38
- cpx22: { vcpu: 3, ram: 4, disk: 80, price: '€13.99/mo' },
39
- cpx31: { vcpu: 4, ram: 8, disk: 160, price: '€25.49/mo' },
40
- cpx41: { vcpu: 8, ram: 16, disk: 240, price: '€36.49/mo' },
41
- cpx51: { vcpu: 16, ram: 32, disk: 360, price: '€50.49/mo' },
42
- cax11: { vcpu: 2, ram: 4, disk: 40, price: '€4.49/mo' },
43
- cax21: { vcpu: 4, ram: 8, disk: 80, price: '€7.99/mo' },
44
- cax31: { vcpu: 8, ram: 16, disk: 160, price: '€15.99/mo' },
45
- cax41: { vcpu: 16, ram: 32, disk: 320, price: '€31.49/mo' },
46
- ccx13: { vcpu: 2, ram: 8, disk: 80, price: '€15.99/mo' },
47
- ccx23: { vcpu: 4, ram: 16, disk: 160, price: '€31.49/mo' },
48
- ccx33: { vcpu: 8, ram: 32, disk: 240, price: '€62.49/mo' },
49
- ccx43: { vcpu: 16, ram: 64, disk: 360, price: '€124.99/mo' },
50
- ccx53: { vcpu: 32, ram: 128, disk: 600, price: '€249.99/mo' },
51
- ccx63: { vcpu: 48, ram: 192, disk: 960, price: '€374.49/mo' },
67
+ cx23: { vcpu: 2, ram: 4, disk: 40 },
68
+ cx33: { vcpu: 4, ram: 8, disk: 80 },
69
+ cx43: { vcpu: 8, ram: 16, disk: 160 },
70
+ cx53: { vcpu: 16, ram: 32, disk: 320 },
71
+ cx22: { vcpu: 2, ram: 4, disk: 40 },
72
+ cx32: { vcpu: 4, ram: 8, disk: 80 },
73
+ cx42: { vcpu: 8, ram: 16, disk: 160 },
74
+ cx52: { vcpu: 16, ram: 32, disk: 320 },
75
+ cpx11: { vcpu: 2, ram: 2, disk: 40 },
76
+ cpx21: { vcpu: 3, ram: 4, disk: 80 },
77
+ cpx22: { vcpu: 3, ram: 4, disk: 80 },
78
+ cpx31: { vcpu: 4, ram: 8, disk: 160 },
79
+ cpx41: { vcpu: 8, ram: 16, disk: 240 },
80
+ cpx51: { vcpu: 16, ram: 32, disk: 360 },
81
+ cax11: { vcpu: 2, ram: 4, disk: 40 },
82
+ cax21: { vcpu: 4, ram: 8, disk: 80 },
83
+ cax31: { vcpu: 8, ram: 16, disk: 160 },
84
+ cax41: { vcpu: 16, ram: 32, disk: 320 },
85
+ ccx13: { vcpu: 2, ram: 8, disk: 80 },
86
+ ccx23: { vcpu: 4, ram: 16, disk: 160 },
87
+ ccx33: { vcpu: 8, ram: 32, disk: 240 },
88
+ ccx43: { vcpu: 16, ram: 64, disk: 360 },
89
+ ccx53: { vcpu: 32, ram: 128, disk: 600 },
90
+ ccx63: { vcpu: 48, ram: 192, disk: 960 },
52
91
  };
53
92
 
54
93
  // Live catalog populated by fetchServerTypes(). Falls back to FALLBACK_SERVER_TYPES.
@@ -61,24 +100,6 @@ export class HetznerProvider extends BaseProvider {
61
100
  static DEFAULT_TYPE = 'cpx11';
62
101
  static HA_REGIONS = ['fsn1', 'hel1'];
63
102
 
64
- // Infrastructure costs (EUR/mo, as of Apr 2026)
65
- static INFRA_COSTS = {
66
- floatingIp: 3.57,
67
- volumePerGb: 0.052, // Hetzner CSI volumes
68
- s3MinBucket: 4.49, // 250 GB Object Storage minimum
69
- firewall: 0, // Free
70
- network: 0, // Free (first 1 TB)
71
- };
72
-
73
- // Known PVC sizes (Gi) per service
74
- static VOLUME_SIZES = {
75
- base: 30, // postgres 20Gi + storage 10Gi
76
- observability: 25, // prometheus 10Gi + loki 10Gi + grafana 5Gi
77
- n8n: 5,
78
- metabase: 1,
79
- redis: 1,
80
- };
81
-
82
103
  static EU_REGIONS = ['fsn1', 'nbg1', 'hel1'];
83
104
  static US_REGIONS = ['ash', 'hil'];
84
105
 
@@ -123,27 +144,10 @@ export class HetznerProvider extends BaseProvider {
123
144
  locationTypes[locName].add(t.name);
124
145
  }
125
146
 
126
- // Build per-location price map
127
- const locationPrices = {};
128
- for (const p of t.prices || []) {
129
- const loc = p.location;
130
- if (loc) {
131
- locationPrices[loc] = `€${parseFloat(p.price_monthly.gross).toFixed(2)}/mo`;
132
- }
133
- }
134
-
135
- // Use first available location price as the default display price
136
- const firstPrice = t.prices[0];
137
- const defaultMonthly = firstPrice
138
- ? `€${parseFloat(firstPrice.price_monthly.gross).toFixed(2)}/mo`
139
- : '';
140
-
141
147
  types[t.name] = {
142
148
  vcpu: t.cores,
143
149
  ram: t.memory,
144
150
  disk: t.disk,
145
- price: defaultMonthly,
146
- locationPrices,
147
151
  cpuType: t.cpu_type,
148
152
  architecture: t.architecture,
149
153
  };
@@ -227,17 +231,19 @@ export class HetznerProvider extends BaseProvider {
227
231
  return defaults.workerType;
228
232
  }
229
233
 
230
- // Offline fallback: EU regions have ARM, US regions don't.
231
- // Only map to types that exist in the fallback catalog blindly swapping
232
- // the prefix can produce invalid types (e.g. cpx22cax22 which doesn't exist).
234
+ // Offline fallback. The ONLY architecture that isn't universally available
235
+ // is ARM (cax): EU regions have both cpx (x86) and cax (ARM); US regions
236
+ // have cpx but NO cax. So the only valid conversion is caxcpx in a US
237
+ // region (the requested ARM type can't exist there). x86 (cpx) is available
238
+ // everywhere, so it must NEVER be flipped to cax — doing so converted an
239
+ // x86 primary's cpx11 into ARM cax11 on an EU standby, which both mismatches
240
+ // the primary's architecture (the x86 app image won't run on ARM) and chases
241
+ // scarcer ARM capacity (RCA 2026-06-23: nbg1 standby failed
242
+ // resource_unavailable on cax). Keep cpx as-is.
233
243
  if (HetznerProvider.US_REGIONS.includes(targetRegion) && serverType.startsWith('cax')) {
234
244
  const alt = `cpx${serverType.replace(/^cax/, '')}`;
235
245
  if (HetznerProvider.FALLBACK_SERVER_TYPES[alt]) return alt;
236
246
  }
237
- if (HetznerProvider.EU_REGIONS.includes(targetRegion) && serverType.startsWith('cpx')) {
238
- const alt = `cax${serverType.replace(/^cpx/, '')}`;
239
- if (HetznerProvider.FALLBACK_SERVER_TYPES[alt]) return alt;
240
- }
241
247
  return serverType;
242
248
  }
243
249
 
@@ -251,11 +257,7 @@ export class HetznerProvider extends BaseProvider {
251
257
  if (available) {
252
258
  return [...available]
253
259
  .filter((name) => name in HetznerProvider.SERVER_TYPES)
254
- .map((name) => {
255
- const spec = HetznerProvider.SERVER_TYPES[name];
256
- const price = spec.locationPrices?.[region] || spec.price;
257
- return { name, ...spec, price };
258
- })
260
+ .map((name) => ({ name, ...HetznerProvider.SERVER_TYPES[name] }))
259
261
  .sort((a, b) => a.vcpu - b.vcpu || a.ram - b.ram);
260
262
  }
261
263
 
@@ -268,16 +270,6 @@ export class HetznerProvider extends BaseProvider {
268
270
  .map(([name, info]) => ({ name, ...info }));
269
271
  }
270
272
 
271
- /**
272
- * Returns the monthly price for a server type in a specific location.
273
- * Only available after fetchServerTypes() — returns null otherwise.
274
- */
275
- static getPrice(serverType, location) {
276
- const spec = HetznerProvider.SERVER_TYPES[serverType];
277
- if (!spec) return null;
278
- return (location && spec.locationPrices?.[location]) || spec.price || null;
279
- }
280
-
281
273
  /**
282
274
  * Create a new Hetzner server
283
275
  * @param {object} config - Server configuration
@@ -36,14 +36,6 @@ export const K8S_PROFILES = [
36
36
  },
37
37
  ];
38
38
 
39
- /**
40
- * Parse a price string like "€7.99/mo" into a number.
41
- */
42
- export function parsePrice(priceStr) {
43
- const match = (priceStr || '').match(/[\d.]+/);
44
- return match ? Number.parseFloat(match[0]) : 0;
45
- }
46
-
47
39
  /**
48
40
  * Format a server type label with aligned columns.
49
41
  * Pre-computes column widths from the full list so values line up.
@@ -67,7 +59,6 @@ function buildAlignedLabels(types) {
67
59
  * Disables types that are too small, marks minimum and recommended.
68
60
  */
69
61
  export function buildComposeTypeOptions(regionTypes, defaultType) {
70
- const { INFRA_COSTS } = HetznerProvider;
71
62
  const filtered = regionTypes.filter((t) => !t.cpuType || t.cpuType === 'shared');
72
63
  const labels = buildAlignedLabels(filtered);
73
64
 
@@ -76,18 +67,13 @@ export function buildComposeTypeOptions(regionTypes, defaultType) {
76
67
  const isMinimum = !tooSmall && t.ram === COMPOSE_MIN_RAM_GB && t.disk <= 40;
77
68
  const isRecommended = t.name === defaultType;
78
69
 
79
- const serverCost = parsePrice(t.price);
80
- const totalCost = serverCost + INFRA_COSTS.s3MinBucket;
81
-
82
70
  let hint = '';
83
71
  if (tooSmall) {
84
72
  hint = `need ${COMPOSE_MIN_RAM_GB}GB+ RAM for Supabase`;
85
73
  } else if (isMinimum) {
86
- hint = `~€${totalCost.toFixed(0)}/mo total — minimum`;
74
+ hint = 'minimum';
87
75
  } else if (isRecommended) {
88
- hint = `~€${totalCost.toFixed(0)}/mo total — recommended`;
89
- } else {
90
- hint = `~€${totalCost.toFixed(0)}/mo total`;
76
+ hint = 'recommended';
91
77
  }
92
78
 
93
79
  return {
@@ -101,11 +87,8 @@ export function buildComposeTypeOptions(regionTypes, defaultType) {
101
87
 
102
88
  /**
103
89
  * Build select options for K8s cluster size profiles.
104
- * Calculates total monthly cost including all provisioned infra.
105
90
  */
106
91
  export function buildK8sProfileOptions(_region, regionTypes, secondaryRegion) {
107
- const { INFRA_COSTS, VOLUME_SIZES, SERVER_TYPES } = HetznerProvider;
108
-
109
92
  // Determine available types (intersection of both regions for HA)
110
93
  const availableNames = new Set(regionTypes.map((t) => t.name));
111
94
  let effectiveAvailable = availableNames;
@@ -117,10 +100,6 @@ export function buildK8sProfileOptions(_region, regionTypes, secondaryRegion) {
117
100
  }
118
101
 
119
102
  const hasArm = effectiveAvailable.has('cax11');
120
- const regionMultiplier = secondaryRegion ? 2 : 1;
121
-
122
- // Per-cluster fixed costs: floating IP + base CSI volumes
123
- const fixedPerCluster = INFRA_COSTS.floatingIp + VOLUME_SIZES.base * INFRA_COSTS.volumePerGb;
124
103
 
125
104
  return K8S_PROFILES.map((profile) => {
126
105
  const variant = hasArm ? profile.arm : profile.x86;
@@ -131,16 +110,9 @@ export function buildK8sProfileOptions(_region, regionTypes, secondaryRegion) {
131
110
  );
132
111
  if (!allAvailable) return null;
133
112
 
134
- const serverCost =
135
- parsePrice(SERVER_TYPES[variant.master]?.price) +
136
- parsePrice(SERVER_TYPES[variant.supabase]?.price) +
137
- parsePrice(SERVER_TYPES[variant.worker]?.price);
138
-
139
- const totalCost = (serverCost + fixedPerCluster) * regionMultiplier + INFRA_COSTS.s3MinBucket;
140
-
141
113
  return {
142
114
  value: profile.name,
143
- label: `${profile.label} ~€${totalCost.toFixed(0)}/mo`,
115
+ label: profile.label,
144
116
  hint: `${profile.hint} (${variant.master} + ${variant.supabase} + ${variant.worker})`,
145
117
  _variant: variant,
146
118
  };
package/src/lib/ssh.js CHANGED
@@ -16,7 +16,7 @@
16
16
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
17
17
  import { tmpdir } from 'node:os';
18
18
  import { join } from 'node:path';
19
- import { runCommand } from './command.js';
19
+ import { runCommandAsync } from './command.js';
20
20
  import { knownHostsPath } from './host-keys.js';
21
21
  import { shEscape } from './shell.js';
22
22
 
@@ -130,10 +130,11 @@ export const NO_PIN_SSH_OPTS = sshHostKeyOpts().join(' ');
130
130
  * @param {boolean} [options.firstConnect=false]
131
131
  * @param {number} [options.timeout=120000]
132
132
  * @param {boolean} [options.silent=true]
133
- * @returns {string} - trimmed stdout
133
+ * @param {string} [options.input] - stdin piped to the remote command
134
+ * @returns {Promise<string>} - trimmed stdout
134
135
  */
135
- export function sshRun(ip, sshKeyPath, argv, options = {}) {
136
- const { env, firstConnect = false, timeout = 120_000, silent = true } = options;
136
+ export async function sshRun(ip, sshKeyPath, argv, options = {}) {
137
+ const { env, firstConnect = false, timeout = 120_000, silent = true, input } = options;
137
138
  if (!Array.isArray(argv) || argv.length === 0) {
138
139
  throw new Error('sshRun requires a non-empty argv array');
139
140
  }
@@ -147,7 +148,7 @@ export function sshRun(ip, sshKeyPath, argv, options = {}) {
147
148
  `root@${ip}`,
148
149
  remoteCmd,
149
150
  ];
150
- const out = runCommand(cmd, { silent, timeout, returnOutput: true });
151
+ const out = await runCommandAsync(cmd, { silent, timeout, returnOutput: true, input });
151
152
  return typeof out === 'string' ? out.trim() : '';
152
153
  }
153
154
 
@@ -157,17 +158,17 @@ export function sshRun(ip, sshKeyPath, argv, options = {}) {
157
158
  * via bash, then removed. Use for pipelines that genuinely need shell
158
159
  * features; never interpolate untrusted values into the script string.
159
160
  */
160
- export function sshRunScript(ip, sshKeyPath, bashScript, options = {}) {
161
+ export async function sshRunScript(ip, sshKeyPath, bashScript, options = {}) {
161
162
  const localTmp = mkdtempSync(join(tmpdir(), 'vb-sshscript-'));
162
163
  const localPath = join(localTmp, 'script.sh');
163
164
  const remotePath = `/tmp/vb-script-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.sh`;
164
165
  try {
165
166
  writeFileSync(localPath, bashScript, { mode: 0o700 });
166
- scpUpload(ip, sshKeyPath, localPath, remotePath, options);
167
- return sshRun(ip, sshKeyPath, ['bash', remotePath], options);
167
+ await scpUpload(ip, sshKeyPath, localPath, remotePath, options);
168
+ return await sshRun(ip, sshKeyPath, ['bash', remotePath], options);
168
169
  } finally {
169
170
  try {
170
- sshRun(ip, sshKeyPath, ['rm', '-f', remotePath], {
171
+ await sshRun(ip, sshKeyPath, ['rm', '-f', remotePath], {
171
172
  ...options,
172
173
  silent: true,
173
174
  timeout: 15_000,
@@ -182,7 +183,7 @@ export function sshRunScript(ip, sshKeyPath, bashScript, options = {}) {
182
183
  /**
183
184
  * Download a file from a remote server via SCP. Argv form.
184
185
  */
185
- export function scpDownload(ip, sshKeyPath, remotePath, localPath, options = {}) {
186
+ export async function scpDownload(ip, sshKeyPath, remotePath, localPath, options = {}) {
186
187
  const { env, firstConnect = false, timeout = 300_000 } = options;
187
188
  const cmd = [
188
189
  'scp',
@@ -193,13 +194,13 @@ export function scpDownload(ip, sshKeyPath, remotePath, localPath, options = {})
193
194
  `root@${ip}:${remotePath}`,
194
195
  localPath,
195
196
  ];
196
- runCommand(cmd, { silent: true, timeout });
197
+ await runCommandAsync(cmd, { silent: true, timeout });
197
198
  }
198
199
 
199
200
  /**
200
201
  * Upload a file to a remote server via SCP. Argv form.
201
202
  */
202
- export function scpUpload(ip, sshKeyPath, localPath, remotePath, options = {}) {
203
+ export async function scpUpload(ip, sshKeyPath, localPath, remotePath, options = {}) {
203
204
  const { env, firstConnect = false, timeout = 300_000 } = options;
204
205
  const cmd = [
205
206
  'scp',
@@ -210,7 +211,7 @@ export function scpUpload(ip, sshKeyPath, localPath, remotePath, options = {}) {
210
211
  localPath,
211
212
  `root@${ip}:${remotePath}`,
212
213
  ];
213
- runCommand(cmd, { silent: true, timeout });
214
+ await runCommandAsync(cmd, { silent: true, timeout });
214
215
  }
215
216
 
216
217
  /**
@@ -222,13 +223,13 @@ export function scpUpload(ip, sshKeyPath, localPath, remotePath, options = {}) {
222
223
  * @param {string} sshKeyPath
223
224
  * @param {string[]} kubectlArgv - kubectl subcommand + args (e.g. ['get', 'pods', '-n', 'vibecarbon'])
224
225
  * @param {object} [options]
225
- * @returns {string} trimmed stdout
226
+ * @returns {Promise<string>} trimmed stdout
226
227
  */
227
- export function sshKubectl(ip, sshKeyPath, kubectlArgv, options = {}) {
228
+ export async function sshKubectl(ip, sshKeyPath, kubectlArgv, options = {}) {
228
229
  if (!Array.isArray(kubectlArgv) || kubectlArgv.length === 0) {
229
230
  throw new Error('sshKubectl requires a non-empty argv array (without leading "kubectl")');
230
231
  }
231
- return sshRun(
232
+ return await sshRun(
232
233
  ip,
233
234
  sshKeyPath,
234
235
  ['env', `KUBECONFIG=${K3S_KUBECONFIG}`, 'kubectl', ...kubectlArgv],
@@ -239,8 +240,8 @@ export function sshKubectl(ip, sshKeyPath, kubectlArgv, options = {}) {
239
240
  /**
240
241
  * Get the name of the PostgreSQL pod in the vibecarbon namespace.
241
242
  */
242
- export function getPostgresPod(ip, sshKeyPath) {
243
- return sshKubectl(ip, sshKeyPath, [
243
+ export async function getPostgresPod(ip, sshKeyPath) {
244
+ return await sshKubectl(ip, sshKeyPath, [
244
245
  'get',
245
246
  'pods',
246
247
  '-n',
@@ -0,0 +1,81 @@
1
+ /**
2
+ * List wal-g base backups by running `wal-g backup-list --json` inside the
3
+ * Postgres container (compose) or pod (k8s). wal-g is the source of truth for
4
+ * what is actually restorable — the legacy `backups/*_full.tar.gz` S3 objects
5
+ * are NOT wal-g backups and must not be offered as restore targets.
6
+ *
7
+ * Restore itself always fetches the LATEST base backup and replays WAL (to the
8
+ * present or a point-in-time target), so this listing is informational — it
9
+ * tells the operator the available recovery window for PITR.
10
+ */
11
+
12
+ import { getPostgresPod, sshKubectl, sshRun } from './ssh.js';
13
+
14
+ /**
15
+ * Parse `wal-g backup-list --json` output into normalized entries, newest-first.
16
+ * Pure (no I/O) so it's unit-testable. Tolerant of wal-g version field-name
17
+ * drift (backup_name/BackupName, time/start_time/finish_time/…).
18
+ *
19
+ * @param {string} jsonStr
20
+ * @returns {Array<{ name: string, time: Date }>}
21
+ */
22
+ export function parseWalgBackupList(jsonStr) {
23
+ let arr;
24
+ try {
25
+ arr = JSON.parse(jsonStr);
26
+ } catch {
27
+ return [];
28
+ }
29
+ if (!Array.isArray(arr)) return [];
30
+ return arr
31
+ .map((b) => {
32
+ const name = b.backup_name ?? b.BackupName ?? b.name ?? null;
33
+ const rawTime =
34
+ b.time ?? b.start_time ?? b.StartTime ?? b.finish_time ?? b.FinishTime ?? b.creation_time;
35
+ const time = rawTime ? new Date(rawTime) : null;
36
+ return { name, time };
37
+ })
38
+ .filter((b) => b.name && b.time && !Number.isNaN(b.time.getTime()))
39
+ .sort((a, b) => b.time.getTime() - a.time.getTime());
40
+ }
41
+
42
+ /**
43
+ * List wal-g base backups for an environment. Best-effort: returns [] on any
44
+ * failure (db down, wal-g missing, no --json support, parse error) so callers
45
+ * can degrade gracefully without breaking the restore/backup flow.
46
+ *
47
+ * @param {object} opts
48
+ * @param {string} opts.serverIp - master/single node IP
49
+ * @param {string} opts.sshKeyPath
50
+ * @param {string} opts.projectName
51
+ * @param {boolean} opts.isCompose - true for compose, false for k8s
52
+ * @returns {Promise<Array<{ name: string, time: Date }>>}
53
+ */
54
+ export async function listWalgBackups({ serverIp, sshKeyPath, projectName, isCompose }) {
55
+ try {
56
+ let out;
57
+ if (isCompose) {
58
+ out = await sshRun(serverIp, sshKeyPath, [
59
+ 'bash',
60
+ '-lc',
61
+ `cd /opt/${projectName} && docker compose exec -T db wal-g backup-list --json`,
62
+ ]);
63
+ } else {
64
+ const pod = await getPostgresPod(serverIp, sshKeyPath);
65
+ if (!pod) return [];
66
+ out = await sshKubectl(serverIp, sshKeyPath, [
67
+ 'exec',
68
+ '-n',
69
+ 'vibecarbon',
70
+ pod,
71
+ '--',
72
+ 'wal-g',
73
+ 'backup-list',
74
+ '--json',
75
+ ]);
76
+ }
77
+ return typeof out === 'string' ? parseWalgBackupList(out) : [];
78
+ } catch {
79
+ return [];
80
+ }
81
+ }