vibecarbon 0.2.0 → 0.2.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.
@@ -144,7 +144,12 @@ services:
144
144
  # PITR gap, but the DB stays up — strictly better than the previous
145
145
  # behavior for a SaaS template. Grep `docker logs db` for
146
146
  # WAL_ARCHIVE_FAILED to detect silent backup regressions.
147
- - archive_command=/etc/postgresql/wal-archive.sh %p
147
+ # Invoke via `bash` rather than relying on the script's exec bit: the
148
+ # create-time scaffold copy (copyFileSync) drops +x, and a 0644 script
149
+ # made postgres fail archiving with exit 126 ("not executable"), which
150
+ # silently re-pins WAL. bash-invoking it (like reconcile.sh) is immune to
151
+ # the file mode, so the fault-tolerant wrapper always runs.
152
+ - archive_command=bash /etc/postgresql/wal-archive.sh %p
148
153
  - -c
149
154
  # 60s was too aggressive — on an idle Supabase DB, that forces a
150
155
  # 16 MiB segment switch every minute regardless of write volume (PG14+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
package/src/create.js CHANGED
@@ -780,6 +780,16 @@ async function bootstrap(cliArgs) {
780
780
  join(projectDir, 'volumes/db/set-passwords.sh'),
781
781
  variables,
782
782
  );
783
+ // wal-archive.sh is postgres's archive_command. It was missing from this copy
784
+ // list, so scaffolds shipped no source file — Docker then bind-mounted an
785
+ // empty DIRECTORY at /etc/postgresql/wal-archive.sh and postgres failed
786
+ // archiving with exit 126 ("is a directory"), re-pinning WAL. Copy it (the
787
+ // chmod block below makes it +x; the script has only shell $vars, no {{}}).
788
+ copyTemplate(
789
+ 'volumes/db/wal-archive.sh',
790
+ join(projectDir, 'volumes/db/wal-archive.sh'),
791
+ variables,
792
+ );
783
793
  copyTemplate('volumes/db/jwt.sql', join(projectDir, 'volumes/db/jwt.sql'), variables);
784
794
  copyTemplate('volumes/db/realtime.sql', join(projectDir, 'volumes/db/realtime.sql'), variables);
