@boxes-dev/dvb 0.2.4 → 0.2.6

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 (92) hide show
  1. package/dist/bin/dvb.cjs +5224 -1568
  2. package/dist/bin/dvbd.cjs +73 -2
  3. package/dist/codex/services-schema.json +4 -0
  4. package/dist/codex/setup-env-secrets-schema.json +50 -0
  5. package/dist/codex/setup-external-schema.json +35 -0
  6. package/dist/codex/setup-extra-artifacts-schema.json +22 -0
  7. package/dist/devbox/cli.d.ts.map +1 -1
  8. package/dist/devbox/cli.js +1 -1
  9. package/dist/devbox/cli.js.map +1 -1
  10. package/dist/devbox/commands/agent.js +2 -2
  11. package/dist/devbox/commands/agent.js.map +1 -1
  12. package/dist/devbox/commands/init/args.d.ts +1 -0
  13. package/dist/devbox/commands/init/args.d.ts.map +1 -1
  14. package/dist/devbox/commands/init/args.js +4 -0
  15. package/dist/devbox/commands/init/args.js.map +1 -1
  16. package/dist/devbox/commands/init/clack.d.ts +22 -0
  17. package/dist/devbox/commands/init/clack.d.ts.map +1 -0
  18. package/dist/devbox/commands/init/clack.js +152 -0
  19. package/dist/devbox/commands/init/clack.js.map +1 -0
  20. package/dist/devbox/commands/init/codex/artifacts.d.ts.map +1 -1
  21. package/dist/devbox/commands/init/codex/artifacts.js +2 -1
  22. package/dist/devbox/commands/init/codex/artifacts.js.map +1 -1
  23. package/dist/devbox/commands/init/codex/index.d.ts +6 -6
  24. package/dist/devbox/commands/init/codex/index.d.ts.map +1 -1
  25. package/dist/devbox/commands/init/codex/index.js +118 -11
  26. package/dist/devbox/commands/init/codex/index.js.map +1 -1
  27. package/dist/devbox/commands/init/codex/local.d.ts +23 -4
  28. package/dist/devbox/commands/init/codex/local.d.ts.map +1 -1
  29. package/dist/devbox/commands/init/codex/local.js +344 -142
  30. package/dist/devbox/commands/init/codex/local.js.map +1 -1
  31. package/dist/devbox/commands/init/codex/plan.d.ts +31 -3
  32. package/dist/devbox/commands/init/codex/plan.d.ts.map +1 -1
  33. package/dist/devbox/commands/init/codex/plan.js +132 -6
  34. package/dist/devbox/commands/init/codex/plan.js.map +1 -1
  35. package/dist/devbox/commands/init/codex/prompts.d.ts +4 -2
  36. package/dist/devbox/commands/init/codex/prompts.d.ts.map +1 -1
  37. package/dist/devbox/commands/init/codex/prompts.js +4 -2
  38. package/dist/devbox/commands/init/codex/prompts.js.map +1 -1
  39. package/dist/devbox/commands/init/codex/remote.d.ts +2 -2
  40. package/dist/devbox/commands/init/codex/remote.d.ts.map +1 -1
  41. package/dist/devbox/commands/init/codex/remote.js +116 -11
  42. package/dist/devbox/commands/init/codex/remote.js.map +1 -1
  43. package/dist/devbox/commands/init/index.d.ts.map +1 -1
  44. package/dist/devbox/commands/init/index.js +1957 -658
  45. package/dist/devbox/commands/init/index.js.map +1 -1
  46. package/dist/devbox/commands/init/packaging.d.ts.map +1 -1
  47. package/dist/devbox/commands/init/packaging.js +4 -9
  48. package/dist/devbox/commands/init/packaging.js.map +1 -1
  49. package/dist/devbox/commands/init/progress.d.ts +17 -0
  50. package/dist/devbox/commands/init/progress.d.ts.map +1 -0
  51. package/dist/devbox/commands/init/progress.js +68 -0
  52. package/dist/devbox/commands/init/progress.js.map +1 -0
  53. package/dist/devbox/commands/init/registry.d.ts.map +1 -1
  54. package/dist/devbox/commands/init/registry.js +0 -6
  55. package/dist/devbox/commands/init/registry.js.map +1 -1
  56. package/dist/devbox/commands/init/remote.d.ts +13 -2
  57. package/dist/devbox/commands/init/remote.d.ts.map +1 -1
  58. package/dist/devbox/commands/init/remote.js +231 -80
  59. package/dist/devbox/commands/init/remote.js.map +1 -1
  60. package/dist/devbox/commands/init/repo.d.ts +5 -2
  61. package/dist/devbox/commands/init/repo.d.ts.map +1 -1
  62. package/dist/devbox/commands/init/repo.js +28 -12
  63. package/dist/devbox/commands/init/repo.js.map +1 -1
  64. package/dist/devbox/commands/init/state.d.ts +22 -1
  65. package/dist/devbox/commands/init/state.d.ts.map +1 -1
  66. package/dist/devbox/commands/init/state.js +25 -1
  67. package/dist/devbox/commands/init/state.js.map +1 -1
  68. package/dist/devbox/commands/mountSsh.d.ts.map +1 -1
  69. package/dist/devbox/commands/mountSsh.js +37 -4
  70. package/dist/devbox/commands/mountSsh.js.map +1 -1
  71. package/dist/devbox/commands/wezterm.d.ts.map +1 -1
  72. package/dist/devbox/commands/wezterm.js +18 -132
  73. package/dist/devbox/commands/wezterm.js.map +1 -1
  74. package/dist/devbox/completions/index.d.ts.map +1 -1
  75. package/dist/devbox/completions/index.js +1 -0
  76. package/dist/devbox/completions/index.js.map +1 -1
  77. package/dist/prompts/local-scan-env-secrets.md +51 -0
  78. package/dist/prompts/local-scan-external.md +36 -0
  79. package/dist/prompts/local-scan-extra-artifacts.md +26 -0
  80. package/dist/prompts/local-services-scan.md +66 -5
  81. package/dist/prompts/remote-apply.md +17 -9
  82. package/dist/wezterm/ensureMux.d.ts +18 -0
  83. package/dist/wezterm/ensureMux.d.ts.map +1 -0
  84. package/dist/wezterm/ensureMux.js +74 -0
  85. package/dist/wezterm/ensureMux.js.map +1 -0
  86. package/dist/wezterm/installOptions.d.ts +18 -0
  87. package/dist/wezterm/installOptions.d.ts.map +1 -0
  88. package/dist/wezterm/installOptions.js +129 -0
  89. package/dist/wezterm/installOptions.js.map +1 -0
  90. package/package.json +3 -1
  91. package/dist/codex/setup-schema.json +0 -176
  92. package/dist/prompts/local-scan.md +0 -32
@@ -2,24 +2,25 @@ import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
- import readline from "node:readline/promises";
5
+ import { cancel as clackCancel, confirm as clackConfirm, isCancel, log as clackLog, note as clackNote, select as clackSelect, selectKey as clackSelectKey, taskLog as clackTaskLog, } from "@clack/prompts";
6
6
  import { resolveSocketInfo } from "@boxes-dev/core";
7
7
  import { createSecretStore, loadConfig, createSpritesClient, fingerprintFromOrigin, fingerprintFromRootCommit, normalizeGitRemoteUrl, resolveSpritesApiUrl, SpritesApiError, slugify, } from "@boxes-dev/core";
8
8
  import { DAEMON_TIMEOUT_MS, ensureDaemonRunning, requestJson, requireDaemonFeatures, } from "../../daemonClient.js";
9
9
  import { ensureSpritesToken } from "../../auth.js";
10
10
  import { fetchSpriteDaemonRelease, getConvexUrl, issueSpriteDaemonToken, } from "../../controlPlane.js";
11
11
  import { logger } from "../../logger.js";
12
- import { createStatusLine } from "../../ui/statusLine.js";
13
12
  import { parseInitArgs } from "./args.js";
14
13
  import { confirmCopyWorktree, findRepoRoot, mapGlobalGitConfigDestinations, readGlobalGitConfigFiles, readHeadState, readNullSeparatedPaths, readRepoOrigin, readRootCommit, readWorktreeState, resolveGitCommonDir, runCommand, } from "./repo.js";
15
14
  import { createFileListArchive, createGitMetaArchive, writePatch } from "./packaging.js";
16
- import { bootstrapDevbox, ensureSpriteDaemonService, expandHome, ensureWeztermMuxService, installSpriteDaemon, installWeztermMux, shellQuote, writeRemoteCodexConfig, } from "./remote.js";
15
+ import { bootstrapDevbox, ensureSpriteDaemonService, expandHome, ensureWeztermMuxService, installSpriteDaemon, shellQuote, stageRemoteSetupArtifacts, writeRemoteCodexConfig, } from "./remote.js";
16
+ import { ensureWeztermMuxInstalled } from "../../../wezterm/ensureMux.js";
17
17
  import { ensureDevboxToml, readRepoMarker, writeRepoMarker } from "./registry.js";
18
- import { readInitState, writeInitState } from "./state.js";
18
+ import { INIT_STEP_KEYS, readInitState, writeInitState } from "./state.js";
19
19
  import { ensureSshConfig, ensureSshKey, copyToClipboard, openBrowser, parseGitRemote, readRemoteOrigin, setRemoteOrigin, verifySshAuth, } from "./ssh.js";
20
20
  import { ensureKnownHostsFile, ensureLocalMountKey, ensureRemoteMountAccess, ensureSshdService, readLocalMountPublicKey, } from "../mountSsh.js";
21
- import { createSetupArtifacts, promptForPlanApproval, promptForServicesApproval, readSetupPlan, readServicesPlan, runLocalServicesScan, runLocalSetupScan, runRemoteCodexSetup, uploadSetupPlan, writeSetupPlan, writeSetupSchema, writeServicesPlan, writeServicesSchema, } from "./codex/index.js";
21
+ import { createSetupArtifacts, promptForPlanApproval, promptForServicesApproval, readSetupPlan, readSetupEnvSecretsPlan, readSetupExternalPlan, readSetupExtraArtifactsPlan, readServicesPlan, runLocalServicesScan, runLocalSetupEnvSecretsScan, runLocalSetupExternalScan, runLocalSetupExtraArtifactsScan, runRemoteCodexSetup, uploadSetupPlan, mergeSetupScans, writeSetupPlan, writeSetupEnvSecretsSchema, writeSetupExternalSchema, writeSetupExtraArtifactsSchema, writeServicesPlan, writeServicesSchema, } from "./codex/index.js";
22
22
  import { mergeServicesToml, splitShellCommand, } from "../servicesToml.js";
23
+ import { runInitStep } from "./progress.js";
23
24
  const resolveInitStatus = (steps, complete) => {
24
25
  if (complete || steps?.codexApplied)
25
26
  return "complete";
@@ -54,6 +55,32 @@ const buildServicesTomlUpdates = (services) => {
54
55
  }
55
56
  return updates;
56
57
  };
58
+ const extractCheckpointId = (events) => {
59
+ const idPattern = /\bv\d+\b/;
60
+ const matchId = (value) => {
61
+ if (typeof value !== "string")
62
+ return null;
63
+ const match = value.match(idPattern);
64
+ return match?.[0] ?? null;
65
+ };
66
+ for (const event of events) {
67
+ const direct = event.id ??
68
+ event.checkpoint_id ??
69
+ event.checkpointId ??
70
+ event.checkpointID ??
71
+ event.checkpoint;
72
+ if (typeof direct === "string" && /^v\d+$/.test(direct)) {
73
+ return direct;
74
+ }
75
+ const candidates = [event.data, event.message, event.raw, event.error];
76
+ for (const candidate of candidates) {
77
+ const extracted = matchId(candidate);
78
+ if (extracted)
79
+ return extracted;
80
+ }
81
+ }
82
+ return null;
83
+ };
57
84
  const writeRemoteServicesToml = async ({ client, canonical, workdir, services, }) => {
58
85
  if (services.length === 0)
59
86
  return;
@@ -76,34 +103,67 @@ const writeRemoteServicesToml = async ({ client, canonical, workdir, services, }
76
103
  const merged = mergeServicesToml(baseContent, updates);
77
104
  await client.writeFile(canonical, path.posix.join(workdir.replace(/\/$/, ""), "devbox.toml"), Buffer.from(merged));
78
105
  };
79
- const promptYesNo = async (question, defaultYes = false) => {
80
- if (!process.stdin.isTTY) {
81
- return defaultYes;
82
- }
83
- const suffix = defaultYes ? " (Y/n): " : " (y/N): ";
84
- const rl = readline.createInterface({
85
- input: process.stdin,
86
- output: process.stdout,
106
+ const enableRemoteServices = async ({ client, canonical, services, status, }) => {
107
+ if (services.length === 0)
108
+ return;
109
+ logger.info("init_services_enable_start", {
110
+ box: canonical,
111
+ serviceCount: services.length,
87
112
  });
88
- const answer = await rl.question(`${question}${suffix}`);
89
- rl.close();
90
- const normalized = answer.trim().toLowerCase();
91
- if (!normalized)
92
- return defaultYes;
93
- return normalized === "y" || normalized === "yes";
113
+ for (const service of services) {
114
+ const name = service.name.trim();
115
+ if (!name) {
116
+ throw new Error("Service name is required in services.json.");
117
+ }
118
+ status?.stage(`Enabling service: ${name}`);
119
+ const parts = splitShellCommand(service.command ?? "");
120
+ const [cmd, ...args] = parts;
121
+ if (!cmd) {
122
+ throw new Error(`Service "${name}" is missing a command.`);
123
+ }
124
+ const input = {
125
+ cmd,
126
+ ...(args.length > 0 ? { args } : {}),
127
+ ...(service.httpPort !== null ? { httpPort: service.httpPort } : {}),
128
+ };
129
+ await client.createService(canonical, name, input);
130
+ }
131
+ logger.info("init_services_enable_complete", { box: canonical });
132
+ };
133
+ const throwInitCanceled = () => {
134
+ clackCancel("Init canceled.");
135
+ throw new Error("Init canceled.");
94
136
  };
95
- const promptToContinue = async (message) => {
96
- if (!process.stdin.isTTY) {
137
+ const promptBeforeOpenBrowser = async ({ url, title, consequence, }) => {
138
+ if (!process.stdin.isTTY)
97
139
  return false;
98
- }
99
- const rl = readline.createInterface({
100
- input: process.stdin,
101
- output: process.stdout,
140
+ clackNote(url, title);
141
+ const choice = await clackSelectKey({
142
+ message: `Open ${title} in your browser?`,
143
+ options: [
144
+ { value: "o", label: "Open in browser" },
145
+ { value: "s", label: "Skip" },
146
+ ],
147
+ initialValue: "o",
102
148
  });
103
- await rl.question(message);
104
- rl.close();
149
+ if (isCancel(choice)) {
150
+ throwInitCanceled();
151
+ }
152
+ if (choice === "s") {
153
+ clackLog.warn(consequence);
154
+ return false;
155
+ }
105
156
  return true;
106
157
  };
158
+ const toPosixPath = (value) => value.split(path.sep).join(path.posix.sep);
159
+ const toRepoRelativePath = (repoRoot, filePath) => {
160
+ const resolved = path.isAbsolute(filePath)
161
+ ? filePath
162
+ : path.resolve(repoRoot, filePath);
163
+ const relative = path.relative(repoRoot, resolved) || filePath;
164
+ return toPosixPath(relative);
165
+ };
166
+ const isOutsideRepoPath = (relativePath) => relativePath === ".." || relativePath.startsWith(`..${path.posix.sep}`);
107
167
  const ensureWorkdirOwnership = async ({ client, canonical, workdir, status, json, }) => {
108
168
  const checkResult = await client.exec(canonical, [
109
169
  "/bin/bash",
@@ -133,26 +193,273 @@ const ensureWorkdirOwnership = async ({ client, canonical, workdir, status, json
133
193
  }
134
194
  }
135
195
  };
196
+ const ensureGitSafeDirectory = async ({ client, canonical, workdir, status, }) => {
197
+ status.stage("Configuring git safe.directory");
198
+ logger.info("init_git_safe_directory_configure_start", {
199
+ box: canonical,
200
+ workdir,
201
+ });
202
+ const script = [
203
+ "set -euo pipefail",
204
+ `repo=${shellQuote(workdir)}`,
205
+ 'if [ ! -d "$repo" ]; then',
206
+ ' echo "Missing repo workdir: $repo" >&2',
207
+ " exit 1",
208
+ "fi",
209
+ 'if [ ! -d "$repo/.git" ]; then',
210
+ " exit 0",
211
+ "fi",
212
+ "if ! command -v git >/dev/null 2>&1; then",
213
+ " exit 0",
214
+ "fi",
215
+ "",
216
+ 'ensure_safe_in_file() {',
217
+ ' cfg="$1"',
218
+ ' existing="$(git config --file "$cfg" --get-all safe.directory 2>/dev/null || true)"',
219
+ ' if printf \'%s\\n\' "$existing" | grep -Fxq "$repo"; then',
220
+ " return 0",
221
+ " fi",
222
+ ' git config --file "$cfg" --add safe.directory "$repo"',
223
+ "}",
224
+ "",
225
+ // Ensure the repo is trusted for the sprite user (most devbox flows).
226
+ 'ensure_safe_in_file "/home/sprite/.gitconfig"',
227
+ // If we created/modified the file as root, best-effort fix the owner.
228
+ 'if [ "$(id -u)" -eq 0 ]; then',
229
+ " chown sprite:sprite /home/sprite/.gitconfig >/dev/null 2>&1 || true",
230
+ "elif command -v sudo >/dev/null 2>&1; then",
231
+ " sudo -n chown sprite:sprite /home/sprite/.gitconfig >/dev/null 2>&1 || true",
232
+ "fi",
233
+ "",
234
+ // Best-effort: also trust the repo for root, in case tools run git as root.
235
+ 'if [ "$(id -u)" -eq 0 ]; then',
236
+ ' ensure_safe_in_file "/root/.gitconfig"',
237
+ "elif command -v sudo >/dev/null 2>&1; then",
238
+ ' root_existing="$(sudo -n git config --file /root/.gitconfig --get-all safe.directory 2>/dev/null || true)"',
239
+ ' if ! printf \'%s\\n\' "$root_existing" | grep -Fxq "$repo"; then',
240
+ ' sudo -n git config --file /root/.gitconfig --add safe.directory "$repo" >/dev/null 2>&1 || true',
241
+ " fi",
242
+ "fi",
243
+ ].join("\n");
244
+ const result = await client.exec(canonical, [
245
+ "/bin/bash",
246
+ "--noprofile",
247
+ "--norc",
248
+ "-e",
249
+ "-u",
250
+ "-o",
251
+ "pipefail",
252
+ "-c",
253
+ script,
254
+ ]);
255
+ if (result.exitCode !== 0) {
256
+ const details = result.stderr || result.stdout || "";
257
+ throw new Error(details
258
+ ? `Failed to configure git safe.directory: ${details.trim()}`
259
+ : `Failed to configure git safe.directory (exit ${result.exitCode})`);
260
+ }
261
+ logger.info("init_git_safe_directory_configure_complete", {
262
+ box: canonical,
263
+ workdir,
264
+ });
265
+ };
136
266
  export const runInit = async (args) => {
137
267
  const parsed = parseInitArgs(args);
138
- const statusEnabled = process.stderr.isTTY && !parsed.json;
139
- const status = createStatusLine({ enabled: statusEnabled });
268
+ const progressEnabled = process.stdout.isTTY && !parsed.json;
140
269
  const run = async () => {
141
- status.stage("Detecting repository");
142
- const cwd = process.cwd();
143
- const repoRoot = await findRepoRoot(cwd);
144
- const repoName = path.basename(repoRoot);
145
- const slug = slugify(repoName);
146
- const localHomeDir = process.env.HOME ?? os.homedir();
147
- const repoMarker = await readRepoMarker(repoRoot);
148
- const origin = await readRepoOrigin(repoRoot);
149
- const normalizedOrigin = origin ? normalizeGitRemoteUrl(origin) : null;
150
- const fingerprint = origin
151
- ? fingerprintFromOrigin(origin)
152
- : repoMarker?.fingerprint ??
153
- fingerprintFromRootCommit(await readRootCommit(repoRoot), randomUUID());
154
- let initState = await readInitState(repoRoot);
155
- const initFingerprintMismatch = Boolean(initState?.fingerprint) && initState?.fingerprint !== fingerprint;
270
+ const detected = await runInitStep({
271
+ enabled: progressEnabled,
272
+ title: "Detecting repository",
273
+ fn: async () => {
274
+ const cwd = process.cwd();
275
+ const repoRoot = await findRepoRoot(cwd);
276
+ const repoName = path.basename(repoRoot);
277
+ const slug = slugify(repoName);
278
+ const localHomeDir = process.env.HOME ?? os.homedir();
279
+ // Recoverability contract:
280
+ // - Every init step must be idempotent and/or checkpointed in init-state.json.
281
+ // - `complete` must only be true when the setup is actually usable without
282
+ // additional `dvb init --resume` work.
283
+ // See: apps/cli/docs/INIT_RECOVERABILITY.md
284
+ const repoMarker = await readRepoMarker(repoRoot);
285
+ const origin = await readRepoOrigin(repoRoot);
286
+ const normalizedOrigin = origin ? normalizeGitRemoteUrl(origin) : null;
287
+ const fingerprint = origin
288
+ ? fingerprintFromOrigin(origin)
289
+ : repoMarker?.fingerprint ??
290
+ fingerprintFromRootCommit(await readRootCommit(repoRoot), randomUUID());
291
+ let initState = await readInitState(repoRoot);
292
+ const initFingerprintMismatch = Boolean(initState?.fingerprint) &&
293
+ initState?.fingerprint !== fingerprint;
294
+ if (!initFingerprintMismatch &&
295
+ initState?.complete &&
296
+ !initState.steps.codexApplied) {
297
+ // Older versions could mark init "complete" even though Codex setup had not
298
+ // been applied yet. Treat this as incomplete so `dvb init --resume` can
299
+ // recover.
300
+ initState = { ...initState, complete: false };
301
+ await writeInitState(repoRoot, initState);
302
+ }
303
+ return {
304
+ repoRoot,
305
+ repoName,
306
+ slug,
307
+ localHomeDir,
308
+ repoMarker,
309
+ origin,
310
+ normalizedOrigin,
311
+ fingerprint,
312
+ initState,
313
+ initFingerprintMismatch,
314
+ };
315
+ },
316
+ });
317
+ const { repoRoot, repoName, slug, localHomeDir, repoMarker, origin, normalizedOrigin, fingerprint, } = detected;
318
+ let initState = detected.initState;
319
+ const initFingerprintMismatch = detected.initFingerprintMismatch;
320
+ if (parsed.status) {
321
+ if (parsed.resume ||
322
+ parsed.force ||
323
+ parsed.codexSetupOnly ||
324
+ parsed.alias ||
325
+ parsed.name) {
326
+ throw new Error("`dvb init --status` cannot be combined with other init flags (except --json).");
327
+ }
328
+ const socketInfo = resolveSocketInfo();
329
+ let daemonError = null;
330
+ let registryProject = null;
331
+ try {
332
+ await ensureDaemonRunning(socketInfo.socketPath);
333
+ await requireDaemonFeatures(socketInfo.socketPath, ["registry"]);
334
+ const existingProject = await requestJson(socketInfo.socketPath, "GET", `/registry/project?fingerprint=${encodeURIComponent(fingerprint)}`, DAEMON_TIMEOUT_MS.registry);
335
+ registryProject = existingProject.body.project ?? null;
336
+ }
337
+ catch (error) {
338
+ daemonError = error instanceof Error ? error.message : String(error);
339
+ }
340
+ const localStatus = initState && !initFingerprintMismatch
341
+ ? resolveInitStatus(initState.steps, initState.complete)
342
+ : null;
343
+ const recommendsResume = Boolean(initState && !initFingerprintMismatch && !initState.complete);
344
+ const recommendsInit = !registryProject && (!initState || initFingerprintMismatch);
345
+ const recommendsForce = !recommendsResume &&
346
+ (!initState || initFingerprintMismatch) &&
347
+ Boolean(registryProject?.initStatus && registryProject.initStatus !== "complete");
348
+ const lines = [];
349
+ lines.push("INIT STATUS");
350
+ lines.push("");
351
+ lines.push("Repo");
352
+ lines.push(` root: ${repoRoot}`);
353
+ lines.push(` origin: ${normalizedOrigin ?? origin ?? "(none)"}`);
354
+ lines.push(` fingerprint: ${fingerprint}`);
355
+ lines.push("");
356
+ lines.push("Local state");
357
+ if (repoMarker?.canonical) {
358
+ lines.push(` box marker (.devbox/box.json): present (alias: ${repoMarker.alias ?? "(none)"}, box: ${repoMarker.canonical})`);
359
+ }
360
+ else {
361
+ lines.push(" box marker (.devbox/box.json): missing");
362
+ }
363
+ if (initState) {
364
+ const completeText = initFingerprintMismatch
365
+ ? `${String(Boolean(initState.complete))} (fingerprint mismatch)`
366
+ : String(Boolean(initState.complete));
367
+ lines.push(` init checkpoint (.devbox/init-state.json): present (updated: ${initState.updatedAt}, complete: ${completeText})`);
368
+ if (initState.canonical || initState.alias || initState.workdir) {
369
+ lines.push(` box: ${initState.canonical ?? "(unknown)"} (alias: ${initState.alias ?? "(unknown)"})`);
370
+ lines.push(` workdir: ${initState.workdir ?? "(unknown)"}`);
371
+ }
372
+ if (initState.checkpoints?.preCodexSetup || initState.checkpoints?.postCodexSetup) {
373
+ const pre = initState.checkpoints?.preCodexSetup;
374
+ const post = initState.checkpoints?.postCodexSetup;
375
+ lines.push(" snapshots:");
376
+ if (pre) {
377
+ lines.push(` pre-codex-setup: ${pre.id} (created: ${pre.createdAt})`);
378
+ }
379
+ if (post) {
380
+ lines.push(` post-codex-setup: ${post.id} (created: ${post.createdAt})`);
381
+ }
382
+ }
383
+ if (localStatus) {
384
+ lines.push(` inferred init status: ${localStatus}`);
385
+ }
386
+ lines.push(" steps:");
387
+ const steps = initState.steps ?? {};
388
+ for (const key of INIT_STEP_KEYS) {
389
+ lines.push(` ${key}: ${steps[key] ? "yes" : "no"}`);
390
+ }
391
+ }
392
+ else {
393
+ lines.push(" init checkpoint (.devbox/init-state.json): missing");
394
+ }
395
+ lines.push("");
396
+ lines.push("Registry");
397
+ lines.push(` daemon socket: ${socketInfo.socketPath}`);
398
+ if (daemonError) {
399
+ lines.push(` daemon: unavailable (${daemonError})`);
400
+ }
401
+ else if (registryProject) {
402
+ lines.push(" project: present");
403
+ lines.push(` box: ${registryProject.canonical}`);
404
+ lines.push(` alias: ${registryProject.alias ?? "(none)"}`);
405
+ lines.push(` initStatus: ${registryProject.initStatus ?? "(none)"}`);
406
+ lines.push(` initUpdatedAt: ${registryProject.initUpdatedAt ?? "(none)"}`);
407
+ if (registryProject.localPaths && registryProject.localPaths.length > 0) {
408
+ lines.push(` localPaths: ${registryProject.localPaths.length} path(s)`);
409
+ }
410
+ }
411
+ else {
412
+ lines.push(" project: not found");
413
+ }
414
+ lines.push("");
415
+ lines.push("Recommended");
416
+ if (recommendsResume) {
417
+ lines.push(" dvb init --resume");
418
+ }
419
+ else if (recommendsInit) {
420
+ lines.push(" dvb init");
421
+ }
422
+ else if (recommendsForce) {
423
+ lines.push(" Resume is not available from this clone (no init checkpoint).");
424
+ lines.push(" If init was started elsewhere, re-run `dvb init --resume` from that repo.");
425
+ lines.push(" Otherwise, restart init with `dvb init --force` (re-provisions the remote workdir).");
426
+ }
427
+ else {
428
+ lines.push(" (none)");
429
+ }
430
+ lines.push("");
431
+ if (parsed.json) {
432
+ console.log(JSON.stringify({
433
+ ok: true,
434
+ repo: {
435
+ root: repoRoot,
436
+ origin: normalizedOrigin ?? origin ?? null,
437
+ fingerprint,
438
+ },
439
+ local: {
440
+ marker: repoMarker ?? null,
441
+ initState: initState ?? null,
442
+ initFingerprintMismatch,
443
+ inferredStatus: localStatus,
444
+ },
445
+ registry: {
446
+ socketPath: socketInfo.socketPath,
447
+ error: daemonError,
448
+ project: registryProject,
449
+ },
450
+ recommended: recommendsResume
451
+ ? "dvb init --resume"
452
+ : recommendsInit
453
+ ? "dvb init"
454
+ : recommendsForce
455
+ ? "dvb init --force"
456
+ : null,
457
+ }, null, 2));
458
+ return;
459
+ }
460
+ console.log(lines.join("\n"));
461
+ return;
462
+ }
156
463
  if (parsed.resume) {
157
464
  if (initFingerprintMismatch) {
158
465
  throw new Error("Init state does not match this repo. Remove .devbox/init-state.json and run `dvb init` again.");
@@ -203,6 +510,63 @@ export const runInit = async (args) => {
203
510
  };
204
511
  await writeInitState(repoRoot, initState);
205
512
  };
513
+ const recordCodexCheckpoint = async ({ client, canonical, phase, }) => {
514
+ const createdAt = new Date().toISOString();
515
+ const label = phase === "preCodexSetup" ? "pre-codex-setup" : "post-codex-setup";
516
+ const comment = `dvb init: ${label} repo=${repoName} fingerprint=${fingerprint} at=${createdAt}`;
517
+ const events = await client.createCheckpoint(canonical, { comment });
518
+ const id = extractCheckpointId(events);
519
+ if (!id) {
520
+ logger.warn("init_checkpoint_id_missing", {
521
+ box: canonical,
522
+ fingerprint,
523
+ phase: label,
524
+ });
525
+ return { id: null, comment, createdAt };
526
+ }
527
+ const record = { id, comment, createdAt };
528
+ if (initState) {
529
+ await updateInitState({
530
+ checkpoints: { ...(initState.checkpoints ?? {}), [phase]: record },
531
+ });
532
+ }
533
+ const metaPath = `/home/sprite/.devbox/projects/${fingerprint}.json`;
534
+ try {
535
+ let meta = {};
536
+ try {
537
+ const raw = await client.readFile(canonical, { path: metaPath });
538
+ const parsed = JSON.parse(Buffer.from(raw).toString("utf8"));
539
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
540
+ meta = parsed;
541
+ }
542
+ }
543
+ catch (error) {
544
+ if (!(error instanceof SpritesApiError && error.status === 404)) {
545
+ throw error;
546
+ }
547
+ }
548
+ const existingCheckpoints = meta.checkpoints && typeof meta.checkpoints === "object" && !Array.isArray(meta.checkpoints)
549
+ ? meta.checkpoints
550
+ : {};
551
+ const updated = {
552
+ ...meta,
553
+ checkpoints: {
554
+ ...existingCheckpoints,
555
+ [phase]: record,
556
+ },
557
+ };
558
+ await client.writeFile(canonical, metaPath, Buffer.from(JSON.stringify(updated, null, 2)));
559
+ }
560
+ catch (error) {
561
+ logger.warn("init_checkpoint_meta_update_failed", {
562
+ box: canonical,
563
+ fingerprint,
564
+ phase: label,
565
+ error: error instanceof Error ? error.message : String(error),
566
+ });
567
+ }
568
+ return { id, comment, createdAt };
569
+ };
206
570
  if (parsed.codexSetupOnly) {
207
571
  if (!repoMarker?.canonical) {
208
572
  throw new Error("Repo is not initialized. Run `dvb init` first.");
@@ -229,21 +593,32 @@ export const runInit = async (args) => {
229
593
  catch {
230
594
  // artifacts are optional in setup-only flow
231
595
  }
232
- status.stage("Starting dvbd");
233
596
  const socketInfo = resolveSocketInfo();
234
- await ensureDaemonRunning(socketInfo.socketPath);
235
- await requireDaemonFeatures(socketInfo.socketPath, ["ports"]);
236
- status.stage("Loading devbox config");
237
- const config = await loadConfig(process.env.HOME ? { homeDir: process.env.HOME } : undefined);
238
- const store = await createSecretStore(config?.tokenStore, process.env.HOME ? { homeDir: process.env.HOME } : undefined);
239
- const token = await store.getToken();
240
- if (!token) {
241
- throw new Error("Sprites token missing. Run `dvb setup` first.");
242
- }
243
- const apiBaseUrl = resolveSpritesApiUrl(config);
244
- const client = createSpritesClient({
245
- apiBaseUrl,
246
- token,
597
+ await runInitStep({
598
+ enabled: progressEnabled,
599
+ title: "Starting dvbd",
600
+ fn: async () => {
601
+ await ensureDaemonRunning(socketInfo.socketPath);
602
+ await requireDaemonFeatures(socketInfo.socketPath, ["ports"]);
603
+ },
604
+ });
605
+ const { config, client } = await runInitStep({
606
+ enabled: progressEnabled,
607
+ title: "Loading devbox config",
608
+ fn: async () => {
609
+ const config = await loadConfig(process.env.HOME ? { homeDir: process.env.HOME } : undefined);
610
+ const store = await createSecretStore(config?.tokenStore, process.env.HOME ? { homeDir: process.env.HOME } : undefined);
611
+ const token = await store.getToken();
612
+ if (!token) {
613
+ throw new Error("Sprites token missing. Run `dvb setup` first.");
614
+ }
615
+ const apiBaseUrl = resolveSpritesApiUrl(config);
616
+ const client = createSpritesClient({
617
+ apiBaseUrl,
618
+ token,
619
+ });
620
+ return { config, client };
621
+ },
247
622
  });
248
623
  const canonical = repoMarker.canonical;
249
624
  let expandedWorkdir = expandHome(`~/${slug}`);
@@ -270,34 +645,139 @@ export const runInit = async (args) => {
270
645
  catch {
271
646
  servicesPlan = null;
272
647
  }
273
- await uploadSetupPlan({
274
- client,
275
- canonical,
276
- localSetupPath: setupPath,
277
- remoteSetupPath,
278
- localArtifactsBundlePath: artifactsBundlePath,
279
- localArtifactsManifestPath: artifactsManifestPath,
280
- remoteArtifactsBundlePath: artifactsBundlePath
281
- ? remoteArtifactsBundlePath
282
- : null,
283
- remoteArtifactsManifestPath: artifactsBundlePath
284
- ? remoteArtifactsManifestPath
285
- : null,
286
- status,
648
+ await runInitStep({
649
+ enabled: progressEnabled,
650
+ title: "Configuring git safe.directory",
651
+ fn: async ({ status }) => {
652
+ await ensureGitSafeDirectory({
653
+ client,
654
+ canonical,
655
+ workdir: expandedWorkdir,
656
+ status,
657
+ });
658
+ },
659
+ });
660
+ await runInitStep({
661
+ enabled: progressEnabled,
662
+ title: "Uploading setup plan",
663
+ fn: async ({ status }) => {
664
+ await uploadSetupPlan({
665
+ client,
666
+ canonical,
667
+ localSetupPath: setupPath,
668
+ remoteSetupPath,
669
+ localArtifactsBundlePath: artifactsBundlePath,
670
+ localArtifactsManifestPath: artifactsManifestPath,
671
+ remoteArtifactsBundlePath: artifactsBundlePath
672
+ ? remoteArtifactsBundlePath
673
+ : null,
674
+ remoteArtifactsManifestPath: artifactsBundlePath
675
+ ? remoteArtifactsManifestPath
676
+ : null,
677
+ status,
678
+ });
679
+ },
287
680
  });
288
- await runRemoteCodexSetup({
289
- client,
290
- canonical,
291
- expandedWorkdir,
292
- remoteSetupPath,
293
- remoteArtifactsBundlePath,
294
- remoteArtifactsManifestPath,
295
- socketInfo,
296
- status,
297
- pathSetup,
298
- entrypoints: servicesPlan?.appEntrypoints ?? [],
681
+ await runInitStep({
682
+ enabled: progressEnabled,
683
+ title: "Staging setup artifacts",
684
+ fn: async ({ status }) => {
685
+ status.stage("Copying repo artifacts and staging external files");
686
+ await stageRemoteSetupArtifacts({
687
+ client,
688
+ canonical,
689
+ workdir: expandedWorkdir,
690
+ artifactsBundlePath: remoteArtifactsBundlePath,
691
+ artifactsManifestPath: remoteArtifactsManifestPath,
692
+ });
693
+ },
694
+ });
695
+ await runInitStep({
696
+ enabled: progressEnabled,
697
+ title: "Snapshotting filesystem (pre-setup) (optional)",
698
+ fn: async ({ fail, ok }) => {
699
+ try {
700
+ const checkpoint = await recordCodexCheckpoint({
701
+ client,
702
+ canonical,
703
+ phase: "preCodexSetup",
704
+ });
705
+ if (checkpoint.id) {
706
+ ok(`Snapshot created: ${checkpoint.id}`);
707
+ }
708
+ }
709
+ catch (error) {
710
+ logger.warn("init_checkpoint_create_failed", {
711
+ box: canonical,
712
+ fingerprint,
713
+ phase: "pre-codex-setup",
714
+ error: error instanceof Error ? error.message : String(error),
715
+ });
716
+ fail("Snapshotting filesystem (pre-setup) (optional) (failed)");
717
+ if (!parsed.json) {
718
+ console.warn("Warning: failed to create pre-setup filesystem snapshot. Continuing without it.");
719
+ }
720
+ }
721
+ },
722
+ });
723
+ await runInitStep({
724
+ enabled: progressEnabled,
725
+ title: "Enabling devbox services",
726
+ fn: async ({ status }) => {
727
+ await enableRemoteServices({
728
+ client,
729
+ canonical,
730
+ services: servicesPlan?.backgroundServices ?? [],
731
+ status,
732
+ });
733
+ },
734
+ });
735
+ await runInitStep({
736
+ enabled: progressEnabled,
737
+ title: "Applying setup plan",
738
+ fn: async ({ status }) => {
739
+ await runRemoteCodexSetup({
740
+ client,
741
+ canonical,
742
+ expandedWorkdir,
743
+ remoteSetupPath,
744
+ remoteArtifactsBundlePath,
745
+ remoteArtifactsManifestPath,
746
+ socketInfo,
747
+ status,
748
+ pathSetup,
749
+ entrypoints: servicesPlan?.appEntrypoints ?? [],
750
+ });
751
+ },
752
+ });
753
+ await runInitStep({
754
+ enabled: progressEnabled,
755
+ title: "Snapshotting filesystem (post-setup) (optional)",
756
+ fn: async ({ fail, ok }) => {
757
+ try {
758
+ const checkpoint = await recordCodexCheckpoint({
759
+ client,
760
+ canonical,
761
+ phase: "postCodexSetup",
762
+ });
763
+ if (checkpoint.id) {
764
+ ok(`Snapshot created: ${checkpoint.id}`);
765
+ }
766
+ }
767
+ catch (error) {
768
+ logger.warn("init_checkpoint_create_failed", {
769
+ box: canonical,
770
+ fingerprint,
771
+ phase: "post-codex-setup",
772
+ error: error instanceof Error ? error.message : String(error),
773
+ });
774
+ fail("Snapshotting filesystem (post-setup) (optional) (failed)");
775
+ if (!parsed.json) {
776
+ console.warn("Warning: failed to create post-setup filesystem snapshot. Continuing without it.");
777
+ }
778
+ }
779
+ },
299
780
  });
300
- status.stop();
301
781
  if (parsed.json) {
302
782
  console.log(JSON.stringify({ ok: true }, null, 2));
303
783
  return;
@@ -305,13 +785,23 @@ export const runInit = async (args) => {
305
785
  console.log("Codex setup complete.");
306
786
  return;
307
787
  }
308
- status.stage("Starting dvbd");
309
788
  const socketInfo = resolveSocketInfo();
310
- await ensureDaemonRunning(socketInfo.socketPath);
311
- await requireDaemonFeatures(socketInfo.socketPath, ["registry"]);
312
- status.stage("Checking sprites");
313
- const existingProject = await requestJson(socketInfo.socketPath, "GET", `/registry/project?fingerprint=${encodeURIComponent(fingerprint)}`, DAEMON_TIMEOUT_MS.registry);
314
- const existingEntry = existingProject.body.project ?? null;
789
+ await runInitStep({
790
+ enabled: progressEnabled,
791
+ title: "Starting dvbd",
792
+ fn: async () => {
793
+ await ensureDaemonRunning(socketInfo.socketPath);
794
+ await requireDaemonFeatures(socketInfo.socketPath, ["registry"]);
795
+ },
796
+ });
797
+ const existingEntry = await runInitStep({
798
+ enabled: progressEnabled,
799
+ title: "Checking sprites",
800
+ fn: async () => {
801
+ const existingProject = await requestJson(socketInfo.socketPath, "GET", `/registry/project?fingerprint=${encodeURIComponent(fingerprint)}`, DAEMON_TIMEOUT_MS.registry);
802
+ return existingProject.body.project ?? null;
803
+ },
804
+ });
315
805
  if (existingEntry && !parsed.force) {
316
806
  if (shouldResume) {
317
807
  if (initState?.canonical &&
@@ -335,14 +825,20 @@ export const runInit = async (args) => {
335
825
  const remoteArtifactsBundlePath = path.posix.join(expandedWorkdir, ".devbox", "setup-artifacts.tgz");
336
826
  const remoteArtifactsManifestPath = path.posix.join(expandedWorkdir, ".devbox", "setup-artifacts.json");
337
827
  const pathSetup = 'export PATH="$(npm bin -g 2>/dev/null):$PATH"';
338
- status.stage("Loading devbox config");
339
- const config = await loadConfig(process.env.HOME ? { homeDir: process.env.HOME } : undefined);
340
- const store = await createSecretStore(config?.tokenStore, process.env.HOME ? { homeDir: process.env.HOME } : undefined);
341
- const { token, controlPlaneToken } = await ensureSpritesToken(store, (message) => status.stage(message));
342
- const apiBaseUrl = resolveSpritesApiUrl(config);
343
- const client = createSpritesClient({
344
- apiBaseUrl,
345
- token,
828
+ const { config, client, controlPlaneToken } = await runInitStep({
829
+ enabled: progressEnabled,
830
+ title: "Loading devbox config",
831
+ fn: async ({ status }) => {
832
+ const config = await loadConfig(process.env.HOME ? { homeDir: process.env.HOME } : undefined);
833
+ const store = await createSecretStore(config?.tokenStore, process.env.HOME ? { homeDir: process.env.HOME } : undefined);
834
+ const { token, controlPlaneToken } = await ensureSpritesToken(store, (message) => status.stage(message));
835
+ const apiBaseUrl = resolveSpritesApiUrl(config);
836
+ const client = createSpritesClient({
837
+ apiBaseUrl,
838
+ token,
839
+ });
840
+ return { config, client, controlPlaneToken };
841
+ },
346
842
  });
347
843
  const username = os.userInfo().username;
348
844
  let canonical = (shouldResume && initState?.canonical ? initState.canonical : null) ??
@@ -368,25 +864,32 @@ export const runInit = async (args) => {
368
864
  };
369
865
  const skipCreate = shouldResume && initState?.steps.spritesCreated && initState.canonical;
370
866
  if (!skipCreate) {
371
- status.stage("Creating devbox");
372
- const createResult = await createSprite(canonical);
373
- if (createResult === "exists" && !parsed.force) {
374
- if (canonicalHint) {
375
- throw new Error(`Sprite already exists: ${canonical}`);
376
- }
377
- const suffix = fingerprint.slice(0, 6);
378
- canonical = `${canonical}-${suffix}`;
379
- status.stage("Resolving devbox name");
380
- const second = await createSprite(canonical);
381
- if (second === "exists") {
382
- throw new Error(`Sprite already exists: ${canonical}`);
383
- }
384
- }
385
- await updateInitState({
386
- canonical,
387
- alias,
388
- workdir,
389
- steps: { spritesCreated: true },
867
+ canonical = await runInitStep({
868
+ enabled: progressEnabled,
869
+ title: "Creating devbox",
870
+ fn: async ({ status }) => {
871
+ let nextCanonical = canonical;
872
+ const createResult = await createSprite(nextCanonical);
873
+ if (createResult === "exists" && !parsed.force) {
874
+ if (canonicalHint) {
875
+ throw new Error(`Sprite already exists: ${nextCanonical}`);
876
+ }
877
+ const suffix = fingerprint.slice(0, 6);
878
+ nextCanonical = `${nextCanonical}-${suffix}`;
879
+ status.stage("Resolving devbox name");
880
+ const second = await createSprite(nextCanonical);
881
+ if (second === "exists") {
882
+ throw new Error(`Sprite already exists: ${nextCanonical}`);
883
+ }
884
+ }
885
+ await updateInitState({
886
+ canonical: nextCanonical,
887
+ alias,
888
+ workdir,
889
+ steps: { spritesCreated: true },
890
+ });
891
+ return nextCanonical;
892
+ },
390
893
  });
391
894
  }
392
895
  const aliasLookup = await requestJson(socketInfo.socketPath, "GET", `/registry/alias?alias=${encodeURIComponent(alias)}`, DAEMON_TIMEOUT_MS.registry);
@@ -422,109 +925,134 @@ export const runInit = async (args) => {
422
925
  });
423
926
  }
424
927
  };
425
- status.stage("Bootstrapping devbox");
426
- try {
427
- await bootstrapDevbox(client, canonical);
428
- }
429
- catch (error) {
430
- logger.warn("devbox_bootstrap_failed", {
431
- box: canonical,
432
- error: String(error),
433
- });
434
- if (!parsed.json) {
435
- status.stop();
436
- const message = error instanceof Error && error.message
437
- ? error.message
438
- : String(error);
439
- console.warn(`Warning: devbox bootstrap failed. ${message}`);
440
- }
441
- }
928
+ await runInitStep({
929
+ enabled: progressEnabled,
930
+ title: "Bootstrapping devbox",
931
+ fn: async ({ fail }) => {
932
+ try {
933
+ await bootstrapDevbox(client, canonical);
934
+ }
935
+ catch (error) {
936
+ logger.warn("devbox_bootstrap_failed", {
937
+ box: canonical,
938
+ error: String(error),
939
+ });
940
+ fail("Bootstrapping devbox (failed)");
941
+ if (!parsed.json) {
942
+ const message = error instanceof Error && error.message
943
+ ? error.message
944
+ : String(error);
945
+ console.warn(`Warning: devbox bootstrap failed. ${message}`);
946
+ }
947
+ }
948
+ },
949
+ });
442
950
  const initialStatus = resolveInitStatus(initState?.steps, initState?.complete);
443
951
  if (!shouldResume || !initState?.steps.registrySynced) {
444
- status.stage("Syncing sprites");
445
- await requestJson(socketInfo.socketPath, "POST", "/registry/upsert", DAEMON_TIMEOUT_MS.registry, {
446
- project: buildProjectEntry(initialStatus),
447
- box: {
448
- canonical,
449
- org: config?.org,
450
- createdAt: new Date().toISOString(),
952
+ await runInitStep({
953
+ enabled: progressEnabled,
954
+ title: "Syncing sprites",
955
+ fn: async () => {
956
+ await requestJson(socketInfo.socketPath, "POST", "/registry/upsert", DAEMON_TIMEOUT_MS.registry, {
957
+ project: buildProjectEntry(initialStatus),
958
+ box: {
959
+ canonical,
960
+ org: config?.org,
961
+ createdAt: new Date().toISOString(),
962
+ },
963
+ alias: { alias, canonical },
964
+ });
965
+ await updateInitState({
966
+ canonical,
967
+ alias,
968
+ workdir,
969
+ steps: { registrySynced: true },
970
+ });
451
971
  },
452
- alias: { alias, canonical },
453
- });
454
- await updateInitState({
455
- canonical,
456
- alias,
457
- workdir,
458
- steps: { registrySynced: true },
459
972
  });
460
973
  }
461
974
  if (!shouldResume || !initState?.steps.daemonInstalled) {
462
- status.stage("Installing sprite daemon");
463
- try {
464
- const convexUrl = getConvexUrl();
465
- if (!controlPlaneToken) {
466
- throw new Error("Control plane session required to install daemon.");
467
- }
468
- if (!convexUrl) {
469
- throw new Error("Convex URL unavailable.");
470
- }
471
- const release = await fetchSpriteDaemonRelease(controlPlaneToken);
472
- if (!release) {
473
- throw new Error("No sprite daemon release available.");
474
- }
475
- const heartbeatToken = await issueSpriteDaemonToken(controlPlaneToken, canonical);
476
- if (!heartbeatToken) {
477
- throw new Error("Daemon token unavailable.");
478
- }
479
- await installSpriteDaemon({
480
- client,
481
- spriteName: canonical,
482
- release,
483
- convexUrl,
484
- heartbeatToken,
485
- });
486
- await updateInitState({ steps: { daemonInstalled: true } });
487
- }
488
- catch (error) {
489
- logger.warn("sprite_daemon_install_failed", {
490
- box: canonical,
491
- error: String(error),
492
- });
493
- if (!parsed.json) {
494
- status.stop();
495
- const message = error instanceof Error && error.message
496
- ? error.message
497
- : String(error);
498
- console.warn(`Warning: failed to install sprite daemon. ${message}`);
499
- }
500
- }
501
- }
502
- status.stage("Ensuring sprite daemon service");
503
- try {
504
- await ensureSpriteDaemonService({ client, spriteName: canonical });
505
- await updateInitState({ steps: { daemonServiceEnsured: true } });
506
- }
507
- catch (error) {
508
- logger.warn("sprite_daemon_service_failed", {
509
- box: canonical,
510
- error: String(error),
975
+ await runInitStep({
976
+ enabled: progressEnabled,
977
+ title: "Installing sprite daemon",
978
+ fn: async ({ fail }) => {
979
+ try {
980
+ const convexUrl = getConvexUrl();
981
+ if (!controlPlaneToken) {
982
+ throw new Error("Control plane session required to install daemon.");
983
+ }
984
+ if (!convexUrl) {
985
+ throw new Error("Convex URL unavailable.");
986
+ }
987
+ const release = await fetchSpriteDaemonRelease(controlPlaneToken);
988
+ if (!release) {
989
+ throw new Error("No sprite daemon release available.");
990
+ }
991
+ const heartbeatToken = await issueSpriteDaemonToken(controlPlaneToken, canonical);
992
+ if (!heartbeatToken) {
993
+ throw new Error("Daemon token unavailable.");
994
+ }
995
+ await installSpriteDaemon({
996
+ client,
997
+ spriteName: canonical,
998
+ release,
999
+ convexUrl,
1000
+ heartbeatToken,
1001
+ });
1002
+ await updateInitState({ steps: { daemonInstalled: true } });
1003
+ }
1004
+ catch (error) {
1005
+ logger.warn("sprite_daemon_install_failed", {
1006
+ box: canonical,
1007
+ error: String(error),
1008
+ });
1009
+ fail("Installing sprite daemon (failed)");
1010
+ if (!parsed.json) {
1011
+ const message = error instanceof Error && error.message
1012
+ ? error.message
1013
+ : String(error);
1014
+ console.warn(`Warning: failed to install sprite daemon. ${message}`);
1015
+ }
1016
+ }
1017
+ },
511
1018
  });
512
- if (!parsed.json) {
513
- status.stop();
514
- const message = error instanceof Error && error.message
515
- ? error.message
516
- : String(error);
517
- console.warn(`Warning: failed to ensure sprite daemon service. ${message}`);
518
- }
519
1019
  }
1020
+ await runInitStep({
1021
+ enabled: progressEnabled,
1022
+ title: "Ensuring sprite daemon service",
1023
+ fn: async ({ fail }) => {
1024
+ try {
1025
+ await ensureSpriteDaemonService({ client, spriteName: canonical });
1026
+ await updateInitState({ steps: { daemonServiceEnsured: true } });
1027
+ }
1028
+ catch (error) {
1029
+ logger.warn("sprite_daemon_service_failed", {
1030
+ box: canonical,
1031
+ error: String(error),
1032
+ });
1033
+ fail("Ensuring sprite daemon service (failed)");
1034
+ if (!parsed.json) {
1035
+ const message = error instanceof Error && error.message
1036
+ ? error.message
1037
+ : String(error);
1038
+ console.warn(`Warning: failed to ensure sprite daemon service. ${message}`);
1039
+ }
1040
+ }
1041
+ },
1042
+ });
520
1043
  const setupDir = path.join(repoRoot, ".devbox");
521
1044
  const setupPath = path.join(setupDir, "setup.json");
522
1045
  const servicesPath = path.join(setupDir, "services.json");
1046
+ const scansDir = path.join(setupDir, "scans");
1047
+ const setupEnvSecretsScanPath = path.join(scansDir, "setup-env-secrets.json");
1048
+ const setupExternalScanPath = path.join(scansDir, "setup-external.json");
1049
+ const setupExtraArtifactsScanPath = path.join(scansDir, "setup-extra-artifacts.json");
523
1050
  let setupArtifacts = null;
524
1051
  const nonInteractive = !process.stdin.isTTY || parsed.json;
525
1052
  const skipSetupPlan = shouldResume && initState?.steps.setupPlanWritten;
526
1053
  const skipServicesPlan = shouldResume && initState?.steps.servicesPlanWritten;
527
1054
  const skipServicesConfig = shouldResume && initState?.steps.servicesConfigWritten;
1055
+ const skipServicesEnable = shouldResume && initState?.steps.servicesEnabled;
528
1056
  const skipSetupUpload = nonInteractive || (shouldResume && initState?.steps.setupUploaded);
529
1057
  const skipCodexApply = nonInteractive || (shouldResume && initState?.steps.codexApplied);
530
1058
  let approvedPlan = null;
@@ -532,334 +1060,567 @@ export const runInit = async (args) => {
532
1060
  const setupTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "devbox-setup-"));
533
1061
  try {
534
1062
  await fs.mkdir(setupDir, { recursive: true });
535
- if (!skipSetupPlan || !skipServicesPlan) {
536
- let setupSchemaPath = null;
537
- let servicesSchemaPath = null;
538
- if (!skipSetupPlan) {
539
- setupSchemaPath = await writeSetupSchema(setupTempDir);
1063
+ const tryReadSetupPlan = async () => {
1064
+ try {
1065
+ return await readSetupPlan(setupPath);
540
1066
  }
541
- if (!skipServicesPlan) {
542
- servicesSchemaPath = await writeServicesSchema(setupTempDir);
1067
+ catch {
1068
+ return null;
543
1069
  }
544
- if (!skipSetupPlan && !setupSchemaPath) {
545
- throw new Error("Setup schema path missing.");
1070
+ };
1071
+ const tryReadServicesPlan = async () => {
1072
+ try {
1073
+ return await readServicesPlan(servicesPath);
546
1074
  }
547
- if (!skipServicesPlan && !servicesSchemaPath) {
548
- throw new Error("Services schema path missing.");
1075
+ catch {
1076
+ return null;
549
1077
  }
550
- status.stage("Analyzing local environment");
551
- await Promise.all([
552
- skipSetupPlan
553
- ? Promise.resolve()
554
- : runLocalSetupScan({
555
- cwd: repoRoot,
556
- schemaPath: setupSchemaPath,
557
- outputPath: setupPath,
558
- homeDir: localHomeDir,
559
- onProgress: (message) => {
560
- const base = "Analyzing local environment — setup";
561
- status.stage(`${base} — ${message}`);
562
- },
563
- }),
564
- skipServicesPlan
565
- ? Promise.resolve()
566
- : runLocalServicesScan({
567
- cwd: repoRoot,
568
- schemaPath: servicesSchemaPath,
569
- outputPath: servicesPath,
570
- homeDir: localHomeDir,
571
- onProgress: (message) => {
572
- const base = "Analyzing local environment — services";
573
- status.stage(`${base} — ${message}`);
574
- },
575
- }),
576
- ]);
577
- }
578
- if (!skipSetupPlan) {
579
- const setupPlan = await readSetupPlan(setupPath);
580
- if (nonInteractive) {
581
- approvedPlan = setupPlan;
1078
+ };
1079
+ const tryReadEnvSecretsScan = async () => {
1080
+ try {
1081
+ return await readSetupEnvSecretsPlan(setupEnvSecretsScanPath);
582
1082
  }
583
- else {
584
- if (!process.stdin.isTTY || parsed.json) {
585
- throw new Error("Interactive terminal required to approve setup.");
586
- }
587
- status.stage("Reviewing setup findings");
588
- status.stop();
589
- approvedPlan = await promptForPlanApproval(setupPlan);
1083
+ catch {
1084
+ return null;
590
1085
  }
591
- await writeSetupPlan(setupPath, approvedPlan);
592
- await updateInitState({ steps: { setupPlanWritten: true } });
593
- }
594
- else {
595
- approvedPlan = await readSetupPlan(setupPath);
596
- }
597
- if (!skipServicesPlan) {
598
- const servicesPlan = await readServicesPlan(servicesPath);
599
- if (nonInteractive) {
600
- approvedServices = servicesPlan;
1086
+ };
1087
+ const tryReadExternalScan = async () => {
1088
+ try {
1089
+ return await readSetupExternalPlan(setupExternalScanPath);
601
1090
  }
602
- else {
603
- if (!process.stdin.isTTY || parsed.json) {
604
- throw new Error("Interactive terminal required to approve services.");
605
- }
606
- status.stage("Reviewing run services findings");
607
- status.stop();
608
- approvedServices = await promptForServicesApproval(servicesPlan);
1091
+ catch {
1092
+ return null;
609
1093
  }
610
- await writeServicesPlan(servicesPath, approvedServices);
611
- await updateInitState({ steps: { servicesPlanWritten: true } });
1094
+ };
1095
+ const tryReadExtraArtifactsScan = async () => {
1096
+ try {
1097
+ return await readSetupExtraArtifactsPlan(setupExtraArtifactsScanPath);
1098
+ }
1099
+ catch {
1100
+ return null;
1101
+ }
1102
+ };
1103
+ const shouldRetryCodexScan = (scanFullyCompleted, ...arrays) => !scanFullyCompleted && arrays.every((array) => array.length === 0);
1104
+ let setupPlan = skipSetupPlan || !shouldResume ? null : await tryReadSetupPlan();
1105
+ let servicesPlan = skipServicesPlan || !shouldResume ? null : await tryReadServicesPlan();
1106
+ const needsSetupScan = !skipSetupPlan && !setupPlan;
1107
+ let envSecretsScan = !needsSetupScan || !shouldResume ? null : await tryReadEnvSecretsScan();
1108
+ let externalScan = !needsSetupScan || !shouldResume ? null : await tryReadExternalScan();
1109
+ let extraArtifactsScan = !needsSetupScan || !shouldResume
1110
+ ? null
1111
+ : await tryReadExtraArtifactsScan();
1112
+ if (servicesPlan &&
1113
+ shouldRetryCodexScan(servicesPlan.scanFullyCompleted, servicesPlan.appEntrypoints, servicesPlan.backgroundServices)) {
1114
+ servicesPlan = null;
612
1115
  }
613
- else {
614
- approvedServices = await readServicesPlan(servicesPath);
1116
+ if (envSecretsScan &&
1117
+ shouldRetryCodexScan(envSecretsScan.scanFullyCompleted, envSecretsScan.envFiles, envSecretsScan.secretFiles)) {
1118
+ envSecretsScan = null;
615
1119
  }
616
- if (!skipSetupUpload) {
617
- status.stage("Packaging setup artifacts");
618
- setupArtifacts = await createSetupArtifacts({
619
- repoRoot,
620
- plan: approvedPlan,
621
- outputDir: setupDir,
622
- tempDir: setupTempDir,
623
- homeDir: localHomeDir,
624
- });
1120
+ if (externalScan &&
1121
+ shouldRetryCodexScan(externalScan.scanFullyCompleted, externalScan.externalDependencies, externalScan.externalConfigs)) {
1122
+ externalScan = null;
625
1123
  }
626
- }
627
- finally {
628
- await fs.rm(setupTempDir, { recursive: true, force: true });
629
- }
630
- const skipProvision = shouldResume && initState?.steps.workdirProvisioned;
631
- if (!skipProvision || !skipSetupUpload) {
632
- if (!skipProvision) {
633
- status.stage("Packaging repo");
1124
+ if (extraArtifactsScan &&
1125
+ shouldRetryCodexScan(extraArtifactsScan.scanFullyCompleted, extraArtifactsScan.extraArtifacts)) {
1126
+ extraArtifactsScan = null;
634
1127
  }
635
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "devbox-init-"));
636
- const bundlePath = path.join(tempDir, "repo.bundle");
637
- const archivePath = path.join(tempDir, "repo.tgz");
638
- const gitMetaPath = path.join(tempDir, "git-meta.tgz");
639
- const gitMetaListPath = path.join(tempDir, "git-meta.list");
640
- const stagedPatchPath = path.join(tempDir, "staged.patch");
641
- const unstagedPatchPath = path.join(tempDir, "unstaged.patch");
642
- const untrackedPath = path.join(tempDir, "untracked.tgz");
643
- const untrackedListPath = path.join(tempDir, "untracked.list");
644
- const globalGitConfigSources = await readGlobalGitConfigFiles(repoRoot);
645
- const globalGitConfigMappings = mapGlobalGitConfigDestinations(globalGitConfigSources, localHomeDir);
646
- try {
647
- const remoteBundlePath = "/home/sprite/.devbox/upload.bundle";
648
- const remoteArchivePath = "/home/sprite/.devbox/upload.tgz";
649
- const remoteGitMetaPath = "/home/sprite/.devbox/git-meta.tgz";
650
- const remoteStagedPatchPath = "/home/sprite/.devbox/staged.patch";
651
- const remoteUnstagedPatchPath = "/home/sprite/.devbox/unstaged.patch";
652
- const remoteUntrackedPath = "/home/sprite/.devbox/untracked.tgz";
653
- status.stage("Checking remote git");
654
- const gitCheck = await client.exec(canonical, [
655
- "/bin/bash",
656
- "-lc",
657
- "git --version",
658
- ]);
659
- const remoteHasGit = gitCheck.exitCode === 0;
660
- const remoteDirs = new Set();
661
- remoteDirs.add(path.posix.dirname(remoteBundlePath));
662
- remoteDirs.add(path.posix.dirname(remoteArchivePath));
663
- remoteDirs.add(path.posix.dirname(remoteGitMetaPath));
664
- remoteDirs.add(path.posix.dirname(remoteStagedPatchPath));
665
- remoteDirs.add(path.posix.dirname(remoteUnstagedPatchPath));
666
- remoteDirs.add(path.posix.dirname(remoteUntrackedPath));
667
- for (const mapping of globalGitConfigMappings) {
668
- remoteDirs.add(path.posix.dirname(mapping.dest));
669
- }
670
- if (remoteDirs.size > 0) {
671
- status.stage("Preparing remote directories");
672
- const prepResult = await client.exec(canonical, [
673
- "/bin/bash",
674
- "-lc",
675
- `mkdir -p ${[...remoteDirs].map(shellQuote).join(" ")}`,
1128
+ const needsServicesScan = !skipServicesPlan && !servicesPlan;
1129
+ const needsEnvSecretsScan = needsSetupScan && !envSecretsScan;
1130
+ const needsExternalScan = needsSetupScan && !externalScan;
1131
+ const needsExtraArtifactsScan = needsSetupScan && !extraArtifactsScan;
1132
+ if (needsSetupScan || needsServicesScan) {
1133
+ const runLocalEnvironmentAnalysis = async ({ updateEnvSecrets, updateExternal, updateExtraArtifacts, updateServices, }) => {
1134
+ let envSecretsSchemaPath = null;
1135
+ let externalSchemaPath = null;
1136
+ let extraArtifactsSchemaPath = null;
1137
+ let servicesSchemaPath = null;
1138
+ if (needsEnvSecretsScan) {
1139
+ envSecretsSchemaPath =
1140
+ await writeSetupEnvSecretsSchema(setupTempDir);
1141
+ }
1142
+ if (needsExternalScan) {
1143
+ externalSchemaPath = await writeSetupExternalSchema(setupTempDir);
1144
+ }
1145
+ if (needsExtraArtifactsScan) {
1146
+ extraArtifactsSchemaPath =
1147
+ await writeSetupExtraArtifactsSchema(setupTempDir);
1148
+ }
1149
+ if (needsServicesScan) {
1150
+ servicesSchemaPath = await writeServicesSchema(setupTempDir);
1151
+ }
1152
+ if (needsEnvSecretsScan && !envSecretsSchemaPath) {
1153
+ throw new Error("Env/secrets schema path missing.");
1154
+ }
1155
+ if (needsExternalScan && !externalSchemaPath) {
1156
+ throw new Error("External schema path missing.");
1157
+ }
1158
+ if (needsExtraArtifactsScan && !extraArtifactsSchemaPath) {
1159
+ throw new Error("Extra artifacts schema path missing.");
1160
+ }
1161
+ if (needsServicesScan && !servicesSchemaPath) {
1162
+ throw new Error("Services schema path missing.");
1163
+ }
1164
+ if (needsSetupScan) {
1165
+ await fs.mkdir(scansDir, { recursive: true });
1166
+ }
1167
+ const runCodexScanWithImmediateRetry = async ({ run, read, outputPath, update, shouldRetry, }) => {
1168
+ try {
1169
+ await run();
1170
+ let out = await read();
1171
+ if (shouldRetry(out)) {
1172
+ update("retrying");
1173
+ await fs.rm(outputPath, { force: true });
1174
+ await run();
1175
+ out = await read();
1176
+ }
1177
+ update("done");
1178
+ return out;
1179
+ }
1180
+ catch (error) {
1181
+ update("failed");
1182
+ throw error;
1183
+ }
1184
+ };
1185
+ const envSecretsPromise = !needsSetupScan
1186
+ ? Promise.resolve(null)
1187
+ : !needsEnvSecretsScan
1188
+ ? Promise.resolve(envSecretsScan)
1189
+ : runCodexScanWithImmediateRetry({
1190
+ run: async () => await runLocalSetupEnvSecretsScan({
1191
+ cwd: repoRoot,
1192
+ schemaPath: envSecretsSchemaPath,
1193
+ outputPath: setupEnvSecretsScanPath,
1194
+ onProgress: updateEnvSecrets,
1195
+ }),
1196
+ read: async () => await readSetupEnvSecretsPlan(setupEnvSecretsScanPath),
1197
+ outputPath: setupEnvSecretsScanPath,
1198
+ update: updateEnvSecrets,
1199
+ shouldRetry: (plan) => shouldRetryCodexScan(plan.scanFullyCompleted, plan.envFiles, plan.secretFiles),
1200
+ });
1201
+ const externalPromise = !needsSetupScan
1202
+ ? Promise.resolve(null)
1203
+ : !needsExternalScan
1204
+ ? Promise.resolve(externalScan)
1205
+ : runCodexScanWithImmediateRetry({
1206
+ run: async () => await runLocalSetupExternalScan({
1207
+ cwd: repoRoot,
1208
+ schemaPath: externalSchemaPath,
1209
+ outputPath: setupExternalScanPath,
1210
+ homeDir: localHomeDir,
1211
+ onProgress: updateExternal,
1212
+ }),
1213
+ read: async () => await readSetupExternalPlan(setupExternalScanPath),
1214
+ outputPath: setupExternalScanPath,
1215
+ update: updateExternal,
1216
+ shouldRetry: (plan) => shouldRetryCodexScan(plan.scanFullyCompleted, plan.externalDependencies, plan.externalConfigs),
1217
+ });
1218
+ const extraArtifactsPromise = !needsSetupScan
1219
+ ? Promise.resolve(null)
1220
+ : !needsExtraArtifactsScan
1221
+ ? Promise.resolve(extraArtifactsScan)
1222
+ : runCodexScanWithImmediateRetry({
1223
+ run: async () => await runLocalSetupExtraArtifactsScan({
1224
+ cwd: repoRoot,
1225
+ schemaPath: extraArtifactsSchemaPath,
1226
+ outputPath: setupExtraArtifactsScanPath,
1227
+ onProgress: updateExtraArtifacts,
1228
+ }),
1229
+ read: async () => await readSetupExtraArtifactsPlan(setupExtraArtifactsScanPath),
1230
+ outputPath: setupExtraArtifactsScanPath,
1231
+ update: updateExtraArtifacts,
1232
+ shouldRetry: (plan) => shouldRetryCodexScan(plan.scanFullyCompleted, plan.extraArtifacts),
1233
+ });
1234
+ const servicesPromise = !needsServicesScan
1235
+ ? Promise.resolve(servicesPlan)
1236
+ : runCodexScanWithImmediateRetry({
1237
+ run: async () => await runLocalServicesScan({
1238
+ cwd: repoRoot,
1239
+ schemaPath: servicesSchemaPath,
1240
+ outputPath: servicesPath,
1241
+ homeDir: localHomeDir,
1242
+ onProgress: updateServices,
1243
+ }),
1244
+ read: async () => await readServicesPlan(servicesPath),
1245
+ outputPath: servicesPath,
1246
+ update: updateServices,
1247
+ shouldRetry: (plan) => shouldRetryCodexScan(plan.scanFullyCompleted, plan.appEntrypoints, plan.backgroundServices),
1248
+ });
1249
+ const [envSecrets, external, extraArtifacts, services] = await Promise.all([
1250
+ envSecretsPromise,
1251
+ externalPromise,
1252
+ extraArtifactsPromise,
1253
+ servicesPromise,
676
1254
  ]);
677
- if (prepResult.exitCode !== 0) {
678
- throw new Error(prepResult.stderr || "Failed to prepare remote dirs");
1255
+ if (needsServicesScan) {
1256
+ if (!services) {
1257
+ throw new Error("Services plan missing.");
1258
+ }
1259
+ servicesPlan = services;
1260
+ }
1261
+ if (needsSetupScan) {
1262
+ if (!envSecrets) {
1263
+ throw new Error("Env/secrets scan missing.");
1264
+ }
1265
+ if (!external) {
1266
+ throw new Error("External scan missing.");
1267
+ }
1268
+ if (!extraArtifacts) {
1269
+ throw new Error("Extra artifacts scan missing.");
1270
+ }
1271
+ updateEnvSecrets("merging setup plan");
1272
+ const merged = mergeSetupScans({
1273
+ envSecrets,
1274
+ external,
1275
+ extraArtifacts,
1276
+ });
1277
+ await writeSetupPlan(setupPath, merged);
1278
+ setupPlan = merged;
679
1279
  }
1280
+ };
1281
+ if (!progressEnabled) {
1282
+ await runLocalEnvironmentAnalysis({
1283
+ updateEnvSecrets: () => { },
1284
+ updateExternal: () => { },
1285
+ updateExtraArtifacts: () => { },
1286
+ updateServices: () => { },
1287
+ });
680
1288
  }
681
- let headState = null;
682
- let gitCommonDir = "";
683
- let worktreeState = null;
684
- let copyWorktree = false;
685
- if (!skipProvision) {
686
- status.stage("Inspecting git state");
687
- headState = await readHeadState(repoRoot);
688
- const resolved = await resolveGitCommonDir(repoRoot);
689
- gitCommonDir = resolved.commonDir;
690
- worktreeState = await readWorktreeState(repoRoot);
691
- const hasWorktreeChanges = worktreeState.staged.length > 0 ||
692
- worktreeState.unstaged.length > 0 ||
693
- worktreeState.untracked.length > 0;
694
- if (hasWorktreeChanges && process.stdin.isTTY && !parsed.json) {
695
- status.stop();
696
- copyWorktree = await confirmCopyWorktree(worktreeState);
697
- }
698
- else if (hasWorktreeChanges) {
699
- copyWorktree = true;
1289
+ else {
1290
+ const log = clackTaskLog({
1291
+ title: "Analyzing local environment",
1292
+ limit: 1,
1293
+ spacing: 0,
1294
+ });
1295
+ let active = true;
1296
+ const colorCategory = (label) => {
1297
+ if (!process.stdout.hasColors?.())
1298
+ return label;
1299
+ const undim = "\u001b[22m";
1300
+ const dim = "\u001b[2m";
1301
+ const bold = "\u001b[1m";
1302
+ const teal = "\u001b[36m";
1303
+ const resetColor = "\u001b[39m";
1304
+ return `${undim}${teal}${bold}${label}${resetColor}${undim}${dim}`;
1305
+ };
1306
+ const formatRow = (label, message) => {
1307
+ const normalized = message.replace(/\r?\n/g, " ").trim();
1308
+ return `${colorCategory(label)}: ${normalized}`;
1309
+ };
1310
+ const envSecretsRow = log.group("");
1311
+ const externalRow = log.group("");
1312
+ const extraArtifactsRow = log.group("");
1313
+ const servicesRow = log.group("");
1314
+ const makeUpdater = (row, label) => (message) => {
1315
+ if (!active)
1316
+ return;
1317
+ row.message(formatRow(label, message));
1318
+ };
1319
+ const updateEnvSecrets = makeUpdater(envSecretsRow, "env/secrets");
1320
+ const updateExternal = makeUpdater(externalRow, "external");
1321
+ const updateExtraArtifacts = makeUpdater(extraArtifactsRow, "extra artifacts");
1322
+ const updateServices = makeUpdater(servicesRow, "services");
1323
+ updateEnvSecrets(needsSetupScan ? (needsEnvSecretsScan ? "starting" : "cached") : "skipped");
1324
+ updateExternal(needsSetupScan ? (needsExternalScan ? "starting" : "cached") : "skipped");
1325
+ updateExtraArtifacts(needsSetupScan
1326
+ ? needsExtraArtifactsScan
1327
+ ? "starting"
1328
+ : "cached"
1329
+ : "skipped");
1330
+ updateServices(skipServicesPlan ? "skipped" : needsServicesScan ? "starting" : "cached");
1331
+ try {
1332
+ await runLocalEnvironmentAnalysis({
1333
+ updateEnvSecrets,
1334
+ updateExternal,
1335
+ updateExtraArtifacts,
1336
+ updateServices,
1337
+ });
1338
+ active = false;
1339
+ log.success("Analyzing local environment");
1340
+ }
1341
+ catch (error) {
1342
+ active = false;
1343
+ log.error("Analyzing local environment");
1344
+ throw error;
700
1345
  }
701
1346
  }
702
- let gitMetaCreated = false;
703
- let stagedPatchCreated = false;
704
- let unstagedPatchCreated = false;
705
- let untrackedCreated = false;
706
- if (remoteHasGit) {
707
- if (!skipProvision) {
708
- status.stage("Packaging repo bundle");
709
- await runCommand(repoRoot, "git", [
710
- "bundle",
711
- "create",
712
- bundlePath,
713
- "--all",
714
- ]);
1347
+ }
1348
+ if (!skipSetupPlan && !setupPlan) {
1349
+ setupPlan = await readSetupPlan(setupPath);
1350
+ }
1351
+ if (!skipServicesPlan && !servicesPlan) {
1352
+ servicesPlan = await readServicesPlan(servicesPath);
1353
+ }
1354
+ if (!skipSetupPlan && !setupPlan) {
1355
+ throw new Error("Setup plan missing.");
1356
+ }
1357
+ if (!skipServicesPlan && !servicesPlan) {
1358
+ throw new Error("Services plan missing.");
1359
+ }
1360
+ const scanStepUpdate = {};
1361
+ if (!skipSetupPlan && setupPlan) {
1362
+ if (!initState?.steps.setupEnvSecretsScanned) {
1363
+ scanStepUpdate.setupEnvSecretsScanned = true;
1364
+ }
1365
+ if (!initState?.steps.setupExternalScanned) {
1366
+ scanStepUpdate.setupExternalScanned = true;
1367
+ }
1368
+ if (!initState?.steps.setupExtraArtifactsScanned) {
1369
+ scanStepUpdate.setupExtraArtifactsScanned = true;
1370
+ }
1371
+ if (!initState?.steps.setupPlanScanned) {
1372
+ scanStepUpdate.setupPlanScanned = true;
1373
+ }
1374
+ }
1375
+ if (!skipServicesPlan &&
1376
+ servicesPlan &&
1377
+ !initState?.steps.servicesPlanScanned) {
1378
+ scanStepUpdate.servicesPlanScanned = true;
1379
+ }
1380
+ if (Object.keys(scanStepUpdate).length > 0) {
1381
+ await updateInitState({ steps: scanStepUpdate });
1382
+ }
1383
+ if (skipSetupPlan) {
1384
+ approvedPlan = await readSetupPlan(setupPath);
1385
+ }
1386
+ if (skipServicesPlan) {
1387
+ approvedServices = await readServicesPlan(servicesPath);
1388
+ }
1389
+ if (shouldResume && (approvedPlan || approvedServices)) {
1390
+ const backfillStepUpdate = {};
1391
+ if (approvedPlan) {
1392
+ if (!initState?.steps.setupEnvSecretsScanned) {
1393
+ backfillStepUpdate.setupEnvSecretsScanned = true;
715
1394
  }
716
- if (!skipProvision) {
717
- status.stage("Packaging git metadata");
718
- gitMetaCreated = await createGitMetaArchive(gitCommonDir, gitMetaPath, gitMetaListPath);
1395
+ if (!initState?.steps.setupExternalScanned) {
1396
+ backfillStepUpdate.setupExternalScanned = true;
719
1397
  }
720
- if (copyWorktree && worktreeState) {
721
- status.stage("Packaging worktree changes");
722
- stagedPatchCreated = await writePatch(repoRoot, ["diff", "--binary", "--cached"], stagedPatchPath);
723
- unstagedPatchCreated = await writePatch(repoRoot, ["diff", "--binary"], unstagedPatchPath);
724
- untrackedCreated = await createFileListArchive(repoRoot, worktreeState.untracked, untrackedPath, untrackedListPath);
1398
+ if (!initState?.steps.setupExtraArtifactsScanned) {
1399
+ backfillStepUpdate.setupExtraArtifactsScanned = true;
725
1400
  }
726
- if (!skipProvision) {
727
- status.stage("Uploading repo bundle");
728
- const bundleData = await fs.readFile(bundlePath);
729
- await client.writeFile(canonical, remoteBundlePath, bundleData);
1401
+ if (!initState?.steps.setupPlanScanned) {
1402
+ backfillStepUpdate.setupPlanScanned = true;
730
1403
  }
731
- if (gitMetaCreated) {
732
- status.stage("Uploading git metadata");
733
- const gitMetaData = await fs.readFile(gitMetaPath);
734
- await client.writeFile(canonical, remoteGitMetaPath, gitMetaData);
1404
+ }
1405
+ if (approvedServices && !initState?.steps.servicesPlanScanned) {
1406
+ backfillStepUpdate.servicesPlanScanned = true;
1407
+ }
1408
+ if (Object.keys(backfillStepUpdate).length > 0) {
1409
+ await updateInitState({ steps: backfillStepUpdate });
1410
+ }
1411
+ }
1412
+ if (nonInteractive) {
1413
+ if (setupPlan)
1414
+ approvedPlan = setupPlan;
1415
+ if (servicesPlan)
1416
+ approvedServices = servicesPlan;
1417
+ }
1418
+ else if (setupPlan || servicesPlan) {
1419
+ if (!process.stdin.isTTY || parsed.json) {
1420
+ throw new Error("Interactive terminal required to approve setup.");
1421
+ }
1422
+ const statCache = new Map();
1423
+ const readPathInfo = async (candidatePath) => {
1424
+ const cached = statCache.get(candidatePath);
1425
+ if (cached)
1426
+ return cached;
1427
+ const resolved = path.isAbsolute(candidatePath)
1428
+ ? candidatePath
1429
+ : path.resolve(repoRoot, candidatePath);
1430
+ const relative = toRepoRelativePath(repoRoot, candidatePath);
1431
+ const outsideRepo = isOutsideRepoPath(relative);
1432
+ let isDirectory = false;
1433
+ try {
1434
+ const stat = await fs.stat(resolved);
1435
+ isDirectory = stat.isDirectory();
735
1436
  }
736
- if (stagedPatchCreated) {
737
- status.stage("Uploading staged changes");
738
- await client.writeFile(canonical, remoteStagedPatchPath, await fs.readFile(stagedPatchPath));
1437
+ catch {
1438
+ isDirectory = false;
739
1439
  }
740
- if (unstagedPatchCreated) {
741
- status.stage("Uploading unstaged changes");
742
- await client.writeFile(canonical, remoteUnstagedPatchPath, await fs.readFile(unstagedPatchPath));
1440
+ const info = { relative, outsideRepo, isDirectory };
1441
+ statCache.set(candidatePath, info);
1442
+ return info;
1443
+ };
1444
+ const buildApprovalSummary = async ({ setup, services, }) => {
1445
+ const lines = [];
1446
+ lines.push("Setup");
1447
+ lines.push(`- .env files: ${setup.envFiles.length}`);
1448
+ for (const entry of setup.envFiles) {
1449
+ lines.push(` - ${toRepoRelativePath(repoRoot, entry.path)}`);
743
1450
  }
744
- if (untrackedCreated) {
745
- status.stage("Uploading untracked files");
746
- await client.writeFile(canonical, remoteUntrackedPath, await fs.readFile(untrackedPath));
1451
+ lines.push(`- Secret/config files: ${setup.secretFiles.length}`);
1452
+ for (const entry of setup.secretFiles) {
1453
+ lines.push(` - ${toRepoRelativePath(repoRoot, entry.path)}`);
747
1454
  }
748
- if (!skipProvision && globalGitConfigMappings.length > 0) {
749
- status.stage("Uploading git config");
750
- for (const mapping of globalGitConfigMappings) {
751
- const data = await fs.readFile(mapping.source);
752
- await client.writeFile(canonical, mapping.dest, data);
1455
+ lines.push(`- Other artifacts: ${setup.extraArtifacts.length}`);
1456
+ for (const entry of setup.extraArtifacts) {
1457
+ lines.push(` - ${toRepoRelativePath(repoRoot, entry.path)}`);
1458
+ }
1459
+ lines.push(`- External dependencies: ${setup.externalDependencies.length}`);
1460
+ for (const entry of setup.externalDependencies) {
1461
+ lines.push(` - ${entry.version ? `${entry.name}@${entry.version}` : entry.name}`);
1462
+ }
1463
+ lines.push(`- External config/secret files: ${setup.externalConfigs.length}`);
1464
+ for (const entry of setup.externalConfigs) {
1465
+ lines.push(` - ${toRepoRelativePath(repoRoot, entry.path)}`);
1466
+ }
1467
+ lines.push("");
1468
+ lines.push("Services");
1469
+ lines.push(`- App entrypoints: ${services.appEntrypoints.length}`);
1470
+ for (const entry of services.appEntrypoints) {
1471
+ lines.push(` - ${entry.command}`);
1472
+ }
1473
+ lines.push(`- Background services: ${services.backgroundServices.length}`);
1474
+ for (const entry of services.backgroundServices) {
1475
+ lines.push(` - ${entry.name}`);
1476
+ }
1477
+ const candidatePaths = new Set();
1478
+ for (const entry of [
1479
+ ...setup.envFiles,
1480
+ ...setup.secretFiles,
1481
+ ...setup.extraArtifacts,
1482
+ ...setup.externalConfigs,
1483
+ ]) {
1484
+ candidatePaths.add(entry.path);
1485
+ }
1486
+ const outsideRepoPaths = [];
1487
+ const directoryPaths = [];
1488
+ for (const candidatePath of candidatePaths) {
1489
+ const info = await readPathInfo(candidatePath);
1490
+ if (info.outsideRepo)
1491
+ outsideRepoPaths.push(info.relative);
1492
+ if (info.isDirectory)
1493
+ directoryPaths.push(info.relative);
1494
+ }
1495
+ outsideRepoPaths.sort();
1496
+ directoryPaths.sort();
1497
+ const hasRisk = outsideRepoPaths.length > 0 || directoryPaths.length > 0;
1498
+ const warningLines = [];
1499
+ if (outsideRepoPaths.length > 0) {
1500
+ warningLines.push("Outside repo:");
1501
+ for (const entry of outsideRepoPaths) {
1502
+ warningLines.push(`- ${entry}`);
753
1503
  }
1504
+ warningLines.push("");
754
1505
  }
755
- if (!skipProvision) {
756
- const backup = `${expandedWorkdir}.bak-${Date.now()}`;
757
- const checkoutCommand = headState?.branch
758
- ? `git checkout -B ${shellQuote(headState.branch)} ${shellQuote(headState.commit)}`
759
- : `git checkout --detach ${shellQuote(headState?.commit ?? "")}`;
760
- status.stage("Provisioning workdir");
761
- const remoteCommand = [
762
- "set -euo pipefail",
763
- "unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE",
764
- `if [ -d ${shellQuote(expandedWorkdir)} ]; then`,
765
- parsed.force
766
- ? ` mv ${shellQuote(expandedWorkdir)} ${shellQuote(backup)}`
767
- : ` echo "Target exists: ${expandedWorkdir}" >&2; exit 1`,
768
- "fi",
769
- `mkdir -p ${shellQuote(path.dirname(expandedWorkdir))}`,
770
- `git init -b devbox-init ${shellQuote(expandedWorkdir)}`,
771
- `cd ${shellQuote(expandedWorkdir)}`,
772
- `git fetch ${shellQuote(remoteBundlePath)} 'refs/*:refs/*'`,
773
- `if [ -f ${shellQuote(remoteGitMetaPath)} ]; then`,
774
- ` tar -xzf ${shellQuote(remoteGitMetaPath)} -C .git`,
775
- "fi",
776
- checkoutCommand,
777
- `if [ -f ${shellQuote(remoteStagedPatchPath)} ]; then`,
778
- ` git apply --index ${shellQuote(remoteStagedPatchPath)}`,
779
- "fi",
780
- `if [ -f ${shellQuote(remoteUnstagedPatchPath)} ]; then`,
781
- ` git apply ${shellQuote(remoteUnstagedPatchPath)}`,
782
- "fi",
783
- `if [ -f ${shellQuote(remoteUntrackedPath)} ]; then`,
784
- ` tar -xzf ${shellQuote(remoteUntrackedPath)} -C .`,
785
- "fi",
786
- ].join("\n");
787
- const execResult = await client.exec(canonical, [
788
- "/bin/bash",
789
- "--noprofile",
790
- "--norc",
791
- "-e",
792
- "-u",
793
- "-o",
794
- "pipefail",
795
- "-c",
796
- remoteCommand,
797
- ]);
798
- if (execResult.exitCode !== 0) {
799
- throw new Error(execResult.stderr || "Remote init failed");
1506
+ if (directoryPaths.length > 0) {
1507
+ warningLines.push("Directories:");
1508
+ for (const entry of directoryPaths) {
1509
+ warningLines.push(`- ${entry}`);
800
1510
  }
801
- await updateInitState({ steps: { workdirProvisioned: true } });
802
1511
  }
803
- }
804
- else if (!skipProvision) {
805
- status.stage("Packaging repo (archive)");
806
- if (copyWorktree) {
807
- const workingFiles = await readNullSeparatedPaths(repoRoot, [
808
- "ls-files",
809
- "--cached",
810
- "--others",
811
- "--exclude-standard",
812
- ]);
813
- await createFileListArchive(repoRoot, workingFiles, archivePath, untrackedListPath);
1512
+ return {
1513
+ summary: lines.join("\n"),
1514
+ warning: warningLines.length > 0 ? warningLines.join("\n") : null,
1515
+ hasRisk,
1516
+ };
1517
+ };
1518
+ let draftSetup = setupPlan;
1519
+ let draftServices = servicesPlan;
1520
+ while (true) {
1521
+ const nextSetup = skipSetupPlan
1522
+ ? approvedPlan
1523
+ : await promptForPlanApproval({
1524
+ plan: setupPlan,
1525
+ repoRoot,
1526
+ initialPlan: draftSetup,
1527
+ });
1528
+ const nextServices = skipServicesPlan
1529
+ ? approvedServices
1530
+ : await promptForServicesApproval({
1531
+ plan: servicesPlan,
1532
+ initialPlan: draftServices,
1533
+ });
1534
+ if (!nextSetup) {
1535
+ throw new Error("Setup plan missing.");
814
1536
  }
815
- else {
816
- await runCommand(repoRoot, "git", [
817
- "archive",
818
- "--format=tar.gz",
819
- "-o",
820
- archivePath,
821
- "HEAD",
822
- ]);
1537
+ if (!nextServices) {
1538
+ throw new Error("Services plan missing.");
823
1539
  }
824
- status.stage("Uploading repo archive");
825
- await client.writeFile(canonical, remoteArchivePath, await fs.readFile(archivePath));
826
- if (globalGitConfigMappings.length > 0) {
827
- status.stage("Uploading git config");
828
- for (const mapping of globalGitConfigMappings) {
829
- const data = await fs.readFile(mapping.source);
830
- await client.writeFile(canonical, mapping.dest, data);
831
- }
1540
+ const { summary, warning, hasRisk } = await buildApprovalSummary({
1541
+ setup: nextSetup,
1542
+ services: nextServices,
1543
+ });
1544
+ clackNote(summary, "Selected setup requirements");
1545
+ if (warning) {
1546
+ clackNote(warning, "Special attention");
832
1547
  }
833
- const backup = `${expandedWorkdir}.bak-${Date.now()}`;
834
- status.stage("Provisioning workdir");
835
- const remoteCommand = [
836
- "set -euo pipefail",
837
- "unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE",
838
- `if [ -d ${shellQuote(expandedWorkdir)} ]; then`,
839
- parsed.force
840
- ? ` mv ${shellQuote(expandedWorkdir)} ${shellQuote(backup)}`
841
- : ` echo "Target exists: ${expandedWorkdir}" >&2; exit 1`,
842
- "fi",
843
- `mkdir -p ${shellQuote(expandedWorkdir)}`,
844
- `tar -xzf ${shellQuote(remoteArchivePath)} -C ${shellQuote(expandedWorkdir)}`,
845
- ].join("\n");
846
- const execResult = await client.exec(canonical, [
847
- "/bin/bash",
848
- "--noprofile",
849
- "--norc",
850
- "-e",
851
- "-u",
852
- "-o",
853
- "pipefail",
854
- "-c",
855
- remoteCommand,
856
- ]);
857
- if (execResult.exitCode !== 0) {
858
- throw new Error(execResult.stderr || "Remote init failed");
1548
+ const decision = await clackSelect({
1549
+ message: "Proceed with these selections?",
1550
+ options: [
1551
+ { value: "proceed", label: "Proceed" },
1552
+ { value: "edit", label: "Edit selections" },
1553
+ { value: "cancel", label: "Cancel" },
1554
+ ],
1555
+ initialValue: "proceed",
1556
+ });
1557
+ if (isCancel(decision) || decision === "cancel") {
1558
+ throwInitCanceled();
1559
+ }
1560
+ if (decision === "edit") {
1561
+ draftSetup = nextSetup;
1562
+ draftServices = nextServices;
1563
+ continue;
859
1564
  }
860
- await updateInitState({ steps: { workdirProvisioned: true } });
1565
+ if (hasRisk) {
1566
+ const confirmed = await clackConfirm({
1567
+ message: "You selected items outside the repo and/or directories. Continue?",
1568
+ active: "Continue",
1569
+ inactive: "Edit selections",
1570
+ initialValue: true,
1571
+ });
1572
+ if (isCancel(confirmed)) {
1573
+ throwInitCanceled();
1574
+ }
1575
+ if (!confirmed) {
1576
+ draftSetup = nextSetup;
1577
+ draftServices = nextServices;
1578
+ continue;
1579
+ }
1580
+ }
1581
+ approvedPlan = nextSetup;
1582
+ approvedServices = nextServices;
1583
+ break;
861
1584
  }
862
- if (!skipSetupUpload) {
1585
+ }
1586
+ if (!approvedPlan) {
1587
+ throw new Error("Setup plan missing.");
1588
+ }
1589
+ if (!approvedServices) {
1590
+ throw new Error("Services plan missing.");
1591
+ }
1592
+ const ensuredPlan = approvedPlan;
1593
+ if (!skipSetupPlan) {
1594
+ await writeSetupPlan(setupPath, ensuredPlan);
1595
+ await updateInitState({ steps: { setupPlanWritten: true } });
1596
+ }
1597
+ if (!skipServicesPlan) {
1598
+ await writeServicesPlan(servicesPath, approvedServices);
1599
+ await updateInitState({ steps: { servicesPlanWritten: true } });
1600
+ }
1601
+ if (!skipSetupUpload) {
1602
+ setupArtifacts = await runInitStep({
1603
+ enabled: progressEnabled,
1604
+ title: "Packaging setup artifacts",
1605
+ fn: async () => await createSetupArtifacts({
1606
+ repoRoot,
1607
+ plan: ensuredPlan,
1608
+ outputDir: setupDir,
1609
+ tempDir: setupTempDir,
1610
+ homeDir: localHomeDir,
1611
+ }),
1612
+ });
1613
+ }
1614
+ }
1615
+ finally {
1616
+ await fs.rm(setupTempDir, { recursive: true, force: true });
1617
+ }
1618
+ const skipProvision = shouldResume && initState?.steps.workdirProvisioned;
1619
+ if (skipProvision && !skipSetupUpload) {
1620
+ await runInitStep({
1621
+ enabled: progressEnabled,
1622
+ title: "Uploading setup plan",
1623
+ fn: async ({ status }) => {
863
1624
  await uploadSetupPlan({
864
1625
  client,
865
1626
  canonical,
@@ -875,6 +1636,287 @@ export const runInit = async (args) => {
875
1636
  : null,
876
1637
  status,
877
1638
  });
1639
+ },
1640
+ });
1641
+ await updateInitState({ steps: { setupUploaded: true } });
1642
+ await updateRegistryProjectStatus(resolveInitStatus(initState?.steps, initState?.complete));
1643
+ }
1644
+ else if (!skipProvision || !skipSetupUpload) {
1645
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "devbox-init-"));
1646
+ const bundlePath = path.join(tempDir, "repo.bundle");
1647
+ const archivePath = path.join(tempDir, "repo.tgz");
1648
+ const gitMetaPath = path.join(tempDir, "git-meta.tgz");
1649
+ const gitMetaListPath = path.join(tempDir, "git-meta.list");
1650
+ const stagedPatchPath = path.join(tempDir, "staged.patch");
1651
+ const unstagedPatchPath = path.join(tempDir, "unstaged.patch");
1652
+ const untrackedPath = path.join(tempDir, "untracked.tgz");
1653
+ const untrackedListPath = path.join(tempDir, "untracked.list");
1654
+ const globalGitConfigSources = await readGlobalGitConfigFiles(repoRoot);
1655
+ const globalGitConfigMappings = mapGlobalGitConfigDestinations(globalGitConfigSources, localHomeDir);
1656
+ try {
1657
+ const remoteBundlePath = "/home/sprite/.devbox/upload.bundle";
1658
+ const remoteArchivePath = "/home/sprite/.devbox/upload.tgz";
1659
+ const remoteGitMetaPath = "/home/sprite/.devbox/git-meta.tgz";
1660
+ const remoteStagedPatchPath = "/home/sprite/.devbox/staged.patch";
1661
+ const remoteUnstagedPatchPath = "/home/sprite/.devbox/unstaged.patch";
1662
+ const remoteUntrackedPath = "/home/sprite/.devbox/untracked.tgz";
1663
+ const remoteHasGit = await runInitStep({
1664
+ enabled: progressEnabled,
1665
+ title: "Preparing remote directories",
1666
+ fn: async ({ status }) => {
1667
+ status.stage("Checking remote git");
1668
+ const gitCheck = await client.exec(canonical, [
1669
+ "/bin/bash",
1670
+ "-lc",
1671
+ "git --version",
1672
+ ]);
1673
+ const remoteHasGit = gitCheck.exitCode === 0;
1674
+ const remoteDirs = new Set();
1675
+ remoteDirs.add(path.posix.dirname(remoteBundlePath));
1676
+ remoteDirs.add(path.posix.dirname(remoteArchivePath));
1677
+ remoteDirs.add(path.posix.dirname(remoteGitMetaPath));
1678
+ remoteDirs.add(path.posix.dirname(remoteStagedPatchPath));
1679
+ remoteDirs.add(path.posix.dirname(remoteUnstagedPatchPath));
1680
+ remoteDirs.add(path.posix.dirname(remoteUntrackedPath));
1681
+ for (const mapping of globalGitConfigMappings) {
1682
+ remoteDirs.add(path.posix.dirname(mapping.dest));
1683
+ }
1684
+ if (remoteDirs.size > 0) {
1685
+ status.stage("Preparing remote directories");
1686
+ const prepResult = await client.exec(canonical, [
1687
+ "/bin/bash",
1688
+ "-lc",
1689
+ `mkdir -p ${[...remoteDirs].map(shellQuote).join(" ")}`,
1690
+ ]);
1691
+ if (prepResult.exitCode !== 0) {
1692
+ throw new Error(prepResult.stderr || "Failed to prepare remote dirs");
1693
+ }
1694
+ }
1695
+ return remoteHasGit;
1696
+ },
1697
+ });
1698
+ const { headState, gitCommonDir, worktreeState, copyWorktree } = await runInitStep({
1699
+ enabled: progressEnabled,
1700
+ title: "Inspecting repo state",
1701
+ fn: async ({ status }) => {
1702
+ const headState = await readHeadState(repoRoot);
1703
+ const resolved = await resolveGitCommonDir(repoRoot);
1704
+ const worktreeState = await readWorktreeState(repoRoot);
1705
+ const hasWorktreeChanges = worktreeState.staged.length > 0 ||
1706
+ worktreeState.unstaged.length > 0 ||
1707
+ worktreeState.untracked.length > 0;
1708
+ let copyWorktree = false;
1709
+ if (hasWorktreeChanges && process.stdin.isTTY && !parsed.json) {
1710
+ status.stop();
1711
+ copyWorktree = await confirmCopyWorktree(worktreeState);
1712
+ status.stage("Inspecting repo state");
1713
+ }
1714
+ else if (hasWorktreeChanges) {
1715
+ copyWorktree = true;
1716
+ }
1717
+ return {
1718
+ headState,
1719
+ gitCommonDir: resolved.commonDir,
1720
+ worktreeState,
1721
+ copyWorktree,
1722
+ };
1723
+ },
1724
+ });
1725
+ const packaged = await runInitStep({
1726
+ enabled: progressEnabled,
1727
+ title: "Packaging repo",
1728
+ fn: async ({ status }) => {
1729
+ let gitMetaCreated = false;
1730
+ let stagedPatchCreated = false;
1731
+ let unstagedPatchCreated = false;
1732
+ let untrackedCreated = false;
1733
+ if (remoteHasGit) {
1734
+ status.stage("Packaging repo bundle");
1735
+ await runCommand(repoRoot, "git", [
1736
+ "bundle",
1737
+ "create",
1738
+ bundlePath,
1739
+ "--all",
1740
+ ]);
1741
+ status.stage("Packaging git metadata");
1742
+ gitMetaCreated = await createGitMetaArchive(gitCommonDir, gitMetaPath, gitMetaListPath);
1743
+ if (copyWorktree) {
1744
+ status.stage("Packaging repo changes");
1745
+ stagedPatchCreated = await writePatch(repoRoot, ["diff", "--binary", "--cached"], stagedPatchPath);
1746
+ unstagedPatchCreated = await writePatch(repoRoot, ["diff", "--binary"], unstagedPatchPath);
1747
+ untrackedCreated = await createFileListArchive(repoRoot, worktreeState.untracked, untrackedPath, untrackedListPath);
1748
+ }
1749
+ }
1750
+ else {
1751
+ status.stage("Packaging repo archive");
1752
+ if (copyWorktree) {
1753
+ const workingFiles = await readNullSeparatedPaths(repoRoot, [
1754
+ "ls-files",
1755
+ "--cached",
1756
+ "--others",
1757
+ "--exclude-standard",
1758
+ ]);
1759
+ await createFileListArchive(repoRoot, workingFiles, archivePath, untrackedListPath);
1760
+ }
1761
+ else {
1762
+ await runCommand(repoRoot, "git", [
1763
+ "archive",
1764
+ "--format=tar.gz",
1765
+ "-o",
1766
+ archivePath,
1767
+ "HEAD",
1768
+ ]);
1769
+ }
1770
+ }
1771
+ return {
1772
+ gitMetaCreated,
1773
+ stagedPatchCreated,
1774
+ unstagedPatchCreated,
1775
+ untrackedCreated,
1776
+ };
1777
+ },
1778
+ });
1779
+ await runInitStep({
1780
+ enabled: progressEnabled,
1781
+ title: "Uploading repo",
1782
+ fn: async ({ status }) => {
1783
+ if (remoteHasGit) {
1784
+ status.stage("Uploading repo bundle");
1785
+ const bundleData = await fs.readFile(bundlePath);
1786
+ await client.writeFile(canonical, remoteBundlePath, bundleData);
1787
+ if (packaged.gitMetaCreated) {
1788
+ status.stage("Uploading git metadata");
1789
+ const gitMetaData = await fs.readFile(gitMetaPath);
1790
+ await client.writeFile(canonical, remoteGitMetaPath, gitMetaData);
1791
+ }
1792
+ if (packaged.stagedPatchCreated) {
1793
+ status.stage("Uploading staged changes");
1794
+ await client.writeFile(canonical, remoteStagedPatchPath, await fs.readFile(stagedPatchPath));
1795
+ }
1796
+ if (packaged.unstagedPatchCreated) {
1797
+ status.stage("Uploading unstaged changes");
1798
+ await client.writeFile(canonical, remoteUnstagedPatchPath, await fs.readFile(unstagedPatchPath));
1799
+ }
1800
+ if (packaged.untrackedCreated) {
1801
+ status.stage("Uploading untracked files");
1802
+ await client.writeFile(canonical, remoteUntrackedPath, await fs.readFile(untrackedPath));
1803
+ }
1804
+ }
1805
+ else {
1806
+ status.stage("Uploading repo archive");
1807
+ await client.writeFile(canonical, remoteArchivePath, await fs.readFile(archivePath));
1808
+ }
1809
+ if (globalGitConfigMappings.length > 0) {
1810
+ status.stage("Uploading git config");
1811
+ for (const mapping of globalGitConfigMappings) {
1812
+ const data = await fs.readFile(mapping.source);
1813
+ await client.writeFile(canonical, mapping.dest, data);
1814
+ }
1815
+ }
1816
+ },
1817
+ });
1818
+ await runInitStep({
1819
+ enabled: progressEnabled,
1820
+ title: "Provisioning workdir",
1821
+ fn: async () => {
1822
+ const backup = `${expandedWorkdir}.bak-${Date.now()}`;
1823
+ if (remoteHasGit) {
1824
+ const checkoutCommand = headState.branch
1825
+ ? `git checkout -B ${shellQuote(headState.branch)} ${shellQuote(headState.commit)}`
1826
+ : `git checkout --detach ${shellQuote(headState.commit)}`;
1827
+ const remoteCommand = [
1828
+ "set -euo pipefail",
1829
+ "unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE",
1830
+ `if [ -d ${shellQuote(expandedWorkdir)} ]; then`,
1831
+ parsed.force
1832
+ ? ` mv ${shellQuote(expandedWorkdir)} ${shellQuote(backup)}`
1833
+ : ` echo "Target exists: ${expandedWorkdir}" >&2; exit 1`,
1834
+ "fi",
1835
+ `mkdir -p ${shellQuote(path.dirname(expandedWorkdir))}`,
1836
+ `git init -b devbox-init ${shellQuote(expandedWorkdir)}`,
1837
+ `cd ${shellQuote(expandedWorkdir)}`,
1838
+ `git fetch ${shellQuote(remoteBundlePath)} 'refs/*:refs/*'`,
1839
+ `if [ -f ${shellQuote(remoteGitMetaPath)} ]; then`,
1840
+ ` tar -xzf ${shellQuote(remoteGitMetaPath)} -C .git`,
1841
+ "fi",
1842
+ checkoutCommand,
1843
+ `if [ -f ${shellQuote(remoteStagedPatchPath)} ]; then`,
1844
+ ` git apply --index ${shellQuote(remoteStagedPatchPath)}`,
1845
+ "fi",
1846
+ `if [ -f ${shellQuote(remoteUnstagedPatchPath)} ]; then`,
1847
+ ` git apply ${shellQuote(remoteUnstagedPatchPath)}`,
1848
+ "fi",
1849
+ `if [ -f ${shellQuote(remoteUntrackedPath)} ]; then`,
1850
+ ` tar -xzf ${shellQuote(remoteUntrackedPath)} -C .`,
1851
+ "fi",
1852
+ ].join("\n");
1853
+ const execResult = await client.exec(canonical, [
1854
+ "/bin/bash",
1855
+ "--noprofile",
1856
+ "--norc",
1857
+ "-e",
1858
+ "-u",
1859
+ "-o",
1860
+ "pipefail",
1861
+ "-c",
1862
+ remoteCommand,
1863
+ ]);
1864
+ if (execResult.exitCode !== 0) {
1865
+ throw new Error(execResult.stderr || "Remote init failed");
1866
+ }
1867
+ }
1868
+ else {
1869
+ const remoteCommand = [
1870
+ "set -euo pipefail",
1871
+ "unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE",
1872
+ `if [ -d ${shellQuote(expandedWorkdir)} ]; then`,
1873
+ parsed.force
1874
+ ? ` mv ${shellQuote(expandedWorkdir)} ${shellQuote(backup)}`
1875
+ : ` echo "Target exists: ${expandedWorkdir}" >&2; exit 1`,
1876
+ "fi",
1877
+ `mkdir -p ${shellQuote(expandedWorkdir)}`,
1878
+ `tar -xzf ${shellQuote(remoteArchivePath)} -C ${shellQuote(expandedWorkdir)}`,
1879
+ ].join("\n");
1880
+ const execResult = await client.exec(canonical, [
1881
+ "/bin/bash",
1882
+ "--noprofile",
1883
+ "--norc",
1884
+ "-e",
1885
+ "-u",
1886
+ "-o",
1887
+ "pipefail",
1888
+ "-c",
1889
+ remoteCommand,
1890
+ ]);
1891
+ if (execResult.exitCode !== 0) {
1892
+ throw new Error(execResult.stderr || "Remote init failed");
1893
+ }
1894
+ }
1895
+ await updateInitState({ steps: { workdirProvisioned: true } });
1896
+ },
1897
+ });
1898
+ if (!skipSetupUpload) {
1899
+ await runInitStep({
1900
+ enabled: progressEnabled,
1901
+ title: "Uploading setup plan",
1902
+ fn: async ({ status }) => {
1903
+ await uploadSetupPlan({
1904
+ client,
1905
+ canonical,
1906
+ localSetupPath: setupPath,
1907
+ remoteSetupPath,
1908
+ localArtifactsBundlePath: setupArtifacts?.bundlePath ?? null,
1909
+ localArtifactsManifestPath: setupArtifacts?.manifestPath ?? null,
1910
+ remoteArtifactsBundlePath: setupArtifacts
1911
+ ? path.posix.join(expandedWorkdir, ".devbox", "setup-artifacts.tgz")
1912
+ : null,
1913
+ remoteArtifactsManifestPath: setupArtifacts
1914
+ ? path.posix.join(expandedWorkdir, ".devbox", "setup-artifacts.json")
1915
+ : null,
1916
+ status,
1917
+ });
1918
+ },
1919
+ });
878
1920
  await updateInitState({ steps: { setupUploaded: true } });
879
1921
  await updateRegistryProjectStatus(resolveInitStatus(initState?.steps, initState?.complete));
880
1922
  }
@@ -883,165 +1925,280 @@ export const runInit = async (args) => {
883
1925
  await fs.rm(tempDir, { recursive: true, force: true });
884
1926
  }
885
1927
  }
886
- await ensureWorkdirOwnership({
887
- client,
888
- canonical,
889
- workdir: expandedWorkdir,
890
- status,
891
- json: parsed.json === true,
1928
+ await runInitStep({
1929
+ enabled: progressEnabled,
1930
+ title: "Ensuring workdir ownership",
1931
+ fn: async ({ status }) => {
1932
+ await ensureWorkdirOwnership({
1933
+ client,
1934
+ canonical,
1935
+ workdir: expandedWorkdir,
1936
+ status,
1937
+ json: parsed.json === true,
1938
+ });
1939
+ },
892
1940
  });
1941
+ const skipGitSafeDirectory = shouldResume && initState?.steps.gitSafeDirectoryConfigured;
1942
+ if (!skipGitSafeDirectory) {
1943
+ await runInitStep({
1944
+ enabled: progressEnabled,
1945
+ title: "Configuring git safe.directory",
1946
+ fn: async ({ status }) => {
1947
+ await ensureGitSafeDirectory({
1948
+ client,
1949
+ canonical,
1950
+ workdir: expandedWorkdir,
1951
+ status,
1952
+ });
1953
+ await updateInitState({ steps: { gitSafeDirectoryConfigured: true } });
1954
+ },
1955
+ });
1956
+ }
893
1957
  const skipSshdConfig = shouldResume && initState?.steps.sshdConfigured;
894
1958
  if (!skipSshdConfig) {
895
- status.stage("Configuring SSH mount access");
896
- await ensureLocalMountKey();
897
- await ensureKnownHostsFile();
898
- const publicKey = await readLocalMountPublicKey();
899
- await ensureRemoteMountAccess(client, canonical, publicKey);
900
- await ensureSshdService(client, canonical);
901
- await updateInitState({ steps: { sshdConfigured: true } });
1959
+ await runInitStep({
1960
+ enabled: progressEnabled,
1961
+ title: "Configuring SSH mount access",
1962
+ fn: async ({ fail }) => {
1963
+ try {
1964
+ await ensureLocalMountKey();
1965
+ await ensureKnownHostsFile();
1966
+ const publicKey = await readLocalMountPublicKey();
1967
+ await ensureRemoteMountAccess(client, canonical, publicKey);
1968
+ await ensureSshdService(client, canonical);
1969
+ await updateInitState({ steps: { sshdConfigured: true } });
1970
+ }
1971
+ catch (error) {
1972
+ logger.warn("sshd_config_failed", {
1973
+ box: canonical,
1974
+ error: String(error),
1975
+ });
1976
+ fail("Configuring SSH mount access (failed)");
1977
+ if (!parsed.json) {
1978
+ const message = error instanceof Error && error.message
1979
+ ? error.message
1980
+ : String(error);
1981
+ console.warn(`Warning: failed to configure SSH mount access. ${message}`);
1982
+ console.warn(`To retry later: dvb mount ${alias}`);
1983
+ }
1984
+ }
1985
+ },
1986
+ });
902
1987
  }
903
1988
  const skipSshAuth = nonInteractive ||
904
1989
  (shouldResume && initState?.steps.sshAuthConfigured);
905
1990
  if (!skipSshAuth) {
906
- status.stage("Configuring git SSH auth");
907
- const remoteOrigin = await readRemoteOrigin(client, canonical, expandedWorkdir);
1991
+ const { remoteOrigin, remoteInfo } = await runInitStep({
1992
+ enabled: progressEnabled,
1993
+ title: "Checking git remote for SSH auth",
1994
+ fn: async () => {
1995
+ const remoteOrigin = await readRemoteOrigin(client, canonical, expandedWorkdir);
1996
+ const remoteInfo = remoteOrigin ? parseGitRemote(remoteOrigin) : null;
1997
+ return { remoteOrigin, remoteInfo };
1998
+ },
1999
+ });
908
2000
  if (!remoteOrigin) {
909
2001
  if (!parsed.json) {
910
- status.stop();
911
2002
  console.warn("Warning: unable to detect remote origin on sprite. Skipping SSH setup.");
912
2003
  }
913
2004
  }
2005
+ else if (!remoteInfo) {
2006
+ if (!parsed.json) {
2007
+ console.warn(`Warning: unrecognized git remote format (${remoteOrigin}). Skipping SSH setup.`);
2008
+ }
2009
+ }
914
2010
  else {
915
- const remoteInfo = parseGitRemote(remoteOrigin);
916
- if (!remoteInfo) {
2011
+ let activeOrigin = remoteOrigin;
2012
+ if (remoteInfo.protocol === "https") {
917
2013
  if (!parsed.json) {
918
- status.stop();
919
- console.warn(`Warning: unrecognized git remote format (${remoteOrigin}). Skipping SSH setup.`);
2014
+ clackNote([
2015
+ `Origin is HTTPS (${remoteOrigin}).`,
2016
+ "",
2017
+ "SSH auth will not work unless we switch this remote to SSH:",
2018
+ remoteInfo.sshUrl,
2019
+ ].join("\n"), "Git remote");
920
2020
  }
921
- }
922
- else {
923
- let activeOrigin = remoteOrigin;
924
- if (remoteInfo.protocol === "https") {
2021
+ const shouldSwitch = await clackSelect({
2022
+ message: "Switch remote origin to SSH for git auth?",
2023
+ options: [
2024
+ { value: "switch", label: "Switch to SSH (Recommended)" },
2025
+ { value: "keep", label: "Keep HTTPS (Skip SSH auth setup)" },
2026
+ { value: "cancel", label: "Cancel init" },
2027
+ ],
2028
+ initialValue: "switch",
2029
+ });
2030
+ if (isCancel(shouldSwitch) || shouldSwitch === "cancel") {
2031
+ throwInitCanceled();
2032
+ }
2033
+ if (shouldSwitch === "keep") {
925
2034
  if (!parsed.json) {
926
- status.stop();
927
- console.log(`Origin is HTTPS (${remoteOrigin}). SSH auth will not work unless we switch this remote to SSH.`);
2035
+ console.warn("Skipping SSH auth setup. Configure git credentials for this repo manually before pulling or pushing.");
928
2036
  }
929
- const shouldSwitch = await promptYesNo("Switch origin to SSH for sprite git auth?", false);
930
- if (!shouldSwitch) {
931
- if (!parsed.json) {
932
- console.warn("Skipping SSH auth setup. Configure git credentials for this repo manually before pulling or pushing.");
933
- }
934
- activeOrigin = "";
2037
+ activeOrigin = "";
2038
+ }
2039
+ else {
2040
+ await runInitStep({
2041
+ enabled: progressEnabled,
2042
+ title: "Switching git remote to SSH",
2043
+ fn: async () => {
2044
+ await setRemoteOrigin(client, canonical, expandedWorkdir, remoteInfo.sshUrl);
2045
+ },
2046
+ });
2047
+ activeOrigin = remoteInfo.sshUrl;
2048
+ }
2049
+ }
2050
+ if (activeOrigin) {
2051
+ const publicKey = await runInitStep({
2052
+ enabled: progressEnabled,
2053
+ title: "Generating SSH key",
2054
+ fn: async () => await ensureSshKey(client, canonical, `${alias}@devbox`),
2055
+ });
2056
+ await runInitStep({
2057
+ enabled: progressEnabled,
2058
+ title: "Updating SSH config",
2059
+ fn: async () => await ensureSshConfig(client, canonical, remoteInfo.host),
2060
+ });
2061
+ if (!parsed.json) {
2062
+ clackNote(publicKey, `Add this SSH public key to ${remoteInfo.host}`);
2063
+ const copied = await copyToClipboard(publicKey);
2064
+ if (copied) {
2065
+ clackLog.success("Copied SSH public key to clipboard.");
935
2066
  }
936
2067
  else {
937
- status.stage("Switching git remote to SSH");
938
- await setRemoteOrigin(client, canonical, expandedWorkdir, remoteInfo.sshUrl);
939
- activeOrigin = remoteInfo.sshUrl;
2068
+ clackLog.warn("Could not copy the SSH key automatically.");
940
2069
  }
2070
+ const shouldOpen = await promptBeforeOpenBrowser({
2071
+ url: remoteInfo.settingsUrl,
2072
+ title: `${remoteInfo.host} SSH key page`,
2073
+ consequence: [
2074
+ "Skipping browser open.",
2075
+ "You must add the SSH key before git pull/push will work via SSH.",
2076
+ ].join(" "),
2077
+ });
2078
+ if (shouldOpen) {
2079
+ const opened = openBrowser(remoteInfo.settingsUrl);
2080
+ if (!opened) {
2081
+ clackLog.warn("Unable to open the browser automatically.");
2082
+ }
2083
+ }
2084
+ }
2085
+ const added = await clackConfirm({
2086
+ message: "Have you added the SSH key?",
2087
+ active: "Yes, verify now",
2088
+ inactive: "Not yet",
2089
+ initialValue: true,
2090
+ });
2091
+ if (isCancel(added)) {
2092
+ throwInitCanceled();
941
2093
  }
942
- if (activeOrigin) {
943
- status.stage("Generating SSH key");
944
- const publicKey = await ensureSshKey(client, canonical, `${alias}@devbox`);
945
- status.stage("Updating SSH config");
946
- await ensureSshConfig(client, canonical, remoteInfo.host);
2094
+ if (!added) {
947
2095
  if (!parsed.json) {
948
- status.stop();
949
- console.log("");
950
- console.log(`Add this SSH public key to ${remoteInfo.host}:`);
951
- console.log(publicKey);
952
- console.log(`Open: ${remoteInfo.settingsUrl}`);
953
- const copied = await copyToClipboard(publicKey);
954
- if (copied) {
955
- console.log("Copied the SSH public key to your clipboard.");
956
- }
957
- else {
958
- console.log("Could not copy the SSH key automatically.");
959
- }
960
- const shouldOpen = await promptToContinue("Press Enter to open the SSH key page in your browser...");
961
- if (shouldOpen) {
962
- const opened = openBrowser(remoteInfo.settingsUrl);
963
- if (!opened) {
964
- console.log("Unable to open the browser automatically.");
965
- }
966
- }
2096
+ console.warn("Skipping SSH verification. Add the key and re-run `dvb init --resume` to verify.");
967
2097
  }
968
- const added = await promptYesNo("Have you added the SSH key?", false);
969
- if (!added) {
2098
+ }
2099
+ else {
2100
+ const verified = await runInitStep({
2101
+ enabled: progressEnabled,
2102
+ title: "Verifying git SSH auth",
2103
+ fn: async () => await verifySshAuth(client, canonical, remoteInfo.host, expandedWorkdir),
2104
+ });
2105
+ if (!verified) {
970
2106
  if (!parsed.json) {
971
- console.warn("Skipping SSH verification. Add the key and re-run `dvb init --resume` to verify.");
2107
+ console.warn("SSH auth verification failed. Confirm the key is added and that the repo access is granted.");
972
2108
  }
973
2109
  }
974
2110
  else {
975
- status.stage("Verifying git SSH auth");
976
- const verified = await verifySshAuth(client, canonical, remoteInfo.host, expandedWorkdir);
977
- if (!verified) {
978
- if (!parsed.json) {
979
- status.stop();
980
- console.warn("SSH auth verification failed. Confirm the key is added and that the repo access is granted.");
981
- }
982
- }
983
- else {
984
- await updateInitState({
985
- steps: { sshAuthConfigured: true },
986
- });
987
- }
2111
+ await updateInitState({
2112
+ steps: { sshAuthConfigured: true },
2113
+ });
988
2114
  }
989
2115
  }
990
2116
  }
991
2117
  }
992
2118
  }
993
- if (!shouldResume || !initState?.steps.weztermMuxInstalled) {
994
- status.stage("Installing WezTerm mux server");
995
- try {
996
- await installWeztermMux({ client, spriteName: canonical });
997
- await updateInitState({ steps: { weztermMuxInstalled: true } });
998
- }
999
- catch (error) {
1000
- logger.warn("wezterm_mux_install_failed", {
1001
- box: canonical,
1002
- error: String(error),
1003
- });
1004
- if (!parsed.json) {
1005
- status.stop();
1006
- const message = error instanceof Error && error.message
1007
- ? error.message
1008
- : String(error);
1009
- console.warn(`Warning: failed to install WezTerm mux server. ${message}`);
2119
+ const weztermMuxPresent = await runInitStep({
2120
+ enabled: progressEnabled,
2121
+ title: "Ensuring WezTerm mux server (optional)",
2122
+ fn: async ({ fail }) => {
2123
+ try {
2124
+ const installResult = !shouldResume || !initState?.steps.weztermMuxInstalled
2125
+ ? await ensureWeztermMuxInstalled({
2126
+ client,
2127
+ spriteName: canonical,
2128
+ allowResolveAsset: true,
2129
+ })
2130
+ : await ensureWeztermMuxInstalled({
2131
+ client,
2132
+ spriteName: canonical,
2133
+ // If the mux is missing on resume, we still need to resolve a GitHub asset.
2134
+ allowResolveAsset: true,
2135
+ });
2136
+ if (installResult.muxPresent) {
2137
+ await updateInitState({ steps: { weztermMuxInstalled: true } });
2138
+ }
2139
+ return installResult.muxPresent;
1010
2140
  }
1011
- }
1012
- }
1013
- status.stage("Ensuring WezTerm mux service");
1014
- try {
1015
- await ensureWeztermMuxService({ client, spriteName: canonical });
1016
- await updateInitState({ steps: { weztermMuxServiceEnsured: true } });
1017
- }
1018
- catch (error) {
1019
- logger.warn("wezterm_mux_service_failed", {
1020
- box: canonical,
1021
- error: String(error),
2141
+ catch (error) {
2142
+ logger.warn("wezterm_mux_install_failed", {
2143
+ box: canonical,
2144
+ error: error instanceof Error ? error.message : String(error),
2145
+ });
2146
+ fail("Ensuring WezTerm mux server (optional) (failed)");
2147
+ if (!parsed.json) {
2148
+ const message = error instanceof Error && error.message
2149
+ ? error.message
2150
+ : String(error);
2151
+ console.warn(`Warning: failed to install WezTerm mux server (optional). ${message}\n` +
2152
+ "Tip: re-run with DEVBOX_LOG_LEVEL=info to see Sprite exec logs on stderr.");
2153
+ }
2154
+ return false;
2155
+ }
2156
+ },
2157
+ });
2158
+ if (weztermMuxPresent &&
2159
+ (!shouldResume || !initState?.steps.weztermMuxServiceEnsured)) {
2160
+ await runInitStep({
2161
+ enabled: progressEnabled,
2162
+ title: "Ensuring WezTerm mux service (optional)",
2163
+ fn: async ({ fail }) => {
2164
+ try {
2165
+ await ensureWeztermMuxService({ client, spriteName: canonical });
2166
+ await updateInitState({ steps: { weztermMuxServiceEnsured: true } });
2167
+ }
2168
+ catch (error) {
2169
+ logger.warn("wezterm_mux_service_failed", {
2170
+ box: canonical,
2171
+ error: error instanceof Error ? error.message : String(error),
2172
+ });
2173
+ fail("Ensuring WezTerm mux service (optional) (failed)");
2174
+ if (!parsed.json) {
2175
+ const message = error instanceof Error && error.message
2176
+ ? error.message
2177
+ : String(error);
2178
+ console.warn(`Warning: failed to ensure WezTerm mux service (optional). ${message}`);
2179
+ }
2180
+ }
2181
+ },
1022
2182
  });
1023
- if (!parsed.json) {
1024
- status.stop();
1025
- const message = error instanceof Error && error.message
1026
- ? error.message
1027
- : String(error);
1028
- console.warn(`Warning: failed to ensure WezTerm mux service. ${message}`);
1029
- }
1030
2183
  }
1031
2184
  if (!skipServicesConfig) {
1032
2185
  if (!approvedServices) {
1033
2186
  throw new Error("Missing services plan output.");
1034
2187
  }
1035
- status.stage("Writing devbox.toml services");
1036
- await writeRemoteServicesToml({
1037
- client,
1038
- canonical,
1039
- workdir: expandedWorkdir,
1040
- services: approvedServices.backgroundServices,
2188
+ await runInitStep({
2189
+ enabled: progressEnabled,
2190
+ title: "Writing devbox.toml services",
2191
+ fn: async () => {
2192
+ await writeRemoteServicesToml({
2193
+ client,
2194
+ canonical,
2195
+ workdir: expandedWorkdir,
2196
+ services: approvedServices.backgroundServices,
2197
+ });
2198
+ await updateInitState({ steps: { servicesConfigWritten: true } });
2199
+ },
1041
2200
  });
1042
- await updateInitState({ steps: { servicesConfigWritten: true } });
1043
2201
  }
1044
- status.stage("Registering project");
1045
2202
  const projectMeta = {
1046
2203
  fingerprint,
1047
2204
  canonical,
@@ -1050,23 +2207,64 @@ export const runInit = async (args) => {
1050
2207
  origin: projectOrigin,
1051
2208
  createdAt: projectCreatedAt,
1052
2209
  };
1053
- await client.writeFile(canonical, `/home/sprite/.devbox/projects/${fingerprint}.json`, Buffer.from(JSON.stringify(projectMeta, null, 2)));
1054
- status.stage("Writing local metadata");
1055
- await ensureDevboxToml(repoRoot, repoName, slug);
1056
- await writeRepoMarker(repoRoot, { fingerprint, canonical, alias });
1057
- status.stage("Updating Codex config on sprite");
1058
- try {
1059
- await writeRemoteCodexConfig(client, canonical, repoName);
1060
- }
1061
- catch (error) {
1062
- logger.warn("codex_config_update_failed", {
1063
- error: String(error),
1064
- });
1065
- if (!parsed.json) {
1066
- status.stop();
1067
- console.warn("Warning: failed to update /home/sprite/.codex/config.toml for full-access sandbox settings.");
1068
- }
1069
- }
2210
+ const projectMetaPath = `/home/sprite/.devbox/projects/${fingerprint}.json`;
2211
+ await runInitStep({
2212
+ enabled: progressEnabled,
2213
+ title: "Registering project",
2214
+ fn: async () => {
2215
+ let existing = {};
2216
+ try {
2217
+ const raw = await client.readFile(canonical, { path: projectMetaPath });
2218
+ const parsed = JSON.parse(Buffer.from(raw).toString("utf8"));
2219
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2220
+ existing = parsed;
2221
+ }
2222
+ }
2223
+ catch (error) {
2224
+ if (!(error instanceof SpritesApiError && error.status === 404)) {
2225
+ throw error;
2226
+ }
2227
+ }
2228
+ const existingCheckpoints = existing.checkpoints &&
2229
+ typeof existing.checkpoints === "object" &&
2230
+ !Array.isArray(existing.checkpoints)
2231
+ ? existing.checkpoints
2232
+ : {};
2233
+ const stateCheckpoints = initState?.checkpoints ?? {};
2234
+ const merged = {
2235
+ ...existing,
2236
+ ...projectMeta,
2237
+ checkpoints: { ...existingCheckpoints, ...stateCheckpoints },
2238
+ };
2239
+ await client.writeFile(canonical, projectMetaPath, Buffer.from(JSON.stringify(merged, null, 2)));
2240
+ },
2241
+ });
2242
+ await runInitStep({
2243
+ enabled: progressEnabled,
2244
+ title: "Writing local metadata",
2245
+ fn: async () => {
2246
+ await ensureDevboxToml(repoRoot, repoName, slug);
2247
+ await writeRepoMarker(repoRoot, { fingerprint, canonical, alias });
2248
+ },
2249
+ });
2250
+ await runInitStep({
2251
+ enabled: progressEnabled,
2252
+ title: "Updating Codex config on sprite (optional)",
2253
+ fn: async ({ fail }) => {
2254
+ try {
2255
+ await writeRemoteCodexConfig(client, canonical, repoName);
2256
+ }
2257
+ catch (error) {
2258
+ logger.warn("codex_config_update_failed", {
2259
+ error: String(error),
2260
+ });
2261
+ fail("Updating Codex config on sprite (optional) (failed)");
2262
+ if (!parsed.json) {
2263
+ console.warn("Warning: failed to update /home/sprite/.codex/config.toml for full-access sandbox settings.");
2264
+ }
2265
+ }
2266
+ },
2267
+ });
1070
2268
  try {
1071
2269
  const gitignore = await fs.readFile(path.join(repoRoot, ".gitignore"), "utf8");
1072
2270
  if (!gitignore.includes(".devbox")) {
@@ -1076,42 +2274,143 @@ export const runInit = async (args) => {
1076
2274
  catch {
1077
2275
  // ignore missing .gitignore
1078
2276
  }
2277
+ const skipSetupArtifactsStage = skipCodexApply ||
2278
+ (shouldResume && initState?.steps.setupArtifactsStaged);
2279
+ if (!skipSetupArtifactsStage) {
2280
+ await runInitStep({
2281
+ enabled: progressEnabled,
2282
+ title: "Staging setup artifacts",
2283
+ fn: async ({ status }) => {
2284
+ status.stage("Copying repo artifacts and staging external files");
2285
+ await stageRemoteSetupArtifacts({
2286
+ client,
2287
+ canonical,
2288
+ workdir: expandedWorkdir,
2289
+ artifactsBundlePath: remoteArtifactsBundlePath,
2290
+ artifactsManifestPath: remoteArtifactsManifestPath,
2291
+ });
2292
+ },
2293
+ });
2294
+ await updateInitState({ steps: { setupArtifactsStaged: true } });
2295
+ }
1079
2296
  if (!skipCodexApply) {
1080
- await runRemoteCodexSetup({
1081
- client,
1082
- canonical,
1083
- expandedWorkdir,
1084
- remoteSetupPath,
1085
- remoteArtifactsBundlePath,
1086
- remoteArtifactsManifestPath,
1087
- socketInfo,
1088
- status,
1089
- pathSetup,
1090
- entrypoints: approvedServices?.appEntrypoints ?? [],
2297
+ await runInitStep({
2298
+ enabled: progressEnabled,
2299
+ title: "Snapshotting filesystem (pre-setup) (optional)",
2300
+ fn: async ({ fail, ok }) => {
2301
+ try {
2302
+ const checkpoint = await recordCodexCheckpoint({
2303
+ client,
2304
+ canonical,
2305
+ phase: "preCodexSetup",
2306
+ });
2307
+ if (checkpoint.id) {
2308
+ ok(`Snapshot created: ${checkpoint.id}`);
2309
+ }
2310
+ }
2311
+ catch (error) {
2312
+ logger.warn("init_checkpoint_create_failed", {
2313
+ box: canonical,
2314
+ fingerprint,
2315
+ phase: "pre-codex-setup",
2316
+ error: error instanceof Error ? error.message : String(error),
2317
+ });
2318
+ fail("Snapshotting filesystem (pre-setup) (optional) (failed)");
2319
+ if (!parsed.json) {
2320
+ console.warn("Warning: failed to create pre-setup filesystem snapshot. Continuing without it.");
2321
+ }
2322
+ }
2323
+ },
2324
+ });
2325
+ if (!skipServicesEnable) {
2326
+ await runInitStep({
2327
+ enabled: progressEnabled,
2328
+ title: "Enabling devbox services",
2329
+ fn: async ({ status }) => {
2330
+ await enableRemoteServices({
2331
+ client,
2332
+ canonical,
2333
+ services: approvedServices?.backgroundServices ?? [],
2334
+ status,
2335
+ });
2336
+ await updateInitState({ steps: { servicesEnabled: true } });
2337
+ },
2338
+ });
2339
+ }
2340
+ await runInitStep({
2341
+ enabled: progressEnabled,
2342
+ title: "Running Codex setup",
2343
+ fn: async ({ status }) => {
2344
+ await runRemoteCodexSetup({
2345
+ client,
2346
+ canonical,
2347
+ expandedWorkdir,
2348
+ remoteSetupPath,
2349
+ remoteArtifactsBundlePath,
2350
+ remoteArtifactsManifestPath,
2351
+ socketInfo,
2352
+ status,
2353
+ pathSetup,
2354
+ entrypoints: approvedServices?.appEntrypoints ?? [],
2355
+ });
2356
+ },
1091
2357
  });
1092
2358
  await updateInitState({ steps: { codexApplied: true } });
1093
2359
  await updateRegistryProjectStatus(resolveInitStatus(initState?.steps, initState?.complete));
2360
+ await runInitStep({
2361
+ enabled: progressEnabled,
2362
+ title: "Snapshotting filesystem (post-setup) (optional)",
2363
+ fn: async ({ fail, ok }) => {
2364
+ try {
2365
+ const checkpoint = await recordCodexCheckpoint({
2366
+ client,
2367
+ canonical,
2368
+ phase: "postCodexSetup",
2369
+ });
2370
+ if (checkpoint.id) {
2371
+ ok(`Snapshot created: ${checkpoint.id}`);
2372
+ }
2373
+ }
2374
+ catch (error) {
2375
+ logger.warn("init_checkpoint_create_failed", {
2376
+ box: canonical,
2377
+ fingerprint,
2378
+ phase: "post-codex-setup",
2379
+ error: error instanceof Error ? error.message : String(error),
2380
+ });
2381
+ fail("Snapshotting filesystem (post-setup) (optional) (failed)");
2382
+ if (!parsed.json) {
2383
+ console.warn("Warning: failed to create post-setup filesystem snapshot. Continuing without it.");
2384
+ }
2385
+ }
2386
+ },
2387
+ });
1094
2388
  }
1095
- await updateInitState({ complete: true });
1096
- await updateRegistryProjectStatus("complete");
1097
- status.stop();
2389
+ const finalStatus = resolveInitStatus(initState?.steps, initState?.complete);
2390
+ const isComplete = finalStatus === "complete";
2391
+ await runInitStep({
2392
+ enabled: progressEnabled,
2393
+ title: "Finalizing init",
2394
+ fn: async () => {
2395
+ await updateInitState({ complete: isComplete });
2396
+ await updateRegistryProjectStatus(finalStatus);
2397
+ },
2398
+ });
1098
2399
  if (parsed.json) {
1099
- console.log(JSON.stringify({ ok: true, canonical, alias, workdir, fingerprint }, null, 2));
2400
+ console.log(JSON.stringify({ ok: true, status: finalStatus, canonical, alias, workdir, fingerprint }, null, 2));
2401
+ return;
2402
+ }
2403
+ if (!isComplete) {
2404
+ console.log(`devbox initialized (setup incomplete): ${alias} -> ${canonical}`);
2405
+ console.log(`workdir: ${workdir}`);
2406
+ console.log("next: run `dvb init --resume` from this repo to finish setup");
2407
+ console.log("sprites: synced to control plane");
1100
2408
  return;
1101
2409
  }
1102
2410
  console.log(`devbox initialized: ${alias} -> ${canonical}`);
1103
2411
  console.log(`workdir: ${workdir}`);
1104
2412
  console.log("sprites: synced to control plane");
1105
2413
  };
1106
- try {
1107
- await run();
1108
- }
1109
- catch (error) {
1110
- status.fail("Init failed");
1111
- throw error;
1112
- }
1113
- finally {
1114
- status.stop();
1115
- }
2414
+ await run();
1116
2415
  };
1117
2416
  //# sourceMappingURL=index.js.map