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
@@ -16,7 +16,7 @@ import { tmpdir } from 'node:os';
16
16
  import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import * as p from '@clack/prompts';
19
- import { runCommand, runCommandAsync } from '../../command.js';
19
+ import { runCommandAsync } from '../../command.js';
20
20
  import { perfAsync, perfTimer } from '../../perf.js';
21
21
  import { parseDotenv, shEscape } from '../../shell.js';
22
22
  import { NO_PIN_SSH_OPTS, sshRunScript } from '../../ssh.js';
@@ -54,14 +54,19 @@ const SSH_OPTS = NO_PIN_SSH_OPTS;
54
54
  * Shared with compose/ha.js (imported, not re-copied) so both halves of the
55
55
  * compose path use identical, single-sourced SSH options.
56
56
  */
57
- export function sshRun(ip, sshKeyPath, command, options = {}) {
58
- const { timeout = 120_000 } = options;
59
- return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
60
- stdio: 'pipe',
61
- timeout,
62
- encoding: 'utf-8',
63
- ...options,
64
- });
57
+ export async function sshRun(ip, sshKeyPath, command, options = {}) {
58
+ const { timeout = 120_000, ...rest } = options;
59
+ try {
60
+ return await runCommandAsync(
61
+ ['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command],
62
+ { silent: true, timeout, ...rest },
63
+ );
64
+ } catch {
65
+ // Preserve the legacy stdio:'pipe' contract: this runner returned `false`
66
+ // on remote non-zero exit (silent was unset, so runCommand never threw).
67
+ // ha.js callers + retry loops depend on false-not-throw — keep it identical.
68
+ return false;
69
+ }
65
70
  }
66
71
 
67
72
  /**
@@ -129,7 +134,11 @@ export async function sshRunAsync(ip, sshKeyPath, command, options = {}) {
129
134
  export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
130
135
  for (let i = 0; i < maxAttempts; i++) {
131
136
  try {
132
- runCommand(
137
+ // silent:true makes runCommandAsync REJECT on a non-zero/connection
138
+ // failure, so the catch below actually engages the retry loop. (The old
139
+ // sync runCommand with stdio:'pipe' returned false without throwing, so
140
+ // this returned true on the very first probe regardless of SSH state.)
141
+ await runCommandAsync(
133
142
  [
134
143
  'ssh',
135
144
  ...SSH_OPTS.split(' '),
@@ -141,7 +150,7 @@ export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
141
150
  'echo',
142
151
  'ok',
143
152
  ],
144
- { stdio: 'pipe', timeout: 10_000 },
153
+ { silent: true, timeout: 10_000 },
145
154
  );
146
155
  return true;
147
156
  } catch {
@@ -809,11 +818,11 @@ export async function runMigrations(ip, sshKeyPath, projectName) {
809
818
  /**
810
819
  * Destroy a Docker Compose deployment
811
820
  */
812
- export function destroyCompose(ip, sshKeyPath, projectName) {
821
+ export async function destroyCompose(ip, sshKeyPath, projectName) {
813
822
  const remoteDir = `/opt/${projectName}`;
814
823
 
815
824
  // Stop and remove containers + volumes
816
- sshRun(
825
+ await sshRun(
817
826
  ip,
818
827
  sshKeyPath,
819
828
  `cd ${remoteDir} && docker compose down -v --remove-orphans 2>/dev/null; rm -rf ${remoteDir}`,
@@ -862,10 +871,12 @@ export function composeBackupCmd(remoteDir, retain = 7) {
862
871
  * @param {object} [options]
863
872
  * @param {number} [options.retain] Full base backups to keep (default 7).
864
873
  */
865
- export function backupCompose(ip, sshKeyPath, projectName, options = {}) {
874
+ export async function backupCompose(ip, sshKeyPath, projectName, options = {}) {
866
875
  const remoteDir = `/opt/${projectName}`;
867
876
  const t = perfTimer('backup.walgPush');
868
- sshRun(ip, sshKeyPath, composeBackupCmd(remoteDir, options.retain ?? 7), { timeout: 900_000 });
877
+ await sshRun(ip, sshKeyPath, composeBackupCmd(remoteDir, options.retain ?? 7), {
878
+ timeout: 900_000,
879
+ });
869
880
  t.end();
870
881
  }
871
882
 
@@ -891,7 +902,7 @@ export function backupCompose(ip, sshKeyPath, projectName, options = {}) {
891
902
  * @param {(ip: string, key: string, script: string, o: object) => unknown} [opts.runScript=sshRunScript]
892
903
  * Injectable script runner — defaults to the real SSH runner; overridden in unit tests.
893
904
  */
894
- export function setupComposeBackupCron(ip, sshKeyPath, projectName, backupConfig, opts = {}) {
905
+ export async function setupComposeBackupCron(ip, sshKeyPath, projectName, backupConfig, opts = {}) {
895
906
  const { runScript = sshRunScript } = opts;
896
907
  const remoteDir = `/opt/${projectName}`;
897
908
  const schedule = backupConfig?.schedule || '0 2 * * *';
@@ -918,7 +929,7 @@ export function setupComposeBackupCron(ip, sshKeyPath, projectName, backupConfig
918
929
  'rm -f "$TMP_CRON"',
919
930
  ].join('\n');
920
931
 
921
- runScript(ip, sshKeyPath, installScript, { timeout: 60_000 });
932
+ await runScript(ip, sshKeyPath, installScript, { timeout: 60_000 });
922
933
  }
923
934
 
924
935
  // ISO-8601 datetime: YYYY-MM-DDTHH:MM:SS followed by Z or ±HH:MM
@@ -1041,7 +1052,7 @@ export async function restoreCompose(ip, sshKeyPath, projectName, target = 'late
1041
1052
  // quotes (restore_command = 'wal-g wal-fetch ...') so it MUST go via a
1042
1053
  // file rather than a nested bash -c '...' invocation. The script clears
1043
1054
  // PGDATA, runs wal-g backup-fetch, and writes the archive-recovery config.
1044
- sshRunScript(
1055
+ await sshRunScript(
1045
1056
  ip,
1046
1057
  sshKeyPath,
1047
1058
  [
@@ -22,6 +22,7 @@ import { execFileSync } from 'node:child_process';
22
22
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import * as p from '@clack/prompts';
25
+ import { featureConfigKeys, featureSecretKeys } from '../../config-registry.js';
25
26
  import {
26
27
  seedEnvironmentSecrets,
27
28
  seedOrgSecrets,
@@ -240,20 +241,7 @@ export async function deployK8sGitOps(args) {
240
241
  await seedOrgSecrets(creds);
241
242
 
242
243
  const envLocal = parseEnvLocal(projectDir);
243
- const perEnvSecrets = {
244
- DB_PASSWORD: envLocal.DB_PASSWORD,
245
- JWT_SECRET: envLocal.JWT_SECRET,
246
- SUPABASE_ANON_KEY: envLocal.SUPABASE_ANON_KEY || envLocal.ANON_KEY,
247
- SUPABASE_SERVICE_ROLE_KEY: envLocal.SUPABASE_SERVICE_ROLE_KEY || envLocal.SERVICE_ROLE_KEY,
248
- REALTIME_SECRET: envLocal.REALTIME_SECRET,
249
- DB_ENC_KEY: envLocal.DB_ENC_KEY,
250
- VAULT_ENC_KEY: envLocal.VAULT_ENC_KEY,
251
- PG_META_CRYPTO_KEY: envLocal.PG_META_CRYPTO_KEY,
252
- LOGFLARE_API_KEY: envLocal.LOGFLARE_API_KEY,
253
- ADMIN_EMAIL: envLocal.ADMIN_EMAIL,
254
- ADMIN_PASSWORD: envLocal.ADMIN_PASSWORD,
255
- SMTP_PASSWORD: envLocal.SMTP_PASS,
256
- };
244
+ const perEnvSecrets = buildPerEnvSecrets(envLocal);
257
245
  const perEnvVars = {
258
246
  SITE_URL: domain ? `https://${domain}` : '',
259
247
  DNS_PROVIDER: dnsProvider || '',
@@ -284,6 +272,42 @@ export async function deployK8sGitOps(args) {
284
272
  * to avoid a cross-module import dependency that forces bringing the
285
273
  * full k8s deploy module into GitOps flow.
286
274
  */
275
+ /**
276
+ * Build the per-environment GitHub Environment secret map from a parsed
277
+ * .env.local. Generated infra secrets are enumerated here (they carry the
278
+ * Supabase ANON_KEY/SERVICE_ROLE_KEY alias logic); every configure-managed
279
+ * feature key (billing/OAuth/SMTP — secret and non-secret) is folded in from
280
+ * the config-registry so a new feature secret propagates with no edit here.
281
+ *
282
+ * Values that are undefined/empty are dropped by setSecretFromBodyFile, so
283
+ * unconfigured features upload nothing. Canonical SMTP key is SMTP_PASS (no
284
+ * SMTP_PASSWORD rename — the app and manifests read SMTP_PASS).
285
+ *
286
+ * @param {Record<string,string>} envLocal
287
+ * @returns {Record<string,string|undefined>}
288
+ */
289
+ export function buildPerEnvSecrets(envLocal) {
290
+ const infra = {
291
+ DB_PASSWORD: envLocal.DB_PASSWORD,
292
+ JWT_SECRET: envLocal.JWT_SECRET,
293
+ SUPABASE_ANON_KEY: envLocal.SUPABASE_ANON_KEY || envLocal.ANON_KEY,
294
+ SUPABASE_SERVICE_ROLE_KEY: envLocal.SUPABASE_SERVICE_ROLE_KEY || envLocal.SERVICE_ROLE_KEY,
295
+ REALTIME_SECRET: envLocal.REALTIME_SECRET,
296
+ DB_ENC_KEY: envLocal.DB_ENC_KEY,
297
+ VAULT_ENC_KEY: envLocal.VAULT_ENC_KEY,
298
+ PG_META_CRYPTO_KEY: envLocal.PG_META_CRYPTO_KEY,
299
+ LOGFLARE_API_KEY: envLocal.LOGFLARE_API_KEY,
300
+ ADMIN_EMAIL: envLocal.ADMIN_EMAIL,
301
+ ADMIN_PASSWORD: envLocal.ADMIN_PASSWORD,
302
+ };
303
+ /** @type {Record<string,string|undefined>} */
304
+ const features = {};
305
+ for (const key of [...featureSecretKeys(), ...featureConfigKeys()]) {
306
+ features[key] = envLocal[key];
307
+ }
308
+ return { ...infra, ...features };
309
+ }
310
+
287
311
  function parseEnvLocal(projectDir) {
288
312
  const path = join(projectDir, '.env.local');
289
313
  if (!existsSync(path)) return {};
@@ -38,6 +38,7 @@ import { join } from 'node:path';
38
38
  import { setTimeout as delay } from 'node:timers/promises';
39
39
  import * as p from '@clack/prompts';
40
40
  import { runCommandAsync } from '../../command.js';
41
+ import { featureConfigKeys, featureSecretKeys } from '../../config-registry.js';
41
42
  import { knownHostsPath } from '../../host-keys.js';
42
43
  import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
43
44
  import { dbImageRef } from '../../images.js';
@@ -1179,16 +1180,15 @@ export async function pushImageToLocalRegistry({
1179
1180
  }
1180
1181
 
1181
1182
  /**
1182
- * Subset of .env.local keys that must land in the in-cluster
1183
- * vibecarbon-secrets Secret. The Supabase Helm chart reads JWT_SECRET +
1184
- * ANON_KEY + SERVICE_ROLE_KEY via secretRef from this Secret; the app
1185
- * Deployment reads DB_PASSWORD + the rest via envFrom.
1186
- *
1187
- * Defined as an explicit allowlist (not "everything in .env.local")
1188
- * because .env.local also carries client-side VITE_* values that don't
1189
- * belong in a Secret.
1183
+ * Generated infra secrets that must land in the in-cluster vibecarbon-secrets
1184
+ * Secret. The Supabase Helm chart reads JWT_SECRET + ANON_KEY +
1185
+ * SERVICE_ROLE_KEY via secretRef from this Secret; the app Deployment reads
1186
+ * DB_PASSWORD + the rest via envFrom.
1187
+ *
1188
+ * These are generated at deploy time (not user-configured), so they stay
1189
+ * local here rather than in the config-registry.
1190
1190
  */
1191
- const SECRET_KEYS = [
1191
+ const INFRA_SECRET_KEYS = [
1192
1192
  'DB_PASSWORD',
1193
1193
  'JWT_SECRET',
1194
1194
  'ANON_KEY',
@@ -1204,6 +1204,16 @@ const SECRET_KEYS = [
1204
1204
  'ADMIN_PASSWORD_HASH',
1205
1205
  ];
1206
1206
 
1207
+ /**
1208
+ * Full allowlist of .env.local keys that land in vibecarbon-secrets: generated
1209
+ * infra secrets plus every configure-managed runtime key (billing/OAuth/SMTP,
1210
+ * both secret and non-secret) from the config-registry. The app Deployment
1211
+ * pulls the whole Secret via `envFrom`, so feature config reaches the pod
1212
+ * without a second per-key list here. Client-side VITE_* keys are deliberately
1213
+ * excluded — they're baked into the image at build time, not runtime env.
1214
+ */
1215
+ export const SECRET_KEYS = [...INFRA_SECRET_KEYS, ...featureSecretKeys(), ...featureConfigKeys()];
1216
+
1207
1217
  /**
1208
1218
  * Apply the in-cluster `vibecarbon-secrets` Secret from the project's
1209
1219
  * `.env.local`. Idempotent: rendered as a Secret manifest and piped to
@@ -425,8 +425,17 @@ export async function executeDeployment(args, gatheredConfig) {
425
425
  } else {
426
426
  // CI path: ensureCIImageReady performs the workflow dispatch + GHA-build
427
427
  // wait. On a cold push deploy this is the dominant cost (~10 min).
428
+ // Pass the VITE_* build args so ensureCIImageReady can seed them as repo
429
+ // variables — the CI image bakes them at build time (Vite inlines them).
430
+ // Without this the GHCR image ships empty VITE_SUPABASE_* and the SPA
431
+ // crashes at page load. Mirrors the compose-local build-args path above.
432
+ const { collectComposeBuildArgs } = await import('./compose/build-args.js');
433
+ const ciBuildArgs = collectComposeBuildArgs(process.cwd(), {
434
+ projectName: projectConfig.projectName,
435
+ domain,
436
+ });
428
437
  imageReadyPromise = perfAsync('deploy.image.ciWait', () =>
429
- ensureCIImageReady({ yes: args.yes, tracker }),
438
+ ensureCIImageReady({ yes: args.yes, tracker, buildArgs: ciBuildArgs }),
430
439
  );
431
440
  }
432
441
 
@@ -634,7 +643,15 @@ export async function executeDeployment(args, gatheredConfig) {
634
643
  // Await the promise (cheap if already settled) then sideload the
635
644
  // image tarball over SSH. Sideload uses docker save | gzip | ssh
636
645
  // | docker load — gzip drops ~600MB → ~150-200MB on the wire.
646
+ //
647
+ // Both run silently (build is silent:true; sideload streams no stdout),
648
+ // so without a spinner this is a 15-30s dead pause right after "Proceed"
649
+ // on warm redeploys (where the upStack spinner that normally overlaps
650
+ // the build was skipped). Drive a spinner so the operator sees progress.
651
+ const imageSpinner = p.spinner();
652
+ imageSpinner.start('Building app image');
637
653
  await composeLocalBuildPromise;
654
+ imageSpinner.message('Sideloading app image to the server');
638
655
  await perfAsync('deploy.image.sideload', async () =>
639
656
  sideloadCompose({
640
657
  tag: localImageTag,
@@ -642,6 +659,7 @@ export async function executeDeployment(args, gatheredConfig) {
642
659
  sshKey: sshKeyPath,
643
660
  }),
644
661
  );
662
+ imageSpinner.stop('App image built and sideloaded');
645
663
  } else if (isDirectDeploy) {
646
664
  // Direct path: build the image on the server via DOCKER_HOST=ssh://.
647
665
  // BuildKit caches aggressively, so unchanged source rebuilds in 1-3s.
@@ -764,7 +782,10 @@ export async function executeDeployment(args, gatheredConfig) {
764
782
  : await import('../hetzner-dns.js');
765
783
  const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
766
784
  const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
785
+ const dnsSpinner = p.spinner();
786
+ dnsSpinner.start(`Pointing ${domain} → ${serverIp}...`);
767
787
  await perfAsync('deploy.dns.create', () => updateDnsIp(token, zoneId, domain, serverIp));
788
+ dnsSpinner.stop(`DNS A record set: ${domain} → ${serverIp}`);
768
789
  state.completeStep('compose-dns-update', { serverIp });
769
790
  }
770
791
  // HTTP-01 only: block compose-up until public resolvers see the real
@@ -273,8 +273,27 @@ export async function gatherDeploymentConfig(args) {
273
273
 
274
274
  let secondaryRegion = null;
275
275
  if (ha) {
276
- secondaryRegion =
277
- args.secondaryRegion || envConfig.secondaryRegion || (region === 'nbg1' ? 'fsn1' : 'nbg1');
276
+ const standbyDefault = HetznerProvider.getDefaultStandbyRegion(region);
277
+ secondaryRegion = args.secondaryRegion || envConfig.secondaryRegion || standbyDefault;
278
+ // HA spans TWO regions, but the prompt above only picks the primary. Ask
279
+ // for the standby too (same-continent partner pre-selected, so the common
280
+ // case is one Enter) — otherwise a non-EU primary like `ash` silently fails
281
+ // over across the Atlantic to `nbg1`. Skipped when set via -standby-region,
282
+ // saved config, or -y.
283
+ if (!args.secondaryRegion && !envConfig.secondaryRegion && !args.yes) {
284
+ const standbyOptions = Object.entries(providerConfig.regions)
285
+ .filter(([id]) => id !== region)
286
+ .map(([id, name]) => ({ value: id, label: `${id} - ${name}` }));
287
+ secondaryRegion = await p.select({
288
+ message: 'Standby region (HA failover target):',
289
+ options: standbyOptions,
290
+ initialValue: standbyDefault,
291
+ });
292
+ if (p.isCancel(secondaryRegion)) {
293
+ p.cancel('Operation cancelled.');
294
+ process.exit(0);
295
+ }
296
+ }
278
297
  }
279
298
 
280
299
  const apiToken = await getApiToken(projectConfig.projectName);
@@ -15,7 +15,7 @@ import {
15
15
  } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { dirname, join } from 'node:path';
18
- import { runCommand } from '../command.js';
18
+ import { runCommand, runCommandAsync } from '../command.js';
19
19
  import { getProviderClass } from '../providers/index.js';
20
20
  import { escapeDotenv } from '../shell.js';
21
21
 
@@ -71,15 +71,18 @@ export function getBranchName(envName) {
71
71
  * @param {string} remoteDir - absolute path to the remote deployment directory
72
72
  * @param {Record<string, string>} updates - key/value pairs to set in .env
73
73
  */
74
- export function mergeRemoteDotenv(host, sshOpts, remoteDir, updates) {
74
+ export async function mergeRemoteDotenv(host, sshOpts, remoteDir, updates) {
75
75
  const dir = mkdtempSync(join(tmpdir(), 'vibecarbon-envmerge-'));
76
76
  const local = join(dir, '.env');
77
77
  try {
78
78
  // Pull remote .env
79
79
  try {
80
- runCommand(['scp', ...sshOpts.split(' '), `root@${host}:${remoteDir}/.env`, local], {
81
- silent: true,
82
- });
80
+ await runCommandAsync(
81
+ ['scp', ...sshOpts.split(' '), `root@${host}:${remoteDir}/.env`, local],
82
+ {
83
+ silent: true,
84
+ },
85
+ );
83
86
  } catch (e) {
84
87
  throw new Error(
85
88
  `Failed to pull ${remoteDir}/.env from ${host} — is the remote file present and SSH reachable? Underlying error: ${e?.message ?? e}`,
@@ -101,9 +104,12 @@ export function mergeRemoteDotenv(host, sshOpts, remoteDir, updates) {
101
104
  writeFileSync(local, merged.join('\n'));
102
105
  // Push merged .env back
103
106
  try {
104
- runCommand(['scp', ...sshOpts.split(' '), local, `root@${host}:${remoteDir}/.env`], {
105
- silent: true,
106
- });
107
+ await runCommandAsync(
108
+ ['scp', ...sshOpts.split(' '), local, `root@${host}:${remoteDir}/.env`],
109
+ {
110
+ silent: true,
111
+ },
112
+ );
107
113
  } catch (e) {
108
114
  throw new Error(
109
115
  `Failed to push merged .env back to ${host}:${remoteDir}/.env — remote still has the previous contents. Underlying error: ${e?.message ?? e}`,
@@ -170,10 +176,13 @@ export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
170
176
  const sshOpts = `-i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes`;
171
177
  for (let i = 0; i < maxAttempts; i++) {
172
178
  try {
173
- runCommand(
179
+ // silent:true → runCommandAsync rejects on failure so the retry loop
180
+ // actually engages (the old sync runCommand returned false without
181
+ // throwing, so this returned true on the first probe regardless).
182
+ await runCommandAsync(
174
183
  ['ssh', ...sshOpts.split(' '), '-o', 'ConnectTimeout=5', `root@${host}`, 'echo', 'ok'],
175
184
  {
176
- stdio: 'pipe',
185
+ silent: true,
177
186
  timeout: 10000,
178
187
  },
179
188
  );
@@ -198,7 +207,7 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
198
207
  const sshOpts = `-i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes`;
199
208
 
200
209
  // Create remote directory and required subdirectories
201
- runCommand(
210
+ await runCommandAsync(
202
211
  [
203
212
  'ssh',
204
213
  ...sshOpts.split(' '),
@@ -209,7 +218,7 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
209
218
  `${remoteDir}/volumes/kong`,
210
219
  `${remoteDir}/supabase/migrations`,
211
220
  ],
212
- { stdio: 'pipe' },
221
+ { silent: true, ignoreError: true },
213
222
  );
214
223
 
215
224
  // List of files to copy
@@ -235,9 +244,13 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
235
244
  for (const file of filesToCopy) {
236
245
  const localPath = join(cwd, file);
237
246
  if (existsSync(localPath)) {
238
- runCommand(['scp', ...sshOpts.split(' '), localPath, `root@${host}:${remoteDir}/`], {
239
- stdio: 'pipe',
240
- });
247
+ await runCommandAsync(
248
+ ['scp', ...sshOpts.split(' '), localPath, `root@${host}:${remoteDir}/`],
249
+ {
250
+ silent: true,
251
+ ignoreError: true,
252
+ },
253
+ );
241
254
  }
242
255
  }
243
256
 
@@ -247,10 +260,11 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
247
260
  for (const file of readdirSync(volumesDbDir)) {
248
261
  const filePath = join(volumesDbDir, file);
249
262
  if (existsSync(filePath)) {
250
- runCommand(
263
+ await runCommandAsync(
251
264
  ['scp', ...sshOpts.split(' '), filePath, `root@${host}:${remoteDir}/volumes/db/`],
252
265
  {
253
- stdio: 'pipe',
266
+ silent: true,
267
+ ignoreError: true,
254
268
  },
255
269
  );
256
270
  }
@@ -260,7 +274,7 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
260
274
  // Copy supabase migrations directory
261
275
  const migrationsDir = join(cwd, 'supabase', 'migrations');
262
276
  if (existsSync(migrationsDir)) {
263
- runCommand(
277
+ await runCommandAsync(
264
278
  [
265
279
  'scp',
266
280
  ...sshOpts.split(' '),
@@ -269,7 +283,7 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
269
283
  `root@${host}:${remoteDir}/supabase/migrations/`,
270
284
  ],
271
285
  {
272
- stdio: 'pipe',
286
+ silent: true,
273
287
  ignoreError: true, // handle the 2>/dev/null || true semantics
274
288
  },
275
289
  );
@@ -281,10 +295,11 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
281
295
  for (const file of readdirSync(volumesKongDir)) {
282
296
  const filePath = join(volumesKongDir, file);
283
297
  if (existsSync(filePath)) {
284
- runCommand(
298
+ await runCommandAsync(
285
299
  ['scp', ...sshOpts.split(' '), filePath, `root@${host}:${remoteDir}/volumes/kong/`],
286
300
  {
287
- stdio: 'pipe',
301
+ silent: true,
302
+ ignoreError: true,
288
303
  },
289
304
  );
290
305
  }
@@ -294,9 +309,13 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
294
309
  // Copy local .env file if it exists
295
310
  const localEnvPath = join(cwd, '.env');
296
311
  if (existsSync(localEnvPath)) {
297
- runCommand(['scp', ...sshOpts.split(' '), localEnvPath, `root@${host}:${remoteDir}/`], {
298
- stdio: 'pipe',
299
- });
312
+ await runCommandAsync(
313
+ ['scp', ...sshOpts.split(' '), localEnvPath, `root@${host}:${remoteDir}/`],
314
+ {
315
+ silent: true,
316
+ ignoreError: true,
317
+ },
318
+ );
300
319
  }
301
320
 
302
321
  // Override/append production env vars
@@ -312,17 +331,17 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
312
331
  }
313
332
 
314
333
  if (Object.keys(envOverrides).length > 0) {
315
- mergeRemoteDotenv(host, sshOpts, remoteDir, envOverrides);
334
+ await mergeRemoteDotenv(host, sshOpts, remoteDir, envOverrides);
316
335
  }
317
336
 
318
337
  // Copy prometheus config
319
338
  const prometheusDir = join(cwd, 'prometheus');
320
339
  if (options.observability && existsSync(prometheusDir)) {
321
- runCommand(
340
+ await runCommandAsync(
322
341
  ['ssh', ...sshOpts.split(' '), `root@${host}`, 'mkdir', '-p', `${remoteDir}/prometheus`],
323
- { stdio: 'pipe' },
342
+ { silent: true, ignoreError: true },
324
343
  );
325
- runCommand(
344
+ await runCommandAsync(
326
345
  [
327
346
  'scp',
328
347
  ...sshOpts.split(' '),
@@ -331,7 +350,8 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
331
350
  `root@${host}:${remoteDir}/prometheus/`,
332
351
  ],
333
352
  {
334
- stdio: 'pipe',
353
+ silent: true,
354
+ ignoreError: true,
335
355
  },
336
356
  );
337
357
  }
@@ -339,9 +359,13 @@ export async function setupServerFiles(host, sshKeyPath, projectName, options =
339
359
  // Copy grafana provisioning
340
360
  const grafanaDir = join(cwd, 'grafana');
341
361
  if (options.observability && existsSync(grafanaDir)) {
342
- runCommand(['scp', ...sshOpts.split(' '), '-r', grafanaDir, `root@${host}:${remoteDir}/`], {
343
- stdio: 'pipe',
344
- });
362
+ await runCommandAsync(
363
+ ['scp', ...sshOpts.split(' '), '-r', grafanaDir, `root@${host}:${remoteDir}/`],
364
+ {
365
+ silent: true,
366
+ ignoreError: true,
367
+ },
368
+ );
345
369
  }
346
370
 
347
371
  return true;
@@ -105,6 +105,35 @@ export async function seedOrgSecrets(creds) {
105
105
  return applied;
106
106
  }
107
107
 
108
+ /**
109
+ * Seed repo-level GitHub Actions variables that the build workflow
110
+ * (vibecarbon-build.yml) reads as `--build-arg` inputs. Vite inlines these
111
+ * VITE_* values into the browser bundle at image-build time; without them the
112
+ * CI-built GHCR image ships empty VITE_SUPABASE_* and crashes at page load
113
+ * ("Missing Supabase environment variables").
114
+ *
115
+ * These are non-secret build inputs (VITE_SUPABASE_ANON_KEY is a publishable
116
+ * client key shipped to browsers), so they go to repo VARIABLES, not secrets.
117
+ * Repo-level (not per-environment) because vibecarbon-build.yml builds one
118
+ * push-triggered image per repo — it reflects the most recent push-mode
119
+ * deploy's domain/config. Multi-domain setups should use local-build deploys
120
+ * for non-canonical environments.
121
+ *
122
+ * Empty values are skipped (setVariable returns false) so optional VITE_*
123
+ * (e.g. unconfigured analytics) don't create blank variables.
124
+ *
125
+ * @param {Record<string,string>} viteArgs - VITE_* build args (from collectComposeBuildArgs).
126
+ * @returns {Promise<string[]>} applied variable names.
127
+ */
128
+ export async function seedBuildVars(viteArgs) {
129
+ const applied = [];
130
+ for (const [key, value] of Object.entries(viteArgs)) {
131
+ if (!key.startsWith('VITE_')) continue;
132
+ if (await setVariable(key, value)) applied.push(key);
133
+ }
134
+ return applied;
135
+ }
136
+
108
137
  /**
109
138
  * Seed the per-environment secrets + variables. `envVars` are sourced from
110
139
  * .env.local (read by the caller) so we can re-use the same values as the
@@ -189,11 +189,16 @@ export function requireLicense(commandName) {
189
189
  ` ${c.bold('Vibecarbon Fullerene')} — ${c.success('$499 lifetime')} ${c.dim('— deploy for clients & agencies, commercial use rights')}`,
190
190
  );
191
191
  console.log('');
192
- console.log(` ${c.dim('Purchase:')} ${c.info('https://vibecarbon.dev/pricing')}`);
192
+ console.log(` ${c.dim('Purchase:')} ${c.info('https://vibecarbon.com/#pricing')}`);
193
193
  console.log(` ${c.dim('Activate:')} ${c.info('vibecarbon activate <key>')}`);
194
- console.log(` ${c.dim('Terms:')} ${c.dim('TERMS.md or https://vibecarbon.dev/terms')}`);
194
+ console.log(` ${c.dim('Terms:')} ${c.dim('TERMS.md or https://vibecarbon.com/terms')}`);
195
195
  console.log('');
196
- process.exit(0);
196
+ // Exit non-zero: a gated command run without a license is a failed
197
+ // invocation, not a success. Exiting 0 made scripted/CI `deploy`, `scale`,
198
+ // `backup`, etc. look like they succeeded while doing nothing (the test
199
+ // harnesses had to activate a license to dodge this). Callers and CI now
200
+ // see a real failure.
201
+ process.exit(1);
197
202
  }
198
203
 
199
204
  // Re-export tier utilities
@@ -19,7 +19,6 @@ export const TIERS = {
19
19
  price: 0,
20
20
  originalPrice: 0,
21
21
  discountPercent: 0,
22
- hostingCost: '$0',
23
22
  // Marketing
24
23
  marketingFeatures: [
25
24
  'Create projects',
@@ -51,7 +50,6 @@ export const TIERS = {
51
50
  price: 149,
52
51
  originalPrice: 299,
53
52
  discountPercent: 50,
54
- hostingCost: '~$5-100/mo',
55
53
  badge: 'Lifetime Access',
56
54
  // Marketing
57
55
  marketingFeatures: [
@@ -89,7 +87,6 @@ export const TIERS = {
89
87
  price: 499,
90
88
  originalPrice: 999,
91
89
  discountPercent: 50,
92
- hostingCost: '~$5-100/mo',
93
90
  badge: 'Lifetime Access',
94
91
  // Marketing
95
92
  marketingFeatures: [
@@ -16,12 +16,12 @@ import { getPostgresPod, sshKubectl } from './ssh.js';
16
16
  * @returns {Array<{ size: string, date: string, name: string }>} parsed entries
17
17
  * (empty array if the pod is unreachable or holds no backups)
18
18
  */
19
- export function listPodBackups(ip, sshKeyPath, { sort = 'recent' } = {}) {
20
- const pod = getPostgresPod(ip, sshKeyPath);
19
+ export async function listPodBackups(ip, sshKeyPath, { sort = 'recent' } = {}) {
20
+ const pod = await getPostgresPod(ip, sshKeyPath);
21
21
  const lsFlags = sort === 'largest' ? '-lhS' : '-lt';
22
22
 
23
23
  try {
24
- const output = sshKubectl(ip, sshKeyPath, [
24
+ const output = await sshKubectl(ip, sshKeyPath, [
25
25
  'exec',
26
26
  '-n',
27
27
  'vibecarbon',
@@ -271,6 +271,6 @@ export class BaseProvider {
271
271
  formatServerType(serverType) {
272
272
  const type = this.constructor.SERVER_TYPES[serverType];
273
273
  if (!type) return serverType;
274
- return `${serverType} (${type.vcpu} vCPU, ${type.ram}GB RAM, ${type.disk}GB SSD, ${type.price})`;
274
+ return `${serverType} (${type.vcpu} vCPU, ${type.ram}GB RAM, ${type.disk}GB SSD)`;
275
275
  }
276
276
  }