785
795
  copyTemplate(
@@ -854,6 +864,11 @@ async function bootstrap(cliArgs) {
854
864
  'docker-entrypoint.sh',
855
865
  'volumes/kong/docker-entrypoint.sh',
856
866
  'volumes/db/set-passwords.sh',
867
+ // Postgres execs this directly as its archive_command, so it MUST be +x.
868
+ // copyTemplate (copyFileSync) drops the source's exec bit, and omitting it
869
+ // here shipped a 0644 script → archive_command failed with exit 126 ("not
870
+ // executable") → WAL pinned → disk-fill risk the wrapper exists to prevent.
871
+ 'volumes/db/wal-archive.sh',
857
872
  ];
858
873
  for (const script of shellScripts) {
859
874
  const scriptPath = join(projectDir, script);
package/src/destroy.js CHANGED
@@ -693,7 +693,29 @@ async function handleBackupBucket(envConfig, projectConfig, args, spinner) {
693
693
  }
694
694
  const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, region);
695
695
  try {
696
- const result = await s3Provider.emptyAndDeleteBucket(bucketName);
696
+ // Bound the purge so a slow/contended S3 endpoint can't consume the whole
697
+ // destroy budget. The Hetzner resources are already freed (Pulumi destroy
698
+ // ran before this), but a single S3 op can hang up to ~9 min under the
699
+ // SDK (maxAttempts 3 × 60s requestTimeout) × _send (maxAttempts 3) retry
700
+ // layers — observed on k8s teardown, where the storage bucket doubles as
701
+ // the Pulumi state backend (slow/contended), blowing the 600s
702
+ // final-destroy timeout and getting the whole step SIGKILLed. On timeout
703
+ // we leave the bucket for the orphan sweep / next destroy rather than hang.
704
+ const PURGE_TIMEOUT_MS = 180_000;
705
+ const result = await Promise.race([
706
+ s3Provider.emptyAndDeleteBucket(bucketName),
707
+ new Promise((_, reject) =>
708
+ setTimeout(
709
+ () =>
710
+ reject(
711
+ new Error(
712
+ `backup bucket purge exceeded ${PURGE_TIMEOUT_MS / 1000}s — left for the orphan sweep / a retried destroy`,
713
+ ),
714
+ ),
715
+ PURGE_TIMEOUT_MS,
716
+ ),
717
+ ),
718
+ ]);
697
719
  spinner.stop(
698
720
  result.deleted
699
721
  ? result.objectsRemoved > 0
@@ -1043,15 +1043,18 @@ export async function deployComposeHA(options) {
1043
1043
  ]),
1044
1044
  );
1045
1045
 
1046
- // Run migrations on primary only
1046
+ // Run migrations on primary only. Let failures propagate — silently shipping
1047
+ // an empty/partial schema is far worse than a visibly-failed deploy the
1048
+ // operator can retry. The old swallowing try/catch ("non-fatal") masked
1049
+ // exactly that: HA could deploy a schema-less DB that 500s on every DB-backed
1050
+ // route with no error surfaced. runMigrations now waits for supabase_admin AND
1051
+ // the storage schema (storage.buckets.public) before applying with
1052
+ // ON_ERROR_STOP=1, so the first-run races that motivated the swallow are gone
1053
+ // and a failure here is a real one that must abort the deploy.
1047
1054
  onProgress('Running database migrations on primary...');
1048
- try {
1049
- await perfAsync('deploy.ha.compose.migrations', () =>
1050
- runMigrations(primaryIp, sshKeyPath, projectName),
1051
- );
1052
- } catch {
1053
- // Migrations may partially fail on first run, non-fatal
1054
- }
1055
+ await perfAsync('deploy.ha.compose.migrations', () =>
1056
+ runMigrations(primaryIp, sshKeyPath, projectName),
1057
+ );
1055
1058
 
1056
1059
  // Create admin user in production Supabase (same as single-server deploy)
1057
1060
  onProgress('Creating admin user...');
@@ -92,11 +92,11 @@ export function inspectGitState(cwd) {
92
92
  * pod (Phase 1 wired the registries.yaml; Phase 6 wires the push).
93
93
  *
94
94
  * @param {string} projectDir - Project root with the Dockerfile.
95
- * @param {{projectName: string, timestamp?: string, rebuild?: boolean, tagPrefix?: string}} options
95
+ * @param {{projectName: string, timestamp?: string, rebuild?: boolean, tagPrefix?: string, buildArgs?: Record<string,string>}} options
96
96
  * @returns {{tag: string, gitSha: string, isDirty: boolean}}
97
97
  */
98
98
  export function buildLocalImage(projectDir, options) {
99
- const { projectName, rebuild = false, tagPrefix } = options;
99
+ const { projectName, rebuild = false, tagPrefix, buildArgs = {} } = options;
100
100
  if (!projectName) {
101
101
  throw new Error('buildLocalImage: projectName is required');
102
102
  }
@@ -112,6 +112,14 @@ export function buildLocalImage(projectDir, options) {
112
112
 
113
113
  const args = ['build'];
114
114
  if (rebuild) args.push('--no-cache');
115
+ // Vite inlines import.meta.env.VITE_* at build time, so the browser bundle
116
+ // bakes whatever these ARGs resolve to. Without passing them the bundle ships
117
+ // empty VITE_SUPABASE_* and crashes at page load with "Missing Supabase
118
+ // environment variables" — k8s shipped exactly this because buildAppImage
119
+ // passed no build args (compose plumbs them via collectComposeBuildArgs).
120
+ for (const [k, v] of Object.entries(buildArgs)) {
121
+ if (v !== undefined && v !== null && v !== '') args.push('--build-arg', `${k}=${v}`);
122
+ }
115
123
  args.push('-t', tag, projectDir);
116
124
 
117
125
  execFileSync('docker', args, { stdio: 'inherit' });
@@ -556,9 +556,25 @@ export function loadEnvLocal(envPath) {
556
556
  * @param {boolean} [rebuild]
557
557
  * @returns {Promise<{tag: string, gitSha: string, isDirty: boolean}>}
558
558
  */
559
- export async function buildAppImage(projectDir, projectName, rebuild = false) {
559
+ export async function buildAppImage(projectDir, projectName, rebuild = false, domain = undefined) {
560
560
  const { buildLocalImage } = await import('../image.js');
561
- return buildLocalImage(projectDir, { projectName, rebuild, tagPrefix: '10.0.1.1:5000' });
561
+ // App image: collect VITE_* build args (incl. VITE_SUPABASE_URL rewritten to
562
+ // https://api.<domain>) so the browser bundle ships with real config instead
563
+ // of empty strings. buildLocalImage previously passed NO build args, so the
564
+ // k8s SPA crashed on load with "Missing Supabase environment variables" —
565
+ // compose plumbs these via collectComposeBuildArgs, k8s did not. The backup
566
+ // image build (no frontend) calls this without a domain → no VITE args.
567
+ let buildArgs = {};
568
+ if (domain) {
569
+ const { collectComposeBuildArgs } = await import('../compose/build-args.js');
570
+ buildArgs = collectComposeBuildArgs(projectDir, { projectName, domain });
571
+ }
572
+ return buildLocalImage(projectDir, {
573
+ projectName,
574
+ rebuild,
575
+ tagPrefix: '10.0.1.1:5000',
576
+ buildArgs,
577
+ });
562
578
  }
563
579
 
564
580
  /**
@@ -2436,11 +2452,14 @@ export async function deployK3s(options) {
2436
2452
  }
2437
2453
 
2438
2454
  // 6. Build the app image locally
2439
- const buildInputs = { projectName: options.projectName };
2455
+ // domain is part of the cache key + passed to the build: it's baked into the
2456
+ // browser bundle (VITE_SUPABASE_URL=https://api.<domain>), so a domain change
2457
+ // must force a rebuild rather than serving a stale-domain bundle.
2458
+ const buildInputs = { projectName: options.projectName, domain: options.domain };
2440
2459
  if (!state.shouldSkip('k3s-build', buildInputs)) {
2441
2460
  state.startStep('k3s-build', buildInputs);
2442
2461
  s.start('Building app image (10.0.1.1:5000/...)');
2443
- const built = await buildAppImage(projectDir, options.projectName);
2462
+ const built = await buildAppImage(projectDir, options.projectName, false, options.domain);
2444
2463
  state.completeStep('k3s-build', {
2445
2464
  tag: built.tag,
2446
2465
  gitSha: built.gitSha,
@@ -36,8 +36,13 @@ export async function buildRemote(ip, sshKeyPath, imageTag, cwd, buildArgs = {})
36
36
  // key path via env var rather than string-interpolating into the script
37
37
  // to keep shell-escaping concerns out of the picture (paths with spaces
38
38
  // or quotes would otherwise break the wrapper).
39
+ // ServerAliveInterval/CountMax: BuildKit holds a long-lived SSH session for
40
+ // the build; on a congested/cross-region link it can stall and the http2
41
+ // session helper drops with "error reading preface ... file already closed".
42
+ // Keepalives let ssh detect a dead peer (~60s) and fail the attempt cleanly
43
+ // so the retry below can re-establish, instead of hanging to the build timeout.
39
44
  const sshWrapper = `#!/bin/bash
40
- exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 "$@"
45
+ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$@"
41
46
  `;
42
47
  writeFileSync(sshWrapperPath, sshWrapper, { mode: 0o755 });
43
48
 
@@ -103,15 +108,31 @@ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKn
103
108
  // (compose-ha 2026-05-09T00:38 e2 failure). Treat the false return as a
104
109
  // hard failure so the error path captures + reports it.
105
110
  const buildT = perfTimer('deploy.image.remoteBuild');
106
- const buildOk = runCommand(args, {
107
- cwd,
108
- stdio: 'inherit',
109
- env,
110
- timeout: 600000,
111
- });
111
+ // BuildKit-over-SSH is flaky on first connect to a freshly-booted (often
112
+ // cross-region) VPS: the session helper intermittently drops mid-build with
113
+ // `http2: server: error reading preface ... file already closed` / an SSH
114
+ // reset, and runCommand returns false. Retry a few times — each attempt
115
+ // re-establishes a fresh SSH/BuildKit session, and BuildKit's cache makes
116
+ // retries cheap. Without this a single transient drop failed the whole
117
+ // deploy (compose-ha standby) or left no image so `docker compose up` later
118
+ // hit "pull access denied" (compose scale, e2e loop run #1).
119
+ const BUILD_ATTEMPTS = 3;
120
+ let buildOk = false;
121
+ for (let attempt = 1; attempt <= BUILD_ATTEMPTS; attempt++) {
122
+ buildOk = runCommand(args, { cwd, stdio: 'inherit', env, timeout: 600000 });
123
+ if (buildOk !== false) break;
124
+ if (attempt < BUILD_ATTEMPTS) {
125
+ console.error(
126
+ `[remote-build] docker build on ${ip} failed (attempt ${attempt}/${BUILD_ATTEMPTS}) — retrying in ${3 * attempt}s`,
127
+ );
128
+ await new Promise((r) => setTimeout(r, 3000 * attempt));
129
+ }
130
+ }
112
131
  buildT.end();
113
132
  if (buildOk === false) {
114
- throw new Error(`docker build failed on ${ip} for tag ${imageTag}`);
133
+ throw new Error(
134
+ `docker build failed on ${ip} for tag ${imageTag} after ${BUILD_ATTEMPTS} attempts`,
135
+ );
115
136
  }
116
137
 
117
138
  s.stop(`Image built natively: ${imageTag}`);
package/src/scale.js CHANGED
@@ -439,9 +439,22 @@ async function scaleCompose(environment, envConfig, projectConfig, options = {})
439
439
  // Reuses the same code that does direct-mode deploy builds.
440
440
  if (oldAppImage && isLocalOnlyImageTag(oldAppImage)) {
441
441
  s.start(`Building image ${oldAppImage} on new server...`);
442
- await perfAsync('scale.sideloadImage', () =>
442
+ const built = await perfAsync('scale.sideloadImage', () =>
443
443
  buildRemote(newIp, sshKeyPath, oldAppImage, process.cwd()),
444
444
  );
445
+ // buildRemote returns false on failure (after its own retries). The app
446
+ // image is local-only, so if it isn't built on the new server, the
447
+ // `docker compose up` below pulls it and dies ~a minute later with
448
+ // "pull access denied / repository does not exist" — a confusing
449
+ // downstream symptom. Fail loudly here instead; this guard was missing
450
+ // (the HA deploy path has the equivalent check). RCA: e2e loop run #1.
451
+ if (!built) {
452
+ s.stop('Remote image build failed on new server', 1);
453
+ throw new Error(
454
+ `scale: failed to build local app image ${oldAppImage} on new server ${newIp} ` +
455
+ `(buildRemote exhausted its retries) — aborting before compose up.`,
456
+ );
457
+ }
445
458
  s.stop('Image built on new server');
446
459
  }
447
460