vibecarbon 0.10.0 → 0.11.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.
@@ -19,6 +19,7 @@ import * as p from '@clack/prompts';
19
19
  import { runCommandAsync } from '../../command.js';
20
20
  import { knownHostsPathForKey } from '../../host-keys.js';
21
21
  import { perfAsync, perfTimer } from '../../perf.js';
22
+ import { runWithRetry } from '../../retry.js';
22
23
  import { parseDotenv, shEscape } from '../../shell.js';
23
24
  import { sshRunScript } from '../../ssh.js';
24
25
  import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
@@ -123,45 +124,50 @@ export async function sshRunAsync(ip, sshKeyPath, command, options = {}) {
123
124
  // the retry loop can catch them. We apply ignoreError ourselves after retries.
124
125
  const runOpts = { silent: true, timeout, ...rest };
125
126
 
126
- for (let attempt = 0; attempt < retries; attempt++) {
127
- try {
128
- return await runCommandAsync(sshArgs, runOpts);
129
- } catch (err) {
130
- const msg = err.message || '';
131
- const isConnectionError =
132
- msg.includes('Connection reset') ||
133
- msg.includes('Connection refused') ||
134
- msg.includes('Connection closed') ||
135
- msg.includes('kex_exchange_identification') ||
136
- msg.includes('ssh_exchange_identification') ||
137
- msg.includes('No route to host');
138
- // Timeouts are transient too: a freshly-provisioned Hetzner VPS can
139
- // have unattended-upgrades chewing CPU/disk in the background after
140
- // cloud-init's marker file lands — SSH responds, but each command
141
- // takes longer. Observed in iter-validate4 compose-ha restore where
142
- // dockerLogin (a tiny stdin-script SSH call with a 30s timeout)
143
- // hit the timeout twice in a row on the primary VPS without
144
- // retry. The retry budget here (3 attempts × 5s backoff) is small
145
- // enough that real hangs still surface fast, but absorbs the
146
- // background-work blip on a healthy server.
147
- const isTimeout =
148
- msg.includes('timed out') ||
149
- msg.includes('Command was killed with SIGTERM') ||
150
- msg.includes('ETIMEDOUT');
151
- const isTransient = isConnectionError || isTimeout;
152
-
153
- if (isTransient && attempt < retries - 1) {
127
+ // attempts = delaysMs.length + 1, so retries=3 (default) => 2 delays => 3
128
+ // total attempts, matching the old hand-rolled `for (attempt < retries)` loop.
129
+ const delaysMs = Array.from({ length: Math.max(0, retries - 1) }, () => 5000);
130
+ const ATTEMPTS = delaysMs.length + 1;
131
+
132
+ try {
133
+ return await runWithRetry(() => runCommandAsync(sshArgs, runOpts), {
134
+ delaysMs,
135
+ isTransient: (err) => {
136
+ const msg = err.message || '';
137
+ const isConnectionError =
138
+ msg.includes('Connection reset') ||
139
+ msg.includes('Connection refused') ||
140
+ msg.includes('Connection closed') ||
141
+ msg.includes('kex_exchange_identification') ||
142
+ msg.includes('ssh_exchange_identification') ||
143
+ msg.includes('No route to host');
144
+ // Timeouts are transient too: a freshly-provisioned Hetzner VPS can
145
+ // have unattended-upgrades chewing CPU/disk in the background after
146
+ // cloud-init's marker file lands SSH responds, but each command
147
+ // takes longer. Observed in iter-validate4 compose-ha restore where
148
+ // dockerLogin (a tiny stdin-script SSH call with a 30s timeout)
149
+ // hit the timeout twice in a row on the primary VPS without
150
+ // retry. The retry budget here (3 attempts × 5s backoff) is small
151
+ // enough that real hangs still surface fast, but absorbs the
152
+ // background-work blip on a healthy server.
153
+ const isTimeout =
154
+ msg.includes('timed out') ||
155
+ msg.includes('Command was killed with SIGTERM') ||
156
+ msg.includes('ETIMEDOUT');
157
+ return isConnectionError || isTimeout;
158
+ },
159
+ onRetry: (err, attempt) => {
160
+ const msg = err.message || '';
154
161
  console.error(
155
- `[retry] ssh ${ip}: attempt ${attempt + 1}/${retries} failed (${msg.slice(0, 80).split('\n')[0]}); retrying in 5s`,
162
+ `[retry] ssh ${ip}: attempt ${attempt}/${ATTEMPTS} failed (${msg.slice(0, 80).split('\n')[0]}); retrying in 5s`,
156
163
  );
157
- await new Promise((r) => setTimeout(r, 5000));
158
- continue;
159
- }
160
-
161
- // All retries exhausted — respect ignoreError if set
162
- if (ignoreError) return null;
163
- throw err;
164
- }
164
+ },
165
+ });
166
+ } catch (err) {
167
+ // All retries exhausted (or the error was non-transient) — respect
168
+ // ignoreError if set.
169
+ if (ignoreError) return null;
170
+ throw err;
165
171
  }
166
172
  }
167
173
 
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Single-server Compose deployment (tier: 'compose').
3
+ *
4
+ * Extracted verbatim from the orchestrator's STEP 5 inline compose-single
5
+ * branch. Provisions one Hetzner VPS (or reuses an existing one), transfers
6
+ * the image (local sideload / direct build / GHCR pull), writes DNS before
7
+ * compose-up (HTTP-01 ordering fix), reconciles the stack, runs migrations +
8
+ * admin-user creation, gates on an app-health probe, and installs the backup
9
+ * cron. Returns the persisted-config server descriptor.
10
+ */
11
+
12
+ import { readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import * as p from '@clack/prompts';
15
+ import { loadCredentials } from '../../config.js';
16
+ import { waitForDNSPropagation } from '../../dns-propagation.js';
17
+ import { knownHostsPathForKey, seedKnownHosts } from '../../host-keys.js';
18
+ import { perfAsync } from '../../perf.js';
19
+ import { sideloadCompose } from '../image.js';
20
+ import { buildRemote } from '../remote-build.js';
21
+ import { generateSSHKeyPair } from '../utils.js';
22
+
23
+ export async function deployComposeSingle(ctx) {
24
+ const {
25
+ projectConfig,
26
+ environment,
27
+ envConfig,
28
+ region,
29
+ serverType,
30
+ services,
31
+ domain,
32
+ dnsProvider,
33
+ apiToken,
34
+ cloudflareApiToken,
35
+ cloudflareZoneId,
36
+ hetznerDnsZoneId,
37
+ backupConfig,
38
+ imageRef,
39
+ bundlePath,
40
+ ciReady,
41
+ s3Config,
42
+ state,
43
+ dnsChallenge,
44
+ dnsWarmupPromise,
45
+ isDirectDeploy,
46
+ isComposeLocal,
47
+ localImageTag,
48
+ composeLocalBuildPromise,
49
+ } = ctx;
50
+
51
+ const {
52
+ setupServer,
53
+ dockerLoginOnServer,
54
+ startComposeStack,
55
+ runMigrations,
56
+ createAdminUser,
57
+ setupComposeBackupCron,
58
+ } = await import('./index.js');
59
+ const { setupServerFiles, waitForSSH: waitForComposeSSH } = await import('./index.js');
60
+ const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
61
+
62
+ let serverIp = envConfig.servers?.[0]?.ip || null;
63
+ // Track the Hetzner server's API id + literal name so destroy can find
64
+ // the VPS without falling back to the literal string "master" (which
65
+ // never matches the Pulumi-assigned name `${projectName}-${env}`).
66
+ // Without this, destroy → re-deploy hits 'server name is already used
67
+ // (uniqueness_error)' because the previous VPS was never deleted
68
+ // (caught in compose restore matrix runs).
69
+ let serverHetznerId = envConfig.servers?.[0]?.id || null;
70
+ let serverHetznerName = envConfig.servers?.[0]?.hetznerName || null;
71
+ if (!serverIp) {
72
+ generateSSHKeyPair(sshKeyPath);
73
+ const { upStack } = await import('../../iac/index.js');
74
+ const { buildHetznerComposeProgram } = await import('../../iac/programs/hetzner-compose.js');
75
+ const program = buildHetznerComposeProgram({
76
+ projectName: projectConfig.projectName,
77
+ environment,
78
+ sshPublicKey: readFileSync(`${sshKeyPath}.pub`, 'utf-8').trim(),
79
+ location: region,
80
+ serverType: serverType || 'cx23',
81
+ labels: { 'managed-by': 'vibecarbon' },
82
+ allowedSshIps: (projectConfig.operatorCidrs ?? []).map((e) => e.cidr),
83
+ });
84
+ // VM provisioning via Pulumi — typical 30-90s on Hetzner cx23.
85
+ const result = await perfAsync('deploy.iac.upStack', () =>
86
+ upStack(environment, program, { hcloudToken: apiToken, s3Config }),
87
+ );
88
+ serverIp = result.outputs.serverIp;
89
+ serverHetznerId = result.outputs.serverId || null;
90
+ serverHetznerName = `${projectConfig.projectName}-${environment}`;
91
+ await perfAsync('deploy.iac.waitForSSH', () => waitForComposeSSH(serverIp, sshKeyPath, 30));
92
+ // Trusted host-key seed on fresh provision — captures the new VPS's
93
+ // host key via ssh-keyscan into the per-env known_hosts so the pinned
94
+ // SSH opts (accept-new) become a strict pin for this deploy and every
95
+ // later command. Re-seeding here also cleanly re-pins a Hetzner-
96
+ // recycled IP (destroy → redeploy). Best-effort: falls back to
97
+ // accept-new TOFU if the scan can't reach the server yet. See
98
+ // host-keys.js seedKnownHosts for the full security rationale.
99
+ await seedKnownHosts(knownHostsPathForKey(sshKeyPath), serverIp);
100
+ }
101
+
102
+ // --- Warm path (StateTracker) ---
103
+ // On a second deploy against an already-provisioned server, skip the
104
+ // idempotent-but-slow steps (cloud-init probe, GHCR login, bundle rsync)
105
+ // when their inputs haven't changed. `docker compose up -d` is cheap
106
+ // and always runs — it applies diffs to the live stack.
107
+ const setupInputs = { serverIp };
108
+ if (!state.shouldSkip('compose-setup-server', setupInputs)) {
109
+ state.startStep('compose-setup-server', setupInputs);
110
+ // Cloud-init readiness probe — first-deploy spends 20-40s here while
111
+ // ufw + unattended-upgrades land. Warm deploys hit the ready marker
112
+ // immediately, so this is mostly cold-deploy noise.
113
+ await perfAsync('deploy.compose.cloudInitReady', async () => {
114
+ if (isDirectDeploy || isComposeLocal) {
115
+ // Both direct (build over DOCKER_HOST=ssh://) and local
116
+ // (sideload via docker save | ssh docker load) need dockerd
117
+ // up on the server before they can proceed. Push mode doesn't
118
+ // because the server pulls from GHCR after compose-up starts.
119
+ const { waitForDockerReady } = await import('./index.js');
120
+ await setupServer(serverIp, sshKeyPath);
121
+ await waitForDockerReady(serverIp, sshKeyPath);
122
+ } else {
123
+ await setupServer(serverIp, sshKeyPath);
124
+ }
125
+ });
126
+ state.completeStep('compose-setup-server', { serverIp });
127
+ }
128
+
129
+ // --- Image transfer to server (mode-dependent) ---
130
+ if (isComposeLocal) {
131
+ // Local-build path: the build was kicked off in parallel with
132
+ // iac.upStack at the top of this function. By the time we reach
133
+ // here (post-cloudInitReady), the build has almost always finished.
134
+ // Await the promise (cheap if already settled) then sideload the
135
+ // image tarball over SSH. Sideload uses docker save | gzip | ssh
136
+ // | docker load — gzip drops ~600MB → ~150-200MB on the wire.
137
+ //
138
+ // Both run silently (build is silent:true; sideload streams no stdout),
139
+ // so without a spinner this is a 15-30s dead pause right after "Proceed"
140
+ // on warm redeploys (where the upStack spinner that normally overlaps
141
+ // the build was skipped). Drive a spinner so the operator sees progress.
142
+ const imageSpinner = p.spinner();
143
+ imageSpinner.start('Building app image');
144
+ await composeLocalBuildPromise;
145
+ imageSpinner.message('Sideloading app image to the server');
146
+ await perfAsync('deploy.image.sideload', async () =>
147
+ sideloadCompose({
148
+ tag: localImageTag,
149
+ sshTarget: `root@${serverIp}`,
150
+ sshKey: sshKeyPath,
151
+ }),
152
+ );
153
+ imageSpinner.stop('App image built and sideloaded');
154
+ } else if (isDirectDeploy) {
155
+ // Direct path: build the image on the server via DOCKER_HOST=ssh://.
156
+ // BuildKit caches aggressively, so unchanged source rebuilds in 1-3s.
157
+ // Slower than local+sideload (no upStack overlap) but works when the
158
+ // operator has no local Docker — selected by --direct or by
159
+ // resolveBuildMode's docker-not-found fallback. VITE_* args plumbed
160
+ // through here for the same reason as the local-build path above.
161
+ const { collectComposeBuildArgs } = await import('./build-args.js');
162
+ const directBuildArgs = collectComposeBuildArgs(process.cwd(), {
163
+ projectName: projectConfig.projectName,
164
+ domain,
165
+ });
166
+ const success = await perfAsync('deploy.image.directBuild', () =>
167
+ buildRemote(serverIp, sshKeyPath, localImageTag, process.cwd(), directBuildArgs),
168
+ );
169
+ if (!success) process.exit(1);
170
+ }
171
+
172
+ // --- Docker Hub login (skip if creds unchanged) ---
173
+ // Without this, the reconcile script's `docker compose pull` for
174
+ // Supabase service images (rest, auth, storage, kong, imgproxy, etc.)
175
+ // hits Docker Hub anonymously. A fresh Hetzner VPS shares a NAT range
176
+ // with many other deploys, so the per-IP unauthenticated pull quota
177
+ // is routinely exhausted — observed 2026-04-26 matrix run #3
178
+ // ("toomanyrequests: You have reached your unauthenticated pull rate
179
+ // limit"). Compose-HA + scale already log in; this brings the
180
+ // single-compose deploy path in line.
181
+ const dockerHubCreds = loadCredentials().dockerHub || null;
182
+ if (dockerHubCreds) {
183
+ const dhLoginInputs = {
184
+ serverIp,
185
+ registry: 'docker.io',
186
+ user: dockerHubCreds.username,
187
+ tokenFp: dockerHubCreds.token ? dockerHubCreds.token.slice(0, 8) : '',
188
+ };
189
+ if (!state.shouldSkip('compose-dockerhub-login', dhLoginInputs)) {
190
+ state.startStep('compose-dockerhub-login', dhLoginInputs);
191
+ await dockerLoginOnServer(serverIp, sshKeyPath, dockerHubCreds);
192
+ state.completeStep('compose-dockerhub-login', { serverIp });
193
+ }
194
+ }
195
+
196
+ // --- GHCR login (skip if creds unchanged) ---
197
+ if (ciReady.ghcrPullCreds) {
198
+ const loginInputs = {
199
+ serverIp,
200
+ registry: 'ghcr.io',
201
+ user: ciReady.ghcrPullCreds.owner,
202
+ // Hash the token's fingerprint, not the token itself — we don't
203
+ // want the token written to .vibecarbon/deploy-state-*.json.
204
+ tokenFp: ciReady.ghcrPullCreds.token ? ciReady.ghcrPullCreds.token.slice(0, 8) : '',
205
+ };
206
+ if (!state.shouldSkip('compose-ghcr-login', loginInputs)) {
207
+ state.startStep('compose-ghcr-login', loginInputs);
208
+ await dockerLoginOnServer(serverIp, sshKeyPath, {
209
+ username: ciReady.ghcrPullCreds.owner,
210
+ token: ciReady.ghcrPullCreds.token,
211
+ registry: 'ghcr.io',
212
+ });
213
+ state.completeStep('compose-ghcr-login', { serverIp });
214
+ }
215
+ }
216
+
217
+ // --- Bundle rsync (skip when bundle inputs unchanged) ---
218
+ const bundleInputs = {
219
+ serverIp,
220
+ projectName: projectConfig.projectName,
221
+ imageRef,
222
+ domain,
223
+ services,
224
+ };
225
+ if (!state.shouldSkip('compose-setup-files', bundleInputs)) {
226
+ state.startStep('compose-setup-files', bundleInputs);
227
+ // setupServerFiles tars the bundle locally + streams over ssh +
228
+ // extracts on the server in one pipeline. Bucket the whole upload
229
+ // here; the inner tar/upload split is captured in compose/index.js.
230
+ await perfAsync('deploy.bundle.upload', () =>
231
+ setupServerFiles(serverIp, sshKeyPath, projectConfig.projectName, {
232
+ ...services,
233
+ domain,
234
+ image: imageRef,
235
+ bundlePath,
236
+ }),
237
+ );
238
+ state.completeStep('compose-setup-files', { serverIp });
239
+ }
240
+
241
+ // --- DNS update + propagation wait BEFORE compose-up ---
242
+ //
243
+ // Traefik attempts ACME HTTP-01 challenges immediately on container
244
+ // start and bursts ~7 attempts in ~30s before giving up. If LE's
245
+ // DNS resolver sees stale or absent records on those attempts,
246
+ // every challenge fails with "no valid A records found" and
247
+ // acme.json ends the deploy with zero certs issued — browser
248
+ // sees TRAEFIK DEFAULT CERT and NET::ERR_CERT_AUTHORITY_INVALID.
249
+ //
250
+ // The warm-up that fires in Step 4 writes a 0.0.0.0 placeholder;
251
+ // LE specifically rejects 0.0.0.0 as not-a-valid-A-record. So even
252
+ // when warm-up "succeeds," it doesn't help ACME — it actively
253
+ // poisons the resolver state until the real IP overwrites it.
254
+ //
255
+ // Fix (RCA from vibecarbon.com cold-deploy 2026-05-19): swap the
256
+ // ordering. Write the real IP now, wait for it to propagate to
257
+ // public resolvers, then start compose. Traefik's first ACME
258
+ // attempt sees real DNS, succeeds on the first try.
259
+ //
260
+ // The compose-HA path already writes DNS before compose-up at
261
+ // src/lib/deploy/compose/ha.js — single-region compose was the
262
+ // outlier that ran startComposeStack first then updated DNS.
263
+ if (domain && dnsProvider && dnsProvider !== 'manual') {
264
+ await perfAsync('deploy.dns.warm', async () => {
265
+ await dnsWarmupPromise;
266
+ });
267
+ const dnsUpdateInputs = { domain, dnsProvider, serverIp };
268
+ if (!state.shouldSkip('compose-dns-update', dnsUpdateInputs)) {
269
+ state.startStep('compose-dns-update', dnsUpdateInputs);
270
+ const { setupSimple: updateDnsIp } =
271
+ dnsProvider === 'cloudflare'
272
+ ? await import('../../cloudflare.js')
273
+ : await import('../../hetzner-dns.js');
274
+ const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
275
+ const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
276
+ const dnsSpinner = p.spinner();
277
+ dnsSpinner.start(`Pointing ${domain} → ${serverIp}...`);
278
+ await perfAsync('deploy.dns.create', () => updateDnsIp(token, zoneId, domain, serverIp));
279
+ dnsSpinner.stop(`DNS A record set: ${domain} → ${serverIp}`);
280
+ state.completeStep('compose-dns-update', { serverIp });
281
+ }
282
+ // HTTP-01 only: block compose-up until public resolvers see the real
283
+ // IP. 120s budget covers Cloudflare's typical edge propagation (under
284
+ // 60s) with margin. Returns false on timeout; we proceed anyway —
285
+ // Traefik can still acquire certs on its own retries, the customer
286
+ // just sees a brief self-signed-cert window. Hard-failing here would
287
+ // mask deploys that succeed eventually.
288
+ //
289
+ // DNS-01 (managed providers) skips this entirely: lego validates via a
290
+ // TXT record it writes through the provider API, so cert issuance does
291
+ // not wait on the A record propagating. The A record we just wrote
292
+ // propagates in the background; the post-deploy health probe uses
293
+ // localhost + Host header and never depends on it.
294
+ //
295
+ // Visible spinner so the operator isn't staring at a frozen
296
+ // "Yes" screen for up to 120s on a cold deploy where the DNS
297
+ // edge is slow. `timer` indicator forces the rendered string to
298
+ // change every second so progress remains visibly alive.
299
+ if (!dnsChallenge) {
300
+ const dnsSpinner = p.spinner({ indicator: 'timer' });
301
+ dnsSpinner.start(`Waiting for ${domain} to resolve to ${serverIp}`);
302
+ const dnsPropagated = await perfAsync('deploy.dns.waitForPropagation', () =>
303
+ waitForDNSPropagation(domain, serverIp, 120_000),
304
+ );
305
+ dnsSpinner.stop(
306
+ dnsPropagated
307
+ ? `DNS resolves: ${domain} → ${serverIp}`
308
+ : `DNS propagation timed out — proceeding anyway`,
309
+ );
310
+ }
311
+ }
312
+
313
+ // `docker compose up -d` is idempotent and cheap — always run so a
314
+ // config tweak that didn't change the bundle hash (e.g. env var)
315
+ // still takes effect. Reconcile.sh on the server runs `docker compose
316
+ // pull` (when not local image) + `docker compose up -d` — see
317
+ // compose/index.js for the inner perf instrumentation.
318
+ await perfAsync('deploy.reconcile.run', () =>
319
+ startComposeStack(serverIp, sshKeyPath, projectConfig.projectName, services),
320
+ );
321
+
322
+ // Apply app migrations + reload PostgREST. The orchestrator's inline
323
+ // single-compose path previously skipped this (only deployComposeHA ran
324
+ // runMigrations), so compose-single shipped an EMPTY app schema — 0 public
325
+ // tables, every DB-backed feature 500'd, and db_schema verify failed with
326
+ // PGRST205. runMigrations waits for supabase_admin, applies each
327
+ // supabase/migrations/* with ON_ERROR_STOP=1 (a real failure aborts the
328
+ // deploy rather than silently shipping a schema-less prod), then reloads
329
+ // PostgREST's schema cache. Mirrors the HA path.
330
+ await perfAsync('deploy.compose.migrations', () =>
331
+ runMigrations(serverIp, sshKeyPath, projectConfig.projectName),
332
+ );
333
+
334
+ // Create the production app super-admin in auth.users via GoTrue's admin
335
+ // API. Same class of trap as runMigrations above: this inline single-
336
+ // compose path previously skipped it (only deployComposeHA called
337
+ // createAdminUser), so a single-server deploy shipped a prod app the
338
+ // operator couldn't actually log into — the only admin user lived in
339
+ // their local Docker. Idempotent (422 = already exists); a failure here
340
+ // is a warning, not a hard abort, so a transient GoTrue blip doesn't
341
+ // fail an otherwise-healthy deploy — re-running `vibecarbon deploy`
342
+ // retries it.
343
+ const adminResult = await perfAsync('deploy.compose.createAdminUser', () =>
344
+ createAdminUser(serverIp, sshKeyPath, projectConfig.projectName),
345
+ );
346
+ if (adminResult.success) {
347
+ p.log.success(adminResult.message);
348
+ } else {
349
+ p.log.warn(`${adminResult.message}. Run \`vibecarbon deploy\` again to retry.`);
350
+ }
351
+
352
+ // --- Verifiable success gate ---
353
+ // Probe the app's own /api/health endpoint on the server itself (via
354
+ // localhost + Host header), bypassing DNS/TLS. A successful deploy
355
+ // must yield an HTTP 2xx here; otherwise fail the deploy loudly with
356
+ // container state + log tail.
357
+ //
358
+ // verifyAppHealth polls every 10s for up to 3 minutes while the app
359
+ // container restarts and binds port 3000. Without a visible spinner
360
+ // the operator just sees a frozen "Stack reconciled" line for that
361
+ // entire window — observed 2026-05-19 vibecarbon.com re-deploy
362
+ // where compose-up finished in ~5s but the app took ~3min to come
363
+ // back, leaving the screen looking hung. `timer` indicator so the
364
+ // elapsed-seconds counter ticks visibly even on terminals where
365
+ // the spinner frame-character animation gets throttled under load.
366
+ const { verifyAppHealth } = await import('./index.js');
367
+ const healthSpinner = p.spinner({ indicator: 'timer' });
368
+ healthSpinner.start('Waiting for app to start serving requests');
369
+ let health;
370
+ try {
371
+ health = await perfAsync('deploy.health.probe', () =>
372
+ verifyAppHealth(serverIp, sshKeyPath, projectConfig.projectName, {
373
+ domain,
374
+ }),
375
+ );
376
+ } catch (err) {
377
+ healthSpinner.stop('App health probe errored', 1);
378
+ throw err;
379
+ }
380
+ if (!health.healthy) {
381
+ healthSpinner.stop(`App not serving requests (status: ${health.status})`, 1);
382
+ p.log.error(
383
+ `Deploy produced an unhealthy app (probe status: ${health.status}):\n${health.details}`,
384
+ );
385
+ process.exit(1);
386
+ }
387
+ healthSpinner.stop(`App is serving requests (HTTP ${health.status})`);
388
+
389
+ // Install the scheduled wal-g backup cron on the server. Without this,
390
+ // a fresh compose deploy collects + persists backupConfig (envConfig.backup)
391
+ // but never schedules a backup — backups only ran after a `vibecarbon scale`
392
+ // event (scale.js was the lone caller). setupComposeBackupCron defaults the
393
+ // schedule + retain when backupConfig is absent, so it always installs a
394
+ // sensible cron. wal-g reads its S3 config from the db container env
395
+ // (rendered into .env at deploy) — the cron just runs compose-backup.sh,
396
+ // which self-skips (exit 0) when no S3 backup target is configured, so an
397
+ // always-installed cron never hard-fails on a no-S3 deploy.
398
+ //
399
+ // A cron-install failure must NOT fail an already-healthy deploy: the app
400
+ // is serving by this point. Downgrade to a warning so a transient ssh/cron
401
+ // hiccup doesn't roll back a successful deploy.
402
+ try {
403
+ await perfAsync('deploy.compose.backupCron', async () =>
404
+ setupComposeBackupCron(serverIp, sshKeyPath, projectConfig.projectName, backupConfig),
405
+ );
406
+ } catch (err) {
407
+ p.log.warn(`Scheduled backup cron install failed (deploy still succeeded): ${err.message}`);
408
+ }
409
+
410
+ // No ACME self-heal needed here. Managed-DNS deploys (cloudflare/hetzner)
411
+ // issue certs via DNS-01, which has no HTTP-01-vs-DNS-propagation race to
412
+ // recover from; `manual` DNS was never self-healed (we don't manage its
413
+ // records). The old acme.json poll + Traefik restart (acme-verify.js)
414
+ // existed only for the HTTP-01 race the DNS-01 switch eliminates.
415
+
416
+ return {
417
+ masterIp: serverIp,
418
+ serverId: serverHetznerId || 'manual',
419
+ serverName: serverHetznerName,
420
+ };
421
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { execFileSync } from 'node:child_process';
16
+ import { runCommandAsync } from '../command.js';
16
17
  import { knownHostsPathForKey } from '../host-keys.js';
17
18
  import { shEscape } from '../shell.js';
18
19
 
@@ -95,9 +96,9 @@ export function inspectGitState(cwd) {
95
96
  *
96
97
  * @param {string} projectDir - Project root with the Dockerfile.
97
98
  * @param {{projectName: string, timestamp?: string, rebuild?: boolean, tagPrefix?: string, buildArgs?: Record<string,string>}} options
98
- * @returns {{tag: string, gitSha: string, isDirty: boolean}}
99
+ * @returns {Promise<{tag: string, gitSha: string, isDirty: boolean}>}
99
100
  */
100
- export function buildLocalImage(projectDir, options) {
101
+ export async function buildLocalImage(projectDir, options) {
101
102
  const { projectName, rebuild = false, tagPrefix, buildArgs = {} } = options;
102
103
  if (!projectName) {
103
104
  throw new Error('buildLocalImage: projectName is required');
@@ -124,7 +125,15 @@ export function buildLocalImage(projectDir, options) {
124
125
  }
125
126
  args.push('-t', tag, projectDir);
126
127
 
127
- execFileSync('docker', args, { stdio: 'inherit' });
128
+ // silent: false (the default) inherits stdio so the operator sees layer
129
+ // progress — but runCommandAsync only *rejects* on nonzero exit when
130
+ // silent:true; with inherited stdio it resolves `false` instead (see
131
+ // global constraints). The old execFileSync({stdio:'inherit'}) threw on
132
+ // its own, so we must replicate "fail loudly" by hand here.
133
+ const ok = await runCommandAsync(['docker', ...args], {});
134
+ if (ok === false) {
135
+ throw new Error(`buildLocalImage: docker build failed for ${tag}`);
136
+ }
128
137
  return { tag, gitSha, isDirty };
129
138
  }
130
139
 
@@ -150,8 +159,9 @@ function defaultTimestamp() {
150
159
  * `pipefail` so a docker-save / gzip failure isn't masked by ssh's exit code.
151
160
  *
152
161
  * @param {{tag: string, sshTarget: string, sshKey?: string}} args
162
+ * @returns {Promise<void>}
153
163
  */
154
- export function sideloadCompose({ tag, sshTarget, sshKey }) {
164
+ export async function sideloadCompose({ tag, sshTarget, sshKey }) {
155
165
  // SSH options mirror remote-build.js's wrapper + the compose deploy path:
156
166
  // * Host-key pinned per-env (knownHostsPathForKey), NOT /dev/null + no.
157
167
  // accept-new TOFU's an ephemeral/recycled Hetzner IP but rejects a
@@ -177,15 +187,17 @@ export function sideloadCompose({ tag, sshTarget, sshKey }) {
177
187
  // key warning + `docker load`'s "Loaded image:" success line both bypass
178
188
  // the clack gutter when inherited and print flush-left, breaking the
179
189
  // visual flow. On success we don't need either; on failure we surface
180
- // the captured stderr for debugging via the thrown Error.
190
+ // the captured stderr for debugging via the thrown Error. silent:true is
191
+ // required both to get the capture and because runCommandAsync only
192
+ // rejects (rather than resolving `false`) on nonzero exit in that mode.
181
193
  try {
182
- execFileSync('bash', ['-c', cmd], { stdio: ['ignore', 'pipe', 'pipe'] });
194
+ await runCommandAsync(['bash', '-c', cmd], { silent: true });
183
195
  } catch (err) {
184
196
  const stderr = err.stderr?.toString().trim() || '';
185
197
  const stdout = err.stdout?.toString().trim() || '';
186
198
  // Surface whatever signal the operator can act on: child stderr first
187
199
  // (real ssh / docker errors), then stdout, then the wrapping error's
188
- // message (preserves "Connection refused" when execFileSync's own
200
+ // message (preserves "Connection refused" when runCommandAsync's own
189
201
  // error string is the only thing we have).
190
202
  const detail = [stderr, stdout, err.message].filter(Boolean).join('\n').trim();
191
203
  const wrapped = new Error(`sideloadCompose failed (exit ${err.status ?? '?'}): ${detail}`);