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.
- package/README.md +1 -1
- package/carbon/.env.example +3 -0
- package/carbon/.github/workflows/deploy.yml +24 -21
- package/carbon/.github/workflows/vibecarbon-build.yml +14 -0
- package/carbon/Dockerfile +3 -0
- package/carbon/biome.json +1 -1
- package/carbon/cloud-init/k3s/master-init.sh +26 -2
- package/carbon/cloud-init/k3s/supabase-init.sh +22 -2
- package/carbon/cloud-init/k3s/worker-init.sh +22 -2
- package/carbon/content/docs/cli.mdx +1 -1
- package/carbon/docker-compose.yml +6 -0
- package/carbon/k8s/base/app/deployment.yaml +5 -0
- package/carbon/k8s/values/supabase.values.yaml +30 -0
- package/carbon/package.json +34 -36
- package/carbon/scripts/generate-sitemap.ts +18 -3
- package/carbon/src/client/App.tsx +2 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +4 -3
- package/carbon/src/client/components/PricingSection.tsx +44 -1
- package/carbon/src/client/components/SEO.tsx +6 -3
- package/carbon/src/client/components/ui/calendar.tsx +1 -1
- package/carbon/src/client/index.css +9 -1
- package/carbon/src/client/index.html +3 -3
- package/carbon/src/client/locales/en.json +1 -1
- package/carbon/src/client/pages/Pricing.tsx +169 -0
- package/carbon/src/client/pages/settings/Billing.tsx +2 -6
- package/carbon/src/server/index.ts +23 -4
- package/carbon/src/shared/billing-catalog.ts +25 -0
- package/carbon/src/shared/billing-catalog.types.ts +35 -0
- package/carbon/src/shared/pricing.ts +56 -1
- package/package.json +13 -13
- package/src/activate.js +1 -1
- package/src/backup.js +33 -87
- package/src/configure.js +365 -137
- package/src/create.js +5 -2
- package/src/deploy.js +24 -7
- package/src/destroy.js +11 -4
- package/src/failover.js +64 -47
- package/src/lib/backup-format.js +84 -0
- package/src/lib/billing/stripe-catalog.js +152 -0
- package/src/lib/billing/write-catalog.js +123 -0
- package/src/lib/ci-setup.js +11 -1
- package/src/lib/config-registry.js +116 -0
- package/src/lib/deploy/compose/build-args.js +6 -0
- package/src/lib/deploy/compose/ha.js +25 -25
- package/src/lib/deploy/compose/index.js +29 -18
- package/src/lib/deploy/k8s/gitops-deploy.js +38 -14
- package/src/lib/deploy/k8s/k3s.js +20 -10
- package/src/lib/deploy/orchestrator.js +22 -1
- package/src/lib/deploy/prompts.js +21 -2
- package/src/lib/deploy/utils.js +56 -32
- package/src/lib/github-environments.js +29 -0
- package/src/lib/licensing/index.js +8 -3
- package/src/lib/licensing/tiers.js +0 -3
- package/src/lib/pod-backups.js +3 -3
- package/src/lib/providers/base.js +1 -1
- package/src/lib/providers/hetzner.js +74 -82
- package/src/lib/server-types.js +3 -31
- package/src/lib/ssh.js +19 -18
- package/src/lib/walg-backups.js +81 -0
- package/src/restore.js +122 -202
- package/src/scale.js +25 -29
- package/src/status.js +0 -69
- 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 {
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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), {
|
|
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';
|
|
@@ -124,7 +125,7 @@ export async function runKubectlWithRetry(
|
|
|
124
125
|
);
|
|
125
126
|
await delay(backoffMs);
|
|
126
127
|
}
|
|
127
|
-
if (
|
|
128
|
+
if (result?.status !== 0) {
|
|
128
129
|
throw new Error(
|
|
129
130
|
`${desc} failed with exit ${result?.status ?? '?'}: ${lastStderr.trim().slice(-500)}`,
|
|
130
131
|
);
|
|
@@ -1179,16 +1180,15 @@ export async function pushImageToLocalRegistry({
|
|
|
1179
1180
|
}
|
|
1180
1181
|
|
|
1181
1182
|
/**
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
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);
|
package/src/lib/deploy/utils.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
263
|
+
await runCommandAsync(
|
|
251
264
|
['scp', ...sshOpts.split(' '), filePath, `root@${host}:${remoteDir}/volumes/db/`],
|
|
252
265
|
{
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
+
await runCommandAsync(
|
|
285
299
|
['scp', ...sshOpts.split(' '), filePath, `root@${host}:${remoteDir}/volumes/kong/`],
|
|
286
300
|
{
|
|
287
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
340
|
+
await runCommandAsync(
|
|
322
341
|
['ssh', ...sshOpts.split(' '), `root@${host}`, 'mkdir', '-p', `${remoteDir}/prometheus`],
|
|
323
|
-
{
|
|
342
|
+
{ silent: true, ignoreError: true },
|
|
324
343
|
);
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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.
|
|
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.
|
|
194
|
+
console.log(` ${c.dim('Terms:')} ${c.dim('TERMS.md or https://vibecarbon.com/terms')}`);
|
|
195
195
|
console.log('');
|
|
196
|
-
|
|
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: [
|
package/src/lib/pod-backups.js
CHANGED
|
@@ -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
|
|
274
|
+
return `${serverType} (${type.vcpu} vCPU, ${type.ram}GB RAM, ${type.disk}GB SSD)`;
|
|
275
275
|
}
|
|
276
276
|
}
|