@ycs77/devbox 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs ADDED
@@ -0,0 +1,1835 @@
1
+ import { cac } from "cac";
2
+ import * as p from "@clack/prompts";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { lstat, mkdir, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, isAbsolute, join, parse, relative, sep } from "node:path";
7
+ import { execFile, spawn } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import { parse as parse$1, parseDocument, stringify } from "yaml";
10
+ import { once } from "node:events";
11
+ //#region src/catalog/index.ts
12
+ const NODE_RELEASE_KEYS = [
13
+ "5BE8A3F6C8A5C01D106C0AD820B1A390B168D356",
14
+ "DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7",
15
+ "CC68F5A3106FF448322E48ED27F5E38D5B0A215F",
16
+ "8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600",
17
+ "890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4",
18
+ "C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C",
19
+ "108F52B48DB57BB0CC439B2997B01419BD92F80A",
20
+ "A363A499291CBBC940DD62E41F10027AF002F8B0",
21
+ "655F3B5C1FB3FA8D1A0CA6BDE4A7D232B936D2FD"
22
+ ];
23
+ const PACKAGED_NODE_RECIPES = {
24
+ "24": {
25
+ releaseLine: "24",
26
+ version: "24.19.0",
27
+ runtimeRoot: "/opt/devbox/runtimes/node/24",
28
+ trustedReleaseKeys: NODE_RELEASE_KEYS
29
+ },
30
+ "22": {
31
+ releaseLine: "22",
32
+ version: "22.23.2",
33
+ runtimeRoot: "/opt/devbox/runtimes/node/22",
34
+ trustedReleaseKeys: NODE_RELEASE_KEYS
35
+ }
36
+ };
37
+ const PACKAGED_NODE_RELEASE_LINES = ["24", "22"];
38
+ const PACKAGED_AGENTS = {
39
+ "claude-code": {
40
+ home: {
41
+ volumeName: "devbox-claude",
42
+ target: "/home/devbox/.claude"
43
+ },
44
+ installation: {
45
+ url: "https://claude.ai/install.sh",
46
+ shell: "bash"
47
+ },
48
+ supportsNotifications: true,
49
+ supportsSkillInstallation: true
50
+ },
51
+ codex: {
52
+ home: {
53
+ volumeName: "devbox-codex",
54
+ target: "/home/devbox/.codex"
55
+ },
56
+ installation: {
57
+ url: "https://chatgpt.com/codex/install.sh",
58
+ shell: "sh"
59
+ },
60
+ supportsNotifications: true,
61
+ supportsSkillInstallation: true
62
+ },
63
+ agy: {
64
+ home: {
65
+ volumeName: "devbox-agy",
66
+ target: "/home/devbox/.gemini"
67
+ },
68
+ installation: {
69
+ url: "https://antigravity.google/cli/install.sh",
70
+ shell: "bash"
71
+ },
72
+ supportsNotifications: false,
73
+ supportsSkillInstallation: false
74
+ },
75
+ omp: {
76
+ home: {
77
+ volumeName: "devbox-omp",
78
+ target: "/home/devbox/.omp"
79
+ },
80
+ installation: {
81
+ url: "https://omp.sh/install",
82
+ shell: "sh"
83
+ },
84
+ supportsNotifications: true,
85
+ supportsSkillInstallation: false
86
+ }
87
+ };
88
+ const PACKAGED_RUNTIME_CATALOG = {
89
+ runtimes: { node: PACKAGED_NODE_RELEASE_LINES },
90
+ agents: Object.keys(PACKAGED_AGENTS)
91
+ };
92
+ //#endregion
93
+ //#region src/result.ts
94
+ function success(value) {
95
+ return {
96
+ ok: true,
97
+ value
98
+ };
99
+ }
100
+ function failure(error) {
101
+ return {
102
+ ok: false,
103
+ error
104
+ };
105
+ }
106
+ //#endregion
107
+ //#region src/host.ts
108
+ const execFile$1 = promisify(execFile);
109
+ var HostCommandError = class extends Error {
110
+ exitCode;
111
+ constructor(file, args, exitCode) {
112
+ super(`${file} ${args.join(" ")} exited with status ${exitCode}.`);
113
+ this.exitCode = exitCode;
114
+ this.name = "HostCommandError";
115
+ }
116
+ };
117
+ const supportedHostAction = "Start Docker Desktop and ensure Docker Compose is available.";
118
+ function currentHostEnvironment() {
119
+ return {
120
+ run: async (file, args) => {
121
+ await execFile$1(file, args);
122
+ },
123
+ runDirect: async (file, args) => new Promise((resolve, reject) => {
124
+ const command = spawn(file, args, { stdio: "inherit" });
125
+ command.once("error", reject);
126
+ command.once("close", (code) => {
127
+ if (code === 0) {
128
+ resolve();
129
+ return;
130
+ }
131
+ reject(new HostCommandError(file, args, code ?? 1));
132
+ });
133
+ })
134
+ };
135
+ }
136
+ async function validateSupportedHost(environment = currentHostEnvironment()) {
137
+ for (const [file, args, description] of [[
138
+ "docker",
139
+ ["--version"],
140
+ "Docker"
141
+ ], [
142
+ "docker",
143
+ ["compose", "version"],
144
+ "Docker Compose"
145
+ ]]) try {
146
+ await environment.run(file, args);
147
+ } catch {
148
+ return failure({
149
+ kind: "validation",
150
+ code: "missing-host-prerequisite",
151
+ observed: `${description} is unavailable.`,
152
+ nextAction: supportedHostAction
153
+ });
154
+ }
155
+ return success(void 0);
156
+ }
157
+ //#endregion
158
+ //#region src/agent/volumes.ts
159
+ async function ensureSharedAgentVolumes(configuredAgents, environment = currentHostEnvironment()) {
160
+ for (const configuredAgent of configuredAgents) {
161
+ const volumeName = PACKAGED_AGENTS[configuredAgent]?.home.volumeName;
162
+ if (volumeName !== void 0) await environment.run("docker", [
163
+ "volume",
164
+ "create",
165
+ "--driver",
166
+ "local",
167
+ volumeName
168
+ ]);
169
+ }
170
+ }
171
+ //#endregion
172
+ //#region src/configuration/index.ts
173
+ function normalizeCatalog(catalog = PACKAGED_RUNTIME_CATALOG) {
174
+ if (!isRecord(catalog.runtimes) || !Array.isArray(catalog.agents)) return invalid$1("invalid-runtime-catalog", "The packaged Runtime catalog is invalid.");
175
+ const runtimes = {};
176
+ for (const [family, entries] of Object.entries(catalog.runtimes)) {
177
+ if (!isCatalogName(family) || !Array.isArray(entries) || entries.length === 0) return invalid$1("invalid-runtime-catalog", "The packaged Runtime catalog is invalid.");
178
+ const normalizedEntries = [];
179
+ for (const entry of entries) {
180
+ if (typeof entry !== "string" || entry.length === 0 || normalizedEntries.includes(entry)) return invalid$1("invalid-runtime-catalog", "The packaged Runtime catalog is invalid.");
181
+ normalizedEntries.push(entry);
182
+ }
183
+ runtimes[family] = normalizedEntries;
184
+ }
185
+ const agents = [];
186
+ for (const agent of catalog.agents) {
187
+ if (typeof agent !== "string" || !isCatalogName(agent) || agents.includes(agent)) return invalid$1("invalid-runtime-catalog", "The packaged Agent catalog is invalid.");
188
+ agents.push(agent);
189
+ }
190
+ return success({
191
+ runtimes,
192
+ agents
193
+ });
194
+ }
195
+ function defaultGlobalConfiguration(catalog = PACKAGED_RUNTIME_CATALOG) {
196
+ return {
197
+ version: 1,
198
+ node: catalog.runtimes.node?.[0] === void 0 ? [] : [catalog.runtimes.node[0]],
199
+ agent: [],
200
+ agent_notifications: true
201
+ };
202
+ }
203
+ function defaultLocalConfiguration(globalConfiguration) {
204
+ return {
205
+ version: 1,
206
+ node: globalConfiguration.node[0] ?? null
207
+ };
208
+ }
209
+ function parseGlobalConfiguration(source, catalog = PACKAGED_RUNTIME_CATALOG) {
210
+ const parsed = parseYaml(source);
211
+ if (!parsed.ok) return invalid$1("invalid-global-configuration", parsed.observed);
212
+ const document = parsed.value;
213
+ if (!isRecord(document) || !hasExactKeys$1(document, [
214
+ "version",
215
+ "node",
216
+ "agent",
217
+ "agent_notifications"
218
+ ])) return invalid$1("invalid-global-configuration", "Global configuration contains unknown or missing fields.");
219
+ if (document.version !== 1) return invalid$1("invalid-global-configuration", "Global configuration must use version: 1.");
220
+ if (!Array.isArray(document.node) || !Array.isArray(document.agent)) return invalid$1("invalid-global-configuration", "Global configuration has invalid values.");
221
+ if (typeof document.agent_notifications !== "boolean") return invalid$1("invalid-global-configuration", "Global configuration agent_notifications must be true or false.");
222
+ const node = [];
223
+ for (const rawEntry of document.node) {
224
+ const entry = runtimeLine(rawEntry);
225
+ if (entry === void 0 || !catalog.runtimes.node?.includes(entry)) return invalid$1("invalid-global-configuration", `Node Runtime is not in the packaged catalog: ${String(rawEntry)}.`);
226
+ if (node.includes(entry)) return invalid$1("invalid-global-configuration", `Node Runtime is duplicated: ${entry}.`);
227
+ node.push(entry);
228
+ }
229
+ const agent = [];
230
+ for (const rawAgent of document.agent) {
231
+ if (typeof rawAgent !== "string" || !catalog.agents.includes(rawAgent)) return invalid$1("invalid-global-configuration", `Agent is not in the packaged catalog: ${String(rawAgent)}.`);
232
+ if (agent.includes(rawAgent)) return invalid$1("invalid-global-configuration", `Agent is duplicated: ${rawAgent}.`);
233
+ agent.push(rawAgent);
234
+ }
235
+ return success({
236
+ version: 1,
237
+ node,
238
+ agent,
239
+ agent_notifications: document.agent_notifications
240
+ });
241
+ }
242
+ function parseLocalConfiguration(source, globalConfiguration, catalog = PACKAGED_RUNTIME_CATALOG) {
243
+ const parsed = parseYaml(source);
244
+ if (!parsed.ok) return invalid$1("invalid-local-configuration", parsed.observed);
245
+ const document = parsed.value;
246
+ if (!isRecord(document) || !hasExactKeys$1(document, ["version", "node"])) return invalid$1("invalid-local-configuration", "Local configuration contains unknown or missing fields.");
247
+ if (document.version !== 1) return invalid$1("invalid-local-configuration", "Local configuration must use version: 1.");
248
+ const node = runtimeLineOrNull(document.node);
249
+ if (node === void 0) return invalid$1("invalid-local-configuration", `Node Runtime is invalid: ${String(document.node)}.`);
250
+ if (node !== null && !catalog.runtimes.node?.includes(node)) return invalid$1("invalid-local-configuration", `Node Runtime is not in the packaged catalog: ${String(document.node)}.`);
251
+ if (node !== null && !globalConfiguration.node.includes(node)) return invalid$1("unconfigured-runtime-selection", `Local configuration selects a Node Runtime that is not configured globally: ${node}.`);
252
+ return success({
253
+ version: 1,
254
+ node
255
+ });
256
+ }
257
+ function serializeGlobalConfiguration(configuration) {
258
+ return stringify({
259
+ version: 1,
260
+ node: [...configuration.node],
261
+ agent: [...configuration.agent],
262
+ agent_notifications: configuration.agent_notifications
263
+ });
264
+ }
265
+ function serializeLocalConfiguration(configuration) {
266
+ return stringify({
267
+ version: 1,
268
+ node: configuration.node
269
+ });
270
+ }
271
+ function configurationsEqual(left, right) {
272
+ return JSON.stringify(left) === JSON.stringify(right);
273
+ }
274
+ function parseYaml(source) {
275
+ try {
276
+ const document = parseDocument(source, {
277
+ strict: true,
278
+ uniqueKeys: true,
279
+ version: "1.2"
280
+ });
281
+ if (document.errors.length > 0) return {
282
+ ok: false,
283
+ observed: document.errors[0]?.message ?? "YAML is invalid."
284
+ };
285
+ return {
286
+ ok: true,
287
+ value: document.toJS()
288
+ };
289
+ } catch (error) {
290
+ return {
291
+ ok: false,
292
+ observed: error instanceof Error ? error.message : "YAML is invalid."
293
+ };
294
+ }
295
+ }
296
+ function runtimeLine(value) {
297
+ if (typeof value === "string" && value.length > 0) return value;
298
+ if (typeof value === "number" && Number.isInteger(value)) return String(value);
299
+ }
300
+ function runtimeLineOrNull(value) {
301
+ if (value === null) return null;
302
+ return runtimeLine(value);
303
+ }
304
+ function isRecord(value) {
305
+ if (typeof value !== "object" || value === null) return false;
306
+ const prototype = Object.getPrototypeOf(value);
307
+ return prototype === Object.prototype || prototype === null;
308
+ }
309
+ function hasExactKeys$1(value, keys) {
310
+ const expected = new Set(keys);
311
+ const actual = Object.keys(value);
312
+ return actual.length === expected.size && actual.every((key) => expected.has(key));
313
+ }
314
+ function isCatalogName(value) {
315
+ return /^[a-z][a-z0-9-]*$/.test(value);
316
+ }
317
+ function invalid$1(code, observed) {
318
+ return failure({
319
+ kind: "validation",
320
+ code,
321
+ observed,
322
+ nextAction: "Edit the supported version-1 YAML configuration and try again."
323
+ });
324
+ }
325
+ //#endregion
326
+ //#region src/state-lock/index.ts
327
+ var StateLockInterruptedError = class extends Error {
328
+ constructor() {
329
+ super("State lock acquisition interrupted.");
330
+ this.name = "StateLockInterruptedError";
331
+ }
332
+ };
333
+ var StateLockBusyError = class extends Error {
334
+ scope;
335
+ projectRoot;
336
+ constructor(scope, projectRoot) {
337
+ super("A required Devbox command marker is already present.");
338
+ this.name = "StateLockBusyError";
339
+ this.scope = scope;
340
+ this.projectRoot = projectRoot;
341
+ }
342
+ };
343
+ async function withStateLocks(input, operation) {
344
+ const lockRoot = join(input.devboxHome, "locks");
345
+ const targets = input.global ? [{
346
+ path: join(lockRoot, "global"),
347
+ scope: "global"
348
+ }] : [];
349
+ targets.push(...projectLockTargets(lockRoot, input.projectRoots));
350
+ let devboxHomeExisted = true;
351
+ try {
352
+ await lstat(input.devboxHome);
353
+ } catch (error) {
354
+ if (isMissingFileError$1(error)) devboxHomeExisted = false;
355
+ else throw error;
356
+ }
357
+ const acquired = [];
358
+ const acquireTargets = async (targetsToAcquire) => {
359
+ for (const target of targetsToAcquire) {
360
+ if (input.signal?.aborted) throw new StateLockInterruptedError();
361
+ await acquireLock(target);
362
+ acquired.push(target.path);
363
+ }
364
+ };
365
+ try {
366
+ if (input.signal?.aborted) throw new StateLockInterruptedError();
367
+ await mkdir(lockRoot, {
368
+ recursive: true,
369
+ mode: 448
370
+ });
371
+ await acquireTargets(targets);
372
+ if (input.signal?.aborted) throw new StateLockInterruptedError();
373
+ return await operation({ acquireProjectScopes: async (projectRoots) => {
374
+ await acquireTargets(projectLockTargets(lockRoot, projectRoots));
375
+ } });
376
+ } catch (error) {
377
+ if (error instanceof StateLockBusyError) return failure({
378
+ kind: "operational",
379
+ code: "command-lock-busy",
380
+ observed: error.scope === "global" ? "The Global command marker is already occupied." : `The Project command marker is already occupied for ${error.projectRoot}.`,
381
+ nextAction: `Wait for the other Devbox command to finish and try again. If no Devbox process is running, remove the residual marker under ${input.devboxHome}/locks/ and try again.`
382
+ });
383
+ throw error;
384
+ } finally {
385
+ for (const lockPath of acquired.reverse()) await rm(lockPath, {
386
+ recursive: true,
387
+ force: true
388
+ }).catch(() => void 0);
389
+ await rmdir(lockRoot).catch(() => void 0);
390
+ if (!devboxHomeExisted) await rmdir(input.devboxHome).catch(() => void 0);
391
+ }
392
+ }
393
+ function projectLockTargets(lockRoot, projectRoots) {
394
+ const targets = [];
395
+ const sortedRoots = [...new Set(projectRoots)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
396
+ for (const projectRoot of sortedRoots) {
397
+ const hash = createHash("sha256").update(projectRoot).digest("hex");
398
+ targets.push({
399
+ path: join(lockRoot, `project-${hash}`),
400
+ scope: "project",
401
+ projectRoot
402
+ });
403
+ }
404
+ return targets;
405
+ }
406
+ async function acquireLock(target) {
407
+ try {
408
+ await mkdir(target.path, { mode: 448 });
409
+ } catch (error) {
410
+ if (isFileExistsError(error)) throw new StateLockBusyError(target.scope, target.projectRoot);
411
+ throw error;
412
+ }
413
+ }
414
+ function isFileExistsError(error) {
415
+ return typeof error === "object" && error !== null && error.code === "EEXIST";
416
+ }
417
+ function isMissingFileError$1(error) {
418
+ return typeof error === "object" && error !== null && error.code === "ENOENT";
419
+ }
420
+ //#endregion
421
+ //#region src/workspace/image.ts
422
+ const WORKSPACE_IMAGE = "devbox-workspace:latest";
423
+ //#endregion
424
+ //#region src/project/compose-fragments.ts
425
+ const NOTIFICATION_FRAGMENT = {
426
+ environment: { PULSE_SERVER: "unix:/tmp/pulse-socket" },
427
+ volume: {
428
+ source: "/mnt/wslg/runtime-dir/pulse/native",
429
+ target: "/tmp/pulse-socket",
430
+ readOnly: true
431
+ }
432
+ };
433
+ function selectComposeFragments({ selectedNode, configuredAgents, agentNotifications }) {
434
+ const agents = [];
435
+ let hasNotificationAgent = false;
436
+ for (const agent of configuredAgents) {
437
+ const packagedAgent = PACKAGED_AGENTS[agent];
438
+ if (packagedAgent !== void 0) {
439
+ agents.push({
440
+ agent,
441
+ volume: {
442
+ name: packagedAgent.home.volumeName,
443
+ target: packagedAgent.home.target,
444
+ external: true
445
+ }
446
+ });
447
+ hasNotificationAgent ||= packagedAgent.supportsNotifications;
448
+ }
449
+ }
450
+ return {
451
+ node: selectedNode === null ? void 0 : { environment: { NODE_VERSION: selectedNode } },
452
+ agents,
453
+ notification: agentNotifications && hasNotificationAgent ? NOTIFICATION_FRAGMENT : void 0
454
+ };
455
+ }
456
+ //#endregion
457
+ //#region src/project/compose.ts
458
+ function renderProjectCompose(input) {
459
+ const fragments = selectComposeFragments(input);
460
+ const environment = {
461
+ ...fragments.node?.environment,
462
+ ...fragments.notification?.environment
463
+ };
464
+ const volumes = [
465
+ {
466
+ type: "bind",
467
+ source: input.projectRoot,
468
+ target: `/workspace/${input.sandboxName}`
469
+ },
470
+ ...fragments.agents.map((fragment) => ({
471
+ type: "volume",
472
+ source: fragment.volume.name,
473
+ target: fragment.volume.target
474
+ })),
475
+ ...input.claudeHostConfiguration === void 0 ? [] : [{
476
+ type: "bind",
477
+ source: input.claudeHostConfiguration,
478
+ target: "/home/devbox/.claude.json"
479
+ }],
480
+ ...fragments.notification === void 0 ? [] : [{
481
+ type: "bind",
482
+ source: fragments.notification.volume.source,
483
+ target: fragments.notification.volume.target,
484
+ read_only: fragments.notification.volume.readOnly
485
+ }]
486
+ ];
487
+ const definition = {
488
+ name: input.sandboxName,
489
+ "x-devbox": {
490
+ version: 1,
491
+ project_root: input.projectRoot,
492
+ compose_name: input.sandboxName,
493
+ sandbox_service: "devbox"
494
+ },
495
+ services: { devbox: {
496
+ image: WORKSPACE_IMAGE,
497
+ container_name: input.sandboxName,
498
+ working_dir: `/workspace/${input.sandboxName}`,
499
+ ...Object.keys(environment).length === 0 ? {} : { environment },
500
+ volumes
501
+ } },
502
+ ...fragments.agents.length === 0 ? {} : { volumes: Object.fromEntries(fragments.agents.map((fragment) => [fragment.volume.name, {
503
+ name: fragment.volume.name,
504
+ external: fragment.volume.external
505
+ }])) }
506
+ };
507
+ const source = stringify(definition);
508
+ return validatesProjectCompose(source, input) ? success(source) : failure({
509
+ kind: "operational",
510
+ code: "invalid-project-compose",
511
+ observed: `Devbox generated an invalid Compose definition for Project ${input.projectRoot}.`,
512
+ nextAction: "Run the command again. If the problem continues, report it as a Devbox bug."
513
+ });
514
+ }
515
+ function validatesProjectCompose(source, input) {
516
+ try {
517
+ const definition = parse$1(source);
518
+ if (definition.name !== input.sandboxName) return false;
519
+ const metadata = definition["x-devbox"];
520
+ if (metadata.version !== 1 || metadata.project_root !== input.projectRoot || metadata.compose_name !== input.sandboxName || metadata.sandbox_service !== "devbox") return false;
521
+ const service = definition.services.devbox;
522
+ if (service.image !== "devbox-workspace:latest" || service.container_name !== input.sandboxName || service.working_dir !== `/workspace/${input.sandboxName}` || "build" in service) return false;
523
+ return input.selectedNode === null && service.environment?.NODE_VERSION === void 0 || input.selectedNode !== null && service.environment?.NODE_VERSION === input.selectedNode;
524
+ } catch {
525
+ return false;
526
+ }
527
+ }
528
+ //#endregion
529
+ //#region src/project/registry.ts
530
+ function parseProjectRegistry(source) {
531
+ let document;
532
+ try {
533
+ const parsed = parseDocument(source, {
534
+ strict: true,
535
+ uniqueKeys: true,
536
+ version: "1.2"
537
+ });
538
+ if (parsed.errors.length > 0) return invalid(parsed.errors[0]?.message ?? "Project registry YAML is invalid.");
539
+ document = parsed.toJS();
540
+ } catch (error) {
541
+ return invalid(error instanceof Error ? error.message : "Project registry YAML is invalid.");
542
+ }
543
+ if (!isPlainObject(document) || !hasExactKeys(document, ["version", "projects"]) || document.version !== 1 || !isPlainObject(document.projects)) return invalid("Project registry must contain only version: 1 and projects.");
544
+ const projects = {};
545
+ const assignedIdentities = /* @__PURE__ */ new Set();
546
+ const assignedNames = /* @__PURE__ */ new Set();
547
+ for (const [root, registration] of Object.entries(document.projects)) {
548
+ if (!isAbsolute(root)) return invalid(`Project root must be an exact absolute path: ${root}.`);
549
+ if (!isPlainObject(registration) || !hasExactKeys(registration, ["identity", "name"]) || typeof registration.identity !== "string" || !isSafeStateDirectoryName(registration.identity) || assignedIdentities.has(registration.identity)) return invalid("Sandbox identity assignment is invalid.");
550
+ if (typeof registration.name !== "string" || !isSafeSandboxName(registration.name) || assignedNames.has(registration.name)) return invalid("Sandbox name assignment is invalid.");
551
+ assignedIdentities.add(registration.identity);
552
+ assignedNames.add(registration.name);
553
+ projects[root] = {
554
+ identity: registration.identity,
555
+ name: registration.name
556
+ };
557
+ }
558
+ return success({
559
+ version: 1,
560
+ projects
561
+ });
562
+ }
563
+ function serializeProjectRegistry(registry) {
564
+ const projects = Object.fromEntries(Object.entries(registry.projects).sort(([left], [right]) => {
565
+ if (left < right) return -1;
566
+ if (left > right) return 1;
567
+ return 0;
568
+ }));
569
+ return stringify({
570
+ version: 1,
571
+ projects
572
+ });
573
+ }
574
+ function isSafeStateDirectoryName(name) {
575
+ return /^(?:[A-Za-z0-9_%~-]+)(?:-[2-9][0-9]*)?$/.test(name) && name !== "." && name !== "..";
576
+ }
577
+ function isSafeSandboxName(name) {
578
+ return /^[a-z0-9][a-z0-9_-]+$/.test(name);
579
+ }
580
+ function hasExactKeys(value, keys) {
581
+ const expected = new Set(keys);
582
+ const actual = Object.keys(value);
583
+ return actual.length === expected.size && actual.every((key) => expected.has(key));
584
+ }
585
+ function isPlainObject(value) {
586
+ if (typeof value !== "object" || value === null) return false;
587
+ const prototype = Object.getPrototypeOf(value);
588
+ return prototype === Object.prototype || prototype === null;
589
+ }
590
+ function invalid(observed) {
591
+ return failure({
592
+ kind: "validation",
593
+ code: "invalid-project-registry",
594
+ observed,
595
+ nextAction: "Repair ~/.devbox/projects.yaml using the supported version-1 registry schema."
596
+ });
597
+ }
598
+ //#endregion
599
+ //#region src/project/index.ts
600
+ function devboxPaths(devboxHome = join(homedir(), ".devbox")) {
601
+ return {
602
+ home: devboxHome,
603
+ globalConfiguration: join(devboxHome, "config.yaml"),
604
+ projectRegistry: join(devboxHome, "projects.yaml"),
605
+ projects: join(devboxHome, "projects"),
606
+ buildContext: join(devboxHome, "build"),
607
+ claudeHostConfiguration: join(devboxHome, "agents", "claude", ".claude.json")
608
+ };
609
+ }
610
+ function sandboxIdentity(projectRoot) {
611
+ const root = parse(projectRoot).root;
612
+ const pathWithinRoot = relative(root, projectRoot);
613
+ if (!isAbsolute(projectRoot) || pathWithinRoot === ".." || pathWithinRoot.startsWith(`..${sep}`)) throw new TypeError("Project root must be an absolute path.");
614
+ return pathWithinRoot === "" ? "root" : pathWithinRoot.split(sep).map(escapePathSegment).join("-");
615
+ }
616
+ function projectStateDirectory(sandboxIdentity, devboxHome) {
617
+ if (!isSafeStateDirectoryName(sandboxIdentity)) throw new TypeError("Sandbox identity must be a safe state directory name.");
618
+ return join(devboxHome, "projects", sandboxIdentity);
619
+ }
620
+ function sandboxName(projectRoot) {
621
+ const normalized = basename(projectRoot).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/-+$/, "");
622
+ return normalized.length < 2 ? `project-${normalized || "root"}` : normalized;
623
+ }
624
+ function escapePathSegment(segment) {
625
+ const bytes = new TextEncoder().encode(segment);
626
+ let escaped = "";
627
+ for (const byte of bytes) if (byte >= 65 && byte <= 90 || byte >= 97 && byte <= 122 || byte >= 48 && byte <= 57 || byte === 45 || byte === 95) escaped += String.fromCharCode(byte);
628
+ else escaped += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
629
+ return escaped;
630
+ }
631
+ async function initializeProject(input = {}) {
632
+ return withStateLocks({
633
+ devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
634
+ global: true,
635
+ projectRoots: [input.root ?? process.cwd()],
636
+ signal: input.signal
637
+ }, () => initializeProjectUnlocked(input));
638
+ }
639
+ async function initializeProjectUnlocked(input = {}) {
640
+ const projectRoot = input.root ?? process.cwd();
641
+ const rootCheck = await validateProjectRoot(projectRoot);
642
+ if (!rootCheck.ok) return rootCheck;
643
+ if (input.signal?.aborted) throw new InterruptedError();
644
+ const hostCheck = await (input.validateHost ?? validateSupportedHost)();
645
+ if (!hostCheck.ok) return hostCheck;
646
+ if (input.signal?.aborted) throw new InterruptedError();
647
+ const catalogCheck = normalizeCatalog(input.catalog);
648
+ if (!catalogCheck.ok) return catalogCheck;
649
+ const catalog = catalogCheck.value;
650
+ const paths = devboxPaths(input.devboxHome);
651
+ const registryState = await readOptionalFile(paths.projectRegistry);
652
+ if (!registryState.ok) return registryState;
653
+ const registryCheck = registryState.value.exists ? parseProjectRegistry(registryState.value.content) : success({
654
+ version: 1,
655
+ projects: {}
656
+ });
657
+ if (!registryCheck.ok) return registryCheck;
658
+ const registry = registryCheck.value;
659
+ const registration = registry.projects[projectRoot];
660
+ let identity;
661
+ let name;
662
+ if (registration !== void 0) {
663
+ identity = registration.identity;
664
+ name = registration.name;
665
+ } else {
666
+ const allocatedIdentity = await allocateSandboxIdentity(projectRoot, paths, registry);
667
+ if (!allocatedIdentity.ok) return allocatedIdentity;
668
+ const allocatedName = allocateSandboxName(projectRoot, registry);
669
+ if (!allocatedName.ok) return allocatedName;
670
+ identity = allocatedIdentity.value;
671
+ name = allocatedName.value;
672
+ }
673
+ const stateDirectory = projectStateDirectory(identity, paths.home);
674
+ const registered = registration !== void 0;
675
+ const globalState = await readOptionalFile(paths.globalConfiguration);
676
+ if (!globalState.ok) return globalState;
677
+ if (registered && !globalState.value.exists) return missingConfiguration("global", paths.globalConfiguration);
678
+ let globalConfiguration;
679
+ let globalChanged = false;
680
+ if (globalState.value.exists) {
681
+ const parsedGlobal = parseGlobalConfiguration(globalState.value.content, catalog);
682
+ if (!parsedGlobal.ok) return parsedGlobal;
683
+ globalConfiguration = parsedGlobal.value;
684
+ } else if (input.initialGlobalConfiguration !== void 0) {
685
+ const initialGlobal = validateGlobalObject(input.initialGlobalConfiguration, catalog);
686
+ if (!initialGlobal.ok) return initialGlobal;
687
+ globalConfiguration = initialGlobal.value;
688
+ globalChanged = true;
689
+ } else {
690
+ globalConfiguration = defaultGlobalConfiguration(catalog);
691
+ if (input.prompt?.editGlobal) {
692
+ const checkedGlobal = validateGlobalObject(await input.prompt.editGlobal(globalConfiguration, catalog), catalog);
693
+ if (!checkedGlobal.ok) return checkedGlobal;
694
+ globalConfiguration = checkedGlobal.value;
695
+ }
696
+ globalChanged = true;
697
+ }
698
+ const localPath = join(stateDirectory, "config.yaml");
699
+ const localState = registered ? await readOptionalFile(localPath) : success({
700
+ exists: false,
701
+ content: ""
702
+ });
703
+ if (!localState.ok) return localState;
704
+ if (registered && !localState.value.exists) return missingConfiguration("local", localPath);
705
+ let localConfiguration;
706
+ let localChanged = false;
707
+ if (localState.value.exists) {
708
+ const parsedLocal = parseLocalConfiguration(localState.value.content, globalConfiguration, catalog);
709
+ if (!parsedLocal.ok) return parsedLocal;
710
+ localConfiguration = parsedLocal.value;
711
+ } else if (input.initialLocalConfiguration !== void 0) {
712
+ const initialLocal = validateLocalObject(input.initialLocalConfiguration, globalConfiguration, catalog);
713
+ if (!initialLocal.ok) return initialLocal;
714
+ localConfiguration = initialLocal.value;
715
+ localChanged = true;
716
+ } else {
717
+ localConfiguration = defaultLocalConfiguration(globalConfiguration);
718
+ if (input.prompt?.editLocal) {
719
+ const checkedLocal = validateLocalObject(await input.prompt.editLocal(localConfiguration, catalog, globalConfiguration), globalConfiguration, catalog);
720
+ if (!checkedLocal.ok) return checkedLocal;
721
+ localConfiguration = checkedLocal.value;
722
+ }
723
+ localChanged = true;
724
+ }
725
+ const composeWrite = await composeStateWrite({
726
+ projectRoot,
727
+ sandboxName: name,
728
+ stateDirectory,
729
+ devboxHome: paths.home,
730
+ globalConfiguration,
731
+ localConfiguration
732
+ });
733
+ if (!composeWrite.ok) return composeWrite;
734
+ if (registered) {
735
+ if (!composeWrite.value.previous.exists || composeWrite.value.content !== composeWrite.value.previous.content) {
736
+ const written = await writeStateTransaction([composeWrite.value]);
737
+ if (!written.ok) return written;
738
+ }
739
+ return success({
740
+ root: projectRoot,
741
+ stateDirectory,
742
+ sandboxName: name,
743
+ created: false,
744
+ confirmed: true
745
+ });
746
+ }
747
+ if (!await (input.confirm ?? input.prompt?.confirm ?? (async () => true))(`Register Project ${projectRoot} and write its configuration?`)) return success({
748
+ root: projectRoot,
749
+ stateDirectory,
750
+ sandboxName: name,
751
+ created: false,
752
+ confirmed: false
753
+ });
754
+ const nextRegistry = {
755
+ version: 1,
756
+ projects: {
757
+ ...registry.projects,
758
+ [projectRoot]: {
759
+ identity,
760
+ name
761
+ }
762
+ }
763
+ };
764
+ const written = await writeStateTransaction([
765
+ ...globalChanged ? [{
766
+ path: paths.globalConfiguration,
767
+ content: serializeGlobalConfiguration(globalConfiguration),
768
+ previous: globalState.value
769
+ }] : [],
770
+ ...localChanged ? [{
771
+ path: localPath,
772
+ content: serializeLocalConfiguration(localConfiguration),
773
+ previous: localState.value
774
+ }] : [],
775
+ {
776
+ path: paths.projectRegistry,
777
+ content: serializeProjectRegistry(nextRegistry),
778
+ previous: registryState.value
779
+ },
780
+ composeWrite.value
781
+ ]);
782
+ if (!written.ok) return written;
783
+ return success({
784
+ root: projectRoot,
785
+ stateDirectory,
786
+ sandboxName: name,
787
+ created: true,
788
+ confirmed: true
789
+ });
790
+ }
791
+ async function configureLocalProject(input = {}) {
792
+ return withStateLocks({
793
+ devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
794
+ global: true,
795
+ projectRoots: [input.root ?? process.cwd()],
796
+ signal: input.signal
797
+ }, () => configureLocalProjectUnlocked(input));
798
+ }
799
+ async function configureLocalProjectUnlocked(input = {}) {
800
+ if (input.signal?.aborted) throw new InterruptedError();
801
+ const catalogCheck = normalizeCatalog(input.catalog);
802
+ if (!catalogCheck.ok) return catalogCheck;
803
+ const catalog = catalogCheck.value;
804
+ const projectRoot = input.root ?? process.cwd();
805
+ const paths = devboxPaths(input.devboxHome);
806
+ const registryCheck = await readRegistry(paths.projectRegistry);
807
+ if (!registryCheck.ok) return registryCheck;
808
+ const registration = registryCheck.value.projects[projectRoot];
809
+ if (registration === void 0) return notRegistered(projectRoot);
810
+ const globalState = await readOptionalFile(paths.globalConfiguration);
811
+ if (!globalState.ok) return globalState;
812
+ if (!globalState.value.exists) return missingConfiguration("global", paths.globalConfiguration);
813
+ const globalCheck = parseGlobalConfiguration(globalState.value.content, catalog);
814
+ if (!globalCheck.ok) return globalCheck;
815
+ const stateDirectory = projectStateDirectory(registration.identity, paths.home);
816
+ const localPath = join(stateDirectory, "config.yaml");
817
+ const localState = await readOptionalFile(localPath);
818
+ if (!localState.ok) return localState;
819
+ if (!localState.value.exists) return missingConfiguration("local", localPath);
820
+ const localCheck = parseLocalConfiguration(localState.value.content, globalCheck.value, catalog);
821
+ if (!localCheck.ok) return localCheck;
822
+ let nextConfiguration = input.nextConfiguration ?? localCheck.value;
823
+ if (input.nextConfiguration === void 0 && input.prompt?.editLocal) nextConfiguration = await input.prompt.editLocal(localCheck.value, catalog, globalCheck.value);
824
+ const nextCheck = validateLocalObject(nextConfiguration, globalCheck.value, catalog);
825
+ if (!nextCheck.ok) return nextCheck;
826
+ if (configurationsEqual(localCheck.value, nextCheck.value)) return success({
827
+ scope: "local",
828
+ root: projectRoot,
829
+ changed: false
830
+ });
831
+ const confirm = input.confirm ?? input.prompt?.confirm ?? (async () => true);
832
+ const changeSummary = `Current: ${serializeLocalConfiguration(localCheck.value).trim()}\nNext: ${serializeLocalConfiguration(nextCheck.value).trim()}`;
833
+ if (!await confirm(`Save Local configuration for ${projectRoot}?`, {
834
+ title: "Local configuration changes",
835
+ content: changeSummary
836
+ })) return success({
837
+ scope: "local",
838
+ root: projectRoot,
839
+ changed: false
840
+ });
841
+ if (input.signal?.aborted) throw new InterruptedError();
842
+ const composeWrite = await composeStateWrite({
843
+ projectRoot,
844
+ sandboxName: registration.name,
845
+ stateDirectory,
846
+ devboxHome: paths.home,
847
+ globalConfiguration: globalCheck.value,
848
+ localConfiguration: nextCheck.value
849
+ });
850
+ if (!composeWrite.ok) return composeWrite;
851
+ const written = await writeStateTransaction([{
852
+ path: localPath,
853
+ content: serializeLocalConfiguration(nextCheck.value),
854
+ previous: localState.value
855
+ }, composeWrite.value]);
856
+ if (!written.ok) return written;
857
+ return success({
858
+ scope: "local",
859
+ root: projectRoot,
860
+ changed: true
861
+ });
862
+ }
863
+ async function configureGlobal(input = {}) {
864
+ return withStateLocks({
865
+ devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
866
+ global: true,
867
+ projectRoots: [],
868
+ signal: input.signal
869
+ }, (context) => configureGlobalUnlocked(input, context));
870
+ }
871
+ async function configureGlobalUnlocked(input = {}, locks) {
872
+ if (input.signal?.aborted) throw new InterruptedError();
873
+ const catalogCheck = normalizeCatalog(input.catalog);
874
+ if (!catalogCheck.ok) return catalogCheck;
875
+ const catalog = catalogCheck.value;
876
+ const paths = devboxPaths(input.devboxHome);
877
+ const globalState = await readOptionalFile(paths.globalConfiguration);
878
+ if (!globalState.ok) return globalState;
879
+ let currentConfiguration;
880
+ if (globalState.value.exists) {
881
+ const currentCheck = parseGlobalConfiguration(globalState.value.content, catalog);
882
+ if (!currentCheck.ok) return currentCheck;
883
+ currentConfiguration = currentCheck.value;
884
+ } else currentConfiguration = defaultGlobalConfiguration(catalog);
885
+ const registryState = await readOptionalFile(paths.projectRegistry);
886
+ if (!registryState.ok) return registryState;
887
+ const registryCheck = registryState.value.exists ? parseProjectRegistry(registryState.value.content) : success({
888
+ version: 1,
889
+ projects: {}
890
+ });
891
+ if (!registryCheck.ok) return registryCheck;
892
+ await locks.acquireProjectScopes(Object.keys(registryCheck.value.projects));
893
+ let nextConfiguration = input.nextConfiguration ?? currentConfiguration;
894
+ if (input.nextConfiguration === void 0 && input.prompt?.editGlobal) nextConfiguration = await input.prompt.editGlobal(currentConfiguration, catalog);
895
+ const nextCheck = validateGlobalObject(nextConfiguration, catalog);
896
+ if (!nextCheck.ok) return nextCheck;
897
+ const localConfigurations = /* @__PURE__ */ new Map();
898
+ const localConfigurationSources = /* @__PURE__ */ new Map();
899
+ for (const [root, registration] of Object.entries(registryCheck.value.projects)) {
900
+ const localPath = join(paths.projects, registration.identity, "config.yaml");
901
+ const localState = await readOptionalFile(localPath);
902
+ if (!localState.ok) return localState;
903
+ if (!localState.value.exists) return missingConfiguration("local", localPath);
904
+ const localCheck = parseLocalConfiguration(localState.value.content, currentConfiguration, catalog);
905
+ if (!localCheck.ok) return localCheck;
906
+ localConfigurations.set(root, localCheck.value);
907
+ localConfigurationSources.set(root, localState.value.content);
908
+ }
909
+ const removedNode = new Set(currentConfiguration.node.filter((entry) => !nextCheck.value.node.includes(entry)));
910
+ const replacementConfigurations = /* @__PURE__ */ new Map();
911
+ for (const [root, localConfiguration] of localConfigurations) {
912
+ if (localConfiguration.node === null || !removedNode.has(localConfiguration.node)) continue;
913
+ let replacement = input.nextLocalConfigurations?.[root];
914
+ if (replacement === void 0 && input.prompt?.editLocal) replacement = await input.prompt.editLocal(localConfiguration, catalog, nextCheck.value);
915
+ if (replacement === void 0) return failure({
916
+ kind: "validation",
917
+ code: "node-replacement-required",
918
+ observed: `Global Node Runtime ${localConfiguration.node} is selected by Project ${root}.`,
919
+ nextAction: `Select a replacement Node Runtime or none for Project ${root}.`
920
+ });
921
+ const replacementCheck = validateLocalObject(replacement, nextCheck.value, catalog);
922
+ if (!replacementCheck.ok) return replacementCheck;
923
+ replacementConfigurations.set(root, replacementCheck.value);
924
+ }
925
+ if (!(!globalState.value.exists || !configurationsEqual(currentConfiguration, nextCheck.value)) && replacementConfigurations.size === 0) return success({
926
+ scope: "global",
927
+ changed: false
928
+ });
929
+ const confirm = input.confirm ?? input.prompt?.confirm ?? (async () => true);
930
+ const localChangeSummary = [...replacementConfigurations].map(([root, configuration]) => `Project ${root}:\nCurrent: ${serializeLocalConfiguration(localConfigurations.get(root)).trim()}\nNext: ${serializeLocalConfiguration(configuration).trim()}`).join("\n\n");
931
+ if (!await confirm("Save Global configuration?", {
932
+ title: "Global configuration changes",
933
+ content: [
934
+ `Current Global:\n${serializeGlobalConfiguration(currentConfiguration).trim()}`,
935
+ `Next Global:\n${serializeGlobalConfiguration(nextCheck.value).trim()}`,
936
+ localChangeSummary
937
+ ].filter(Boolean).join("\n\n")
938
+ })) return success({
939
+ scope: "global",
940
+ changed: false
941
+ });
942
+ if (input.signal?.aborted) throw new InterruptedError();
943
+ const composeWrites = [];
944
+ for (const [root, registration] of Object.entries(registryCheck.value.projects)) {
945
+ const composeWrite = await composeStateWrite({
946
+ projectRoot: root,
947
+ sandboxName: registration.name,
948
+ stateDirectory: projectStateDirectory(registration.identity, paths.home),
949
+ devboxHome: paths.home,
950
+ globalConfiguration: nextCheck.value,
951
+ localConfiguration: replacementConfigurations.get(root) ?? localConfigurations.get(root)
952
+ });
953
+ if (!composeWrite.ok) return composeWrite;
954
+ if (!composeWrite.value.previous.exists || composeWrite.value.content !== composeWrite.value.previous.content) composeWrites.push(composeWrite.value);
955
+ }
956
+ const written = await writeStateTransaction([
957
+ {
958
+ path: paths.globalConfiguration,
959
+ content: serializeGlobalConfiguration(nextCheck.value),
960
+ previous: globalState.value
961
+ },
962
+ ...[...replacementConfigurations].map(([root, configuration]) => ({
963
+ path: join(paths.projects, registryCheck.value.projects[root].identity, "config.yaml"),
964
+ content: serializeLocalConfiguration(configuration),
965
+ previous: {
966
+ exists: true,
967
+ content: localConfigurationSources.get(root)
968
+ }
969
+ })),
970
+ ...composeWrites
971
+ ]);
972
+ if (!written.ok) return written;
973
+ return success({
974
+ scope: "global",
975
+ changed: true
976
+ });
977
+ }
978
+ async function removeProject(input = {}) {
979
+ return withStateLocks({
980
+ devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
981
+ global: true,
982
+ projectRoots: [input.root ?? process.cwd()],
983
+ signal: input.signal
984
+ }, () => removeProjectUnlocked(input));
985
+ }
986
+ async function removeProjectUnlocked(input = {}) {
987
+ if (input.signal?.aborted) throw new InterruptedError();
988
+ const projectRoot = input.root ?? process.cwd();
989
+ const rootCheck = await validateProjectRoot(projectRoot);
990
+ if (!rootCheck.ok) return rootCheck;
991
+ const paths = devboxPaths(input.devboxHome);
992
+ const registryCheck = await readRegistry(paths.projectRegistry);
993
+ if (!registryCheck.ok) return registryCheck;
994
+ const registration = registryCheck.value.projects[projectRoot];
995
+ if (registration === void 0) return notRegistered(projectRoot);
996
+ if (!input.yes) {
997
+ if (!await (input.confirm ?? (async () => false))(`Remove Project ${projectRoot} and its Devbox state?`)) return success({
998
+ root: projectRoot,
999
+ removed: false
1000
+ });
1001
+ }
1002
+ if (input.signal?.aborted) throw new InterruptedError();
1003
+ const stateDirectory = projectStateDirectory(registration.identity, paths.home);
1004
+ try {
1005
+ await rm(stateDirectory, {
1006
+ recursive: true,
1007
+ force: true
1008
+ });
1009
+ } catch {
1010
+ return failure({
1011
+ kind: "operational",
1012
+ code: "project-state-removal-failed",
1013
+ observed: `Devbox could not remove Project state: ${stateDirectory}.`,
1014
+ nextAction: "Check write access to ~/.devbox and run devbox rm --yes again."
1015
+ });
1016
+ }
1017
+ const projects = { ...registryCheck.value.projects };
1018
+ delete projects[projectRoot];
1019
+ const written = await writeAtomically(paths.projectRegistry, serializeProjectRegistry({
1020
+ version: 1,
1021
+ projects
1022
+ }));
1023
+ if (!written.ok) return written;
1024
+ return success({
1025
+ root: projectRoot,
1026
+ removed: true
1027
+ });
1028
+ }
1029
+ async function cleanupMissingProjects(input = {}) {
1030
+ return withStateLocks({
1031
+ devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
1032
+ global: true,
1033
+ projectRoots: [],
1034
+ signal: input.signal
1035
+ }, async (locks) => {
1036
+ const discovery = await discoverMissingProjects(input);
1037
+ if (!discovery.ok) return discovery;
1038
+ await locks.acquireProjectScopes(discovery.value.missingRoots);
1039
+ return cleanupMissingProjectsUnlocked(input, discovery.value);
1040
+ });
1041
+ }
1042
+ async function discoverMissingProjects(input) {
1043
+ if (input.signal?.aborted) throw new InterruptedError();
1044
+ const paths = devboxPaths(input.devboxHome);
1045
+ const registryState = await readOptionalFile(paths.projectRegistry);
1046
+ if (!registryState.ok) return registryState;
1047
+ if (!registryState.value.exists) return success({
1048
+ paths,
1049
+ registry: {
1050
+ version: 1,
1051
+ projects: {}
1052
+ },
1053
+ missingRoots: []
1054
+ });
1055
+ const registryCheck = parseProjectRegistry(registryState.value.content);
1056
+ if (!registryCheck.ok) return registryCheck;
1057
+ const missingRoots = [];
1058
+ for (const root of Object.keys(registryCheck.value.projects)) {
1059
+ if (input.signal?.aborted) throw new InterruptedError();
1060
+ const exists = await projectRootExists(root);
1061
+ if (exists === void 0) return failure({
1062
+ kind: "operational",
1063
+ code: "project-root-observation-failed",
1064
+ observed: `Devbox could not inspect Project root: ${root}.`,
1065
+ nextAction: "Check access to the registered Project root and try cleanup again."
1066
+ });
1067
+ if (!exists) missingRoots.push(root);
1068
+ }
1069
+ return success({
1070
+ paths,
1071
+ registry: registryCheck.value,
1072
+ missingRoots
1073
+ });
1074
+ }
1075
+ async function cleanupMissingProjectsUnlocked(input, discovery) {
1076
+ if (input.signal?.aborted) throw new InterruptedError();
1077
+ if (discovery.missingRoots.length === 0) return success({
1078
+ roots: [],
1079
+ removed: false
1080
+ });
1081
+ if (!input.yes) {
1082
+ if (!await (input.confirm ?? (async () => false))(`Remove Missing-root Project registrations?\n${discovery.missingRoots.map((root) => `- ${root}`).join("\n")}`)) return success({
1083
+ roots: [],
1084
+ removed: false
1085
+ });
1086
+ }
1087
+ const removedRoots = [];
1088
+ const projects = { ...discovery.registry.projects };
1089
+ for (const root of discovery.missingRoots) {
1090
+ const rootExists = await projectRootExists(root);
1091
+ if (rootExists === void 0) return failure({
1092
+ kind: "operational",
1093
+ code: "project-root-observation-failed",
1094
+ observed: `Devbox could not recheck Project root: ${root}.`,
1095
+ nextAction: "Check access to the registered Project root and try cleanup again."
1096
+ });
1097
+ if (rootExists) continue;
1098
+ const registration = projects[root];
1099
+ if (registration === void 0) continue;
1100
+ try {
1101
+ await rm(projectStateDirectory(registration.identity, discovery.paths.home), {
1102
+ recursive: true,
1103
+ force: true
1104
+ });
1105
+ } catch {
1106
+ return failure({
1107
+ kind: "operational",
1108
+ code: "missing-project-state-removal-failed",
1109
+ observed: `Devbox could not remove Missing-root Project state for ${root}.`,
1110
+ nextAction: "Check write access to ~/.devbox and try cleanup again."
1111
+ });
1112
+ }
1113
+ delete projects[root];
1114
+ removedRoots.push(root);
1115
+ }
1116
+ if (removedRoots.length === 0) return success({
1117
+ roots: [],
1118
+ removed: false
1119
+ });
1120
+ const written = await writeAtomically(discovery.paths.projectRegistry, serializeProjectRegistry({
1121
+ version: 1,
1122
+ projects
1123
+ }));
1124
+ if (!written.ok) return written;
1125
+ return success({
1126
+ roots: removedRoots,
1127
+ removed: true
1128
+ });
1129
+ }
1130
+ async function runSandboxLifecycle(command, input = {}) {
1131
+ const projectRoot = input.root ?? process.cwd();
1132
+ return withStateLocks({
1133
+ devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
1134
+ global: false,
1135
+ projectRoots: [projectRoot],
1136
+ signal: input.signal
1137
+ }, () => runSandboxLifecycleUnlocked(command, input, projectRoot));
1138
+ }
1139
+ async function runSandboxLifecycleUnlocked(command, input, projectRoot) {
1140
+ if (input.signal?.aborted) throw new InterruptedError();
1141
+ const rootCheck = await validateProjectRoot(projectRoot);
1142
+ if (!rootCheck.ok) return rootCheck;
1143
+ const paths = devboxPaths(input.devboxHome);
1144
+ const registryCheck = await readRegistry(paths.projectRegistry);
1145
+ if (!registryCheck.ok) return registryCheck;
1146
+ const registration = registryCheck.value.projects[projectRoot];
1147
+ if (registration === void 0) return notRegistered(projectRoot);
1148
+ const environment = input.environment ?? currentHostEnvironment();
1149
+ if (command === "up") {
1150
+ const globalState = await readOptionalFile(paths.globalConfiguration);
1151
+ if (!globalState.ok) return globalState;
1152
+ if (!globalState.value.exists) return missingConfiguration("global", paths.globalConfiguration);
1153
+ const globalCheck = parseGlobalConfiguration(globalState.value.content);
1154
+ if (!globalCheck.ok) return globalCheck;
1155
+ await ensureSharedAgentVolumes(globalCheck.value.agent, environment);
1156
+ }
1157
+ const composePath = join(projectStateDirectory(registration.identity, paths.home), "compose.yaml");
1158
+ const operation = command === "up" ? ["up", "-d"] : command === "sh" ? [
1159
+ "exec",
1160
+ "--user",
1161
+ "devbox",
1162
+ "devbox",
1163
+ "bash"
1164
+ ] : [command];
1165
+ await (environment.runDirect ?? environment.run)("docker", [
1166
+ "compose",
1167
+ "--file",
1168
+ composePath,
1169
+ ...operation
1170
+ ]);
1171
+ return success(void 0);
1172
+ }
1173
+ async function validateProjectRoot(projectRoot) {
1174
+ if (!isAbsolute(projectRoot)) return failure({
1175
+ kind: "validation",
1176
+ code: "invalid-project-root",
1177
+ observed: `The current Project directory is not absolute: ${projectRoot}.`,
1178
+ nextAction: "Run the command from an existing directory."
1179
+ });
1180
+ try {
1181
+ if (!(await lstat(projectRoot)).isDirectory()) return failure({
1182
+ kind: "validation",
1183
+ code: "invalid-project-root",
1184
+ observed: `The current Project path is not a directory: ${projectRoot}.`,
1185
+ nextAction: "Run the command from an existing directory."
1186
+ });
1187
+ } catch {
1188
+ return failure({
1189
+ kind: "validation",
1190
+ code: "missing-project-root",
1191
+ observed: `The current Project directory does not exist: ${projectRoot}.`,
1192
+ nextAction: "Change to an existing directory and run the command again."
1193
+ });
1194
+ }
1195
+ return success(void 0);
1196
+ }
1197
+ async function readRegistry(path) {
1198
+ const state = await readOptionalFile(path);
1199
+ if (!state.ok) return state;
1200
+ if (!state.value.exists) return failure({
1201
+ kind: "validation",
1202
+ code: "project-not-registered",
1203
+ observed: "No Project registry exists for this Devbox installation.",
1204
+ nextAction: "Run devbox init from the exact Project directory first."
1205
+ });
1206
+ return parseProjectRegistry(state.value.content);
1207
+ }
1208
+ async function allocateSandboxIdentity(projectRoot, paths, registry) {
1209
+ const identity = sandboxIdentity(projectRoot);
1210
+ for (let suffix = 1; suffix < 1e6; suffix += 1) {
1211
+ const candidate = suffix === 1 ? identity : `${identity}-${suffix}`;
1212
+ if (!isSafeStateDirectoryName(candidate) || Object.values(registry.projects).some((entry) => entry.identity === candidate)) continue;
1213
+ try {
1214
+ await lstat(projectStateDirectory(candidate, paths.home));
1215
+ } catch (error) {
1216
+ if (isMissingFileError(error)) return success(candidate);
1217
+ return failure({
1218
+ kind: "operational",
1219
+ code: "state-directory-observation-failed",
1220
+ observed: `Devbox could not inspect Project state directory: ${projectStateDirectory(candidate, paths.home)}.`,
1221
+ nextAction: "Check write access to ~/.devbox and run devbox init again."
1222
+ });
1223
+ }
1224
+ }
1225
+ return failure({
1226
+ kind: "operational",
1227
+ code: "state-directory-allocation-failed",
1228
+ observed: `Devbox could not allocate a unique Project state directory for ${projectRoot}.`,
1229
+ nextAction: "Remove stale Devbox state only through its supported command and try again."
1230
+ });
1231
+ }
1232
+ function allocateSandboxName(projectRoot, registry) {
1233
+ const name = sandboxName(projectRoot);
1234
+ for (let suffix = 1; suffix < 1e6; suffix += 1) {
1235
+ const candidate = suffix === 1 ? name : `${name}-${suffix}`;
1236
+ if (isSafeSandboxName(candidate) && !Object.values(registry.projects).some((entry) => entry.name === candidate)) return success(candidate);
1237
+ }
1238
+ return failure({
1239
+ kind: "operational",
1240
+ code: "sandbox-name-allocation-failed",
1241
+ observed: `Devbox could not allocate a unique Sandbox name for ${projectRoot}.`,
1242
+ nextAction: "Remove stale Devbox state only through its supported command and try again."
1243
+ });
1244
+ }
1245
+ async function projectRootExists(projectRoot) {
1246
+ try {
1247
+ return (await lstat(projectRoot)).isDirectory();
1248
+ } catch (error) {
1249
+ if (isMissingFileError(error)) return false;
1250
+ return;
1251
+ }
1252
+ }
1253
+ async function readOptionalFile(path) {
1254
+ try {
1255
+ return success({
1256
+ exists: true,
1257
+ content: await readFile(path, "utf8")
1258
+ });
1259
+ } catch (error) {
1260
+ if (isMissingFileError(error)) return success({
1261
+ exists: false,
1262
+ content: ""
1263
+ });
1264
+ return failure({
1265
+ kind: "operational",
1266
+ code: "state-read-failed",
1267
+ observed: `Devbox could not read state file: ${path}.`,
1268
+ nextAction: "Check write access to ~/.devbox and try again."
1269
+ });
1270
+ }
1271
+ }
1272
+ async function writeAtomically(path, content) {
1273
+ const staged = await stageStateWrite(path, content, "tmp");
1274
+ if (!staged.ok) return staged;
1275
+ try {
1276
+ await rename(staged.value, path);
1277
+ return success(void 0);
1278
+ } catch {
1279
+ await rm(staged.value, { force: true }).catch(() => void 0);
1280
+ return failure({
1281
+ kind: "operational",
1282
+ code: "state-write-failed",
1283
+ observed: `Devbox could not atomically write state file: ${path}.`,
1284
+ nextAction: "Check write access to ~/.devbox and run the command again."
1285
+ });
1286
+ }
1287
+ }
1288
+ async function stageStateWrite(path, content, suffix) {
1289
+ const directory = dirname(path);
1290
+ const temporaryPath = join(directory, `.${basename(path)}-${process.pid}-${randomUUID()}.${suffix}`);
1291
+ try {
1292
+ await mkdir(directory, {
1293
+ recursive: true,
1294
+ mode: 448
1295
+ });
1296
+ await writeFile(temporaryPath, content, {
1297
+ encoding: "utf8",
1298
+ flag: "wx",
1299
+ mode: 384
1300
+ });
1301
+ return success(temporaryPath);
1302
+ } catch {
1303
+ await rm(temporaryPath, { force: true }).catch(() => void 0);
1304
+ return failure({
1305
+ kind: "operational",
1306
+ code: "state-write-failed",
1307
+ observed: `Devbox could not atomically write state file: ${path}.`,
1308
+ nextAction: "Check write access to ~/.devbox and run the command again."
1309
+ });
1310
+ }
1311
+ }
1312
+ async function composeStateWrite(input) {
1313
+ const claudeHostConfiguration = await ensureClaudeHostConfiguration(input.devboxHome, input.globalConfiguration);
1314
+ if (!claudeHostConfiguration.ok) return claudeHostConfiguration;
1315
+ const rendered = renderProjectCompose({
1316
+ projectRoot: input.projectRoot,
1317
+ sandboxName: input.sandboxName,
1318
+ selectedNode: input.localConfiguration.node,
1319
+ configuredAgents: input.globalConfiguration.agent,
1320
+ agentNotifications: input.globalConfiguration.agent_notifications,
1321
+ claudeHostConfiguration: claudeHostConfiguration.value
1322
+ });
1323
+ if (!rendered.ok) return rendered;
1324
+ const path = join(input.stateDirectory, "compose.yaml");
1325
+ const previous = await readOptionalFile(path);
1326
+ if (!previous.ok) return previous;
1327
+ return success({
1328
+ path,
1329
+ content: rendered.value,
1330
+ previous: previous.value
1331
+ });
1332
+ }
1333
+ async function ensureClaudeHostConfiguration(devboxHome, globalConfiguration) {
1334
+ if (!globalConfiguration.agent.includes("claude-code")) return success(void 0);
1335
+ const path = devboxPaths(devboxHome).claudeHostConfiguration;
1336
+ try {
1337
+ if ((await lstat(path)).isFile()) return success(path);
1338
+ return failure({
1339
+ kind: "operational",
1340
+ code: "claude-host-configuration-invalid",
1341
+ observed: `Claude host configuration is not a regular file: ${path}.`,
1342
+ nextAction: "Replace it with a regular file, then run the command again."
1343
+ });
1344
+ } catch (error) {
1345
+ if (!isMissingFileError(error)) return failure({
1346
+ kind: "operational",
1347
+ code: "claude-host-configuration-unreadable",
1348
+ observed: `Devbox could not inspect Claude host configuration: ${path}.`,
1349
+ nextAction: "Check access to ~/.devbox and run the command again."
1350
+ });
1351
+ }
1352
+ try {
1353
+ await mkdir(dirname(path), {
1354
+ recursive: true,
1355
+ mode: 448
1356
+ });
1357
+ await writeFile(path, "{}\n", {
1358
+ encoding: "utf8",
1359
+ flag: "wx",
1360
+ mode: 384
1361
+ });
1362
+ return success(path);
1363
+ } catch (error) {
1364
+ if (error.code === "EEXIST") return ensureClaudeHostConfiguration(devboxHome, globalConfiguration);
1365
+ return failure({
1366
+ kind: "operational",
1367
+ code: "claude-host-configuration-write-failed",
1368
+ observed: `Devbox could not create Claude host configuration: ${path}.`,
1369
+ nextAction: "Check write access to ~/.devbox and run the command again."
1370
+ });
1371
+ }
1372
+ }
1373
+ async function writeStateTransaction(writes) {
1374
+ const staged = [];
1375
+ for (const write of writes) {
1376
+ const stagedWrite = await stageStateWrite(write.path, write.content, "transaction.tmp");
1377
+ if (!stagedWrite.ok) {
1378
+ await Promise.all(staged.map((stage) => rm(stage.path, { force: true }).catch(() => void 0)));
1379
+ return stagedWrite;
1380
+ }
1381
+ staged.push({
1382
+ write,
1383
+ path: stagedWrite.value
1384
+ });
1385
+ }
1386
+ const published = [];
1387
+ try {
1388
+ for (const stage of staged) {
1389
+ await rename(stage.path, stage.write.path);
1390
+ published.push(stage.write);
1391
+ }
1392
+ return success(void 0);
1393
+ } catch {
1394
+ let restored = true;
1395
+ for (const write of published.reverse()) if (write.previous.exists) {
1396
+ const restoredWrite = await writeAtomically(write.path, write.previous.content);
1397
+ restored &&= restoredWrite.ok;
1398
+ } else try {
1399
+ await rm(write.path, { force: true });
1400
+ } catch {
1401
+ restored = false;
1402
+ }
1403
+ await Promise.all(staged.map((stage) => rm(stage.path, { force: true }).catch(() => void 0)));
1404
+ return failure({
1405
+ kind: "operational",
1406
+ code: "state-write-failed",
1407
+ observed: restored ? "Devbox could not publish Project state; the previous state was restored." : "Devbox could not publish Project state or restore the previous state.",
1408
+ nextAction: "Check write access to ~/.devbox and run the command again."
1409
+ });
1410
+ }
1411
+ }
1412
+ function validateGlobalObject(configuration, catalog) {
1413
+ try {
1414
+ return parseGlobalConfiguration(serializeGlobalConfiguration(configuration), catalog);
1415
+ } catch {
1416
+ return failure({
1417
+ kind: "validation",
1418
+ code: "invalid-global-configuration",
1419
+ observed: "Global configuration has invalid values.",
1420
+ nextAction: "Edit the supported version-1 Global YAML configuration and try again."
1421
+ });
1422
+ }
1423
+ }
1424
+ function validateLocalObject(configuration, globalConfiguration, catalog) {
1425
+ try {
1426
+ return parseLocalConfiguration(serializeLocalConfiguration(configuration), globalConfiguration, catalog);
1427
+ } catch {
1428
+ return failure({
1429
+ kind: "validation",
1430
+ code: "invalid-local-configuration",
1431
+ observed: "Local configuration has invalid values.",
1432
+ nextAction: "Edit the supported version-1 Local YAML configuration and try again."
1433
+ });
1434
+ }
1435
+ }
1436
+ function notRegistered(projectRoot) {
1437
+ return failure({
1438
+ kind: "validation",
1439
+ code: "project-not-registered",
1440
+ observed: `The exact Project directory is not registered: ${projectRoot}.`,
1441
+ nextAction: "Run devbox init from this exact existing directory first."
1442
+ });
1443
+ }
1444
+ function missingConfiguration(scope, path) {
1445
+ return failure({
1446
+ kind: "validation",
1447
+ code: `missing-${scope}-configuration`,
1448
+ observed: `Devbox could not find the ${scope} configuration: ${path}.`,
1449
+ nextAction: `Restore the supported version-1 ${scope} YAML configuration and try again.`
1450
+ });
1451
+ }
1452
+ function isMissingFileError(error) {
1453
+ return typeof error === "object" && error !== null && error.code === "ENOENT";
1454
+ }
1455
+ var InterruptedError = class extends Error {
1456
+ constructor() {
1457
+ super("Devbox command interrupted.");
1458
+ }
1459
+ };
1460
+ //#endregion
1461
+ //#region src/configuration/prompter.ts
1462
+ function createConfigurationPrompter({ signal, input, output }) {
1463
+ const common = {
1464
+ signal,
1465
+ input,
1466
+ output
1467
+ };
1468
+ return {
1469
+ confirm: async (message, details) => {
1470
+ if (details !== void 0) p.note(details.content, details.title, common);
1471
+ return promptValue(p.confirm({
1472
+ message,
1473
+ ...common
1474
+ }));
1475
+ },
1476
+ selectConfigurationScope: async () => promptValue(p.select({
1477
+ message: "Configuration scope",
1478
+ options: [{
1479
+ value: "local",
1480
+ label: "Current Project"
1481
+ }, {
1482
+ value: "global",
1483
+ label: "Global"
1484
+ }],
1485
+ ...common
1486
+ })),
1487
+ editGlobal: async (configuration, catalog) => {
1488
+ return {
1489
+ version: 1,
1490
+ node: await promptValue(p.multiselect({
1491
+ message: "Configured Node release lines",
1492
+ options: (catalog.runtimes.node ?? []).map((value) => ({ value })),
1493
+ initialValues: [...configuration.node],
1494
+ required: false,
1495
+ ...common
1496
+ })),
1497
+ agent: await promptValue(p.multiselect({
1498
+ message: "Configured Agents",
1499
+ options: catalog.agents.map((value) => ({ value })),
1500
+ initialValues: [...configuration.agent],
1501
+ required: false,
1502
+ ...common
1503
+ })),
1504
+ agent_notifications: await promptValue(p.confirm({
1505
+ message: "Enable Agent notifications",
1506
+ initialValue: configuration.agent_notifications,
1507
+ ...common
1508
+ }))
1509
+ };
1510
+ },
1511
+ editLocal: async (configuration, catalog, globalConfiguration) => {
1512
+ return {
1513
+ version: 1,
1514
+ node: await promptValue(p.select({
1515
+ message: "Selected Node release line",
1516
+ options: [{
1517
+ value: null,
1518
+ label: "None"
1519
+ }, ...(globalConfiguration.node ?? []).map((value) => ({
1520
+ value,
1521
+ label: value
1522
+ }))],
1523
+ initialValue: configuration.node,
1524
+ ...common
1525
+ }))
1526
+ };
1527
+ }
1528
+ };
1529
+ }
1530
+ async function promptValue(prompt) {
1531
+ const value = await prompt;
1532
+ if (p.isCancel(value)) throw new InterruptedError();
1533
+ return value;
1534
+ }
1535
+ //#endregion
1536
+ //#region src/workspace/build.ts
1537
+ async function buildWorkspace(input = {}) {
1538
+ return withStateLocks({
1539
+ devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
1540
+ global: true,
1541
+ projectRoots: [],
1542
+ signal: input.signal
1543
+ }, () => buildWorkspaceUnlocked(input));
1544
+ }
1545
+ async function buildWorkspaceUnlocked(input) {
1546
+ if (input.signal?.aborted) throw new InterruptedError();
1547
+ const catalogCheck = normalizeCatalog();
1548
+ if (!catalogCheck.ok) return catalogCheck;
1549
+ const paths = devboxPaths(input.devboxHome);
1550
+ let globalSource;
1551
+ try {
1552
+ globalSource = await readFile(paths.globalConfiguration, "utf8");
1553
+ } catch {
1554
+ return failure({
1555
+ kind: "operational",
1556
+ code: "global-configuration-missing",
1557
+ observed: "Global configuration is required before building the Workspace image.",
1558
+ nextAction: "Run devbox config to commit Global configuration before devbox build."
1559
+ });
1560
+ }
1561
+ const parsedGlobal = parseGlobalConfiguration(globalSource, catalogCheck.value);
1562
+ if (!parsedGlobal.ok) return parsedGlobal;
1563
+ const globalConfiguration = parsedGlobal.value;
1564
+ const dockerfile = renderBuildDockerfile({
1565
+ nodeRuntimes: globalConfiguration.node.map((releaseLine) => PACKAGED_NODE_RECIPES[releaseLine]),
1566
+ buildNodeRuntime: buildNodeRuntimeRecipe(globalConfiguration),
1567
+ agents: globalConfiguration.agent.map((name) => ({
1568
+ name,
1569
+ recipe: PACKAGED_AGENTS[name]
1570
+ })),
1571
+ skillAgents: globalConfiguration.agent.filter((agent) => PACKAGED_AGENTS[agent]?.supportsSkillInstallation === true)
1572
+ });
1573
+ await rm(paths.buildContext, {
1574
+ recursive: true,
1575
+ force: true
1576
+ });
1577
+ await mkdir(paths.buildContext, { recursive: true });
1578
+ await writeFile(join(paths.buildContext, "Dockerfile"), dockerfile);
1579
+ await writeFile(join(paths.buildContext, ".dockerignore"), "# Machine-owned Workspace build context\n*\n!Dockerfile\n");
1580
+ if (input.signal?.aborted) throw new InterruptedError();
1581
+ const buildResult = await (input.executeDockerBuild ?? runDockerBuild)({
1582
+ context: paths.buildContext,
1583
+ image: WORKSPACE_IMAGE,
1584
+ noCache: input.noCache === true
1585
+ });
1586
+ if (!buildResult.ok) return buildResult;
1587
+ return success({ image: WORKSPACE_IMAGE });
1588
+ }
1589
+ function buildNodeRuntimeRecipe(globalConfiguration) {
1590
+ let buildNodeRuntime;
1591
+ for (const releaseLine of globalConfiguration.node) {
1592
+ const recipe = PACKAGED_NODE_RECIPES[releaseLine];
1593
+ if (buildNodeRuntime === void 0 || Number.parseFloat(releaseLine) > Number.parseFloat(buildNodeRuntime.releaseLine)) buildNodeRuntime = recipe;
1594
+ }
1595
+ return buildNodeRuntime;
1596
+ }
1597
+ function renderBuildDockerfile(input) {
1598
+ const lines = [
1599
+ "# Generated by devbox build. Machine-owned Workspace build context.",
1600
+ "FROM ubuntu:24.04",
1601
+ "",
1602
+ "ENV DEBIAN_FRONTEND=noninteractive",
1603
+ "ENV TZ=UTC",
1604
+ "ENV LANG=C.UTF-8",
1605
+ "",
1606
+ "RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone",
1607
+ "",
1608
+ "# Install the Base profile packages and build tools",
1609
+ "RUN set -eux \\",
1610
+ " && apt-get update && apt-get upgrade -y \\",
1611
+ " && apt-get install -y --no-install-recommends \\",
1612
+ " ca-certificates curl wget git supervisor openssh-client \\",
1613
+ " zip unzip jq less vim ripgrep fd-find procps lsof iproute2 \\",
1614
+ " dnsutils netcat-openbsd rsync tree tzdata bubblewrap \\",
1615
+ " build-essential python3 python-is-python3 pkg-config \\",
1616
+ " gnupg dirmngr xz-utils pulseaudio-utils sudo gosu \\",
1617
+ " && apt-get -y autoremove \\",
1618
+ " && apt-get clean \\",
1619
+ " && rm -rf /var/lib/apt/lists/*",
1620
+ "",
1621
+ "# Create the devbox user and group",
1622
+ "RUN userdel -r ubuntu \\",
1623
+ " && groupadd -f -g 1000 devbox \\",
1624
+ " && useradd -m -s /bin/bash -N -g 1000 -u 1000 devbox \\",
1625
+ " && echo \"devbox ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/devbox \\",
1626
+ " && chmod 0440 /etc/sudoers.d/devbox",
1627
+ ""
1628
+ ];
1629
+ for (const recipe of input.nodeRuntimes) lines.push(...renderNodeRuntimeStage(recipe));
1630
+ lines.push("WORKDIR /workspace", "USER devbox", "");
1631
+ if (input.buildNodeRuntime !== void 0) lines.push("# Configure pnpm for the non-root Sandbox user", "RUN mkdir -p /home/devbox/.config/pnpm /home/devbox/.pnpm-store \\", " && printf 'storeDir: /home/devbox/.pnpm-store\\n' > /home/devbox/.config/pnpm/config.yaml \\", " && chown devbox:devbox /home/devbox/.config/pnpm/config.yaml", "");
1632
+ for (const agent of input.agents) lines.push(`# Install ${agent.name}`, `RUN mkdir -p ${agent.recipe.home.target} \\`, ` && curl -fsSL ${agent.recipe.installation.url} | ${agent.recipe.installation.shell}`, "");
1633
+ if (input.agents.length > 0) lines.push("# Set the PATH to include AI tools", "ENV PATH=\"/home/devbox/.local/bin:${PATH}\"", "");
1634
+ if (input.buildNodeRuntime !== void 0 && input.skillAgents.length > 0) {
1635
+ const skillAgentArguments = input.skillAgents.map((agent) => `-a ${agent}`).join(" ");
1636
+ lines.push("# Install Agent Skills", "RUN set -eux \\", " && mkdir -p /home/devbox/.agents/skills \\", ` && export PATH="${input.buildNodeRuntime.runtimeRoot}/bin:$PATH" \\`, ` && npx -y skills add ycs77/skills -g ${skillAgentArguments} -s '*' -y`, "");
1637
+ }
1638
+ lines.push("USER root");
1639
+ return `${lines.join("\n")}\n`;
1640
+ }
1641
+ function renderNodeRuntimeStage(recipe) {
1642
+ const keys = recipe.trustedReleaseKeys.map((key) => ` ${key}`).join(" \\\n");
1643
+ return [
1644
+ `# Install Node.js ${recipe.releaseLine}`,
1645
+ "RUN ARCH= OPENSSL_ARCH= && dpkgArch=\"$(dpkg --print-architecture)\" \\",
1646
+ " && case \"${dpkgArch##*-}\" in \\",
1647
+ " amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; \\",
1648
+ " ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; \\",
1649
+ " s390x) ARCH='s390x' OPENSSL_ARCH='linux*-s390x';; \\",
1650
+ " arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; \\",
1651
+ " *) echo \"unsupported architecture\"; exit 1 ;; \\",
1652
+ " esac \\",
1653
+ " && set -eux \\",
1654
+ ` && NODE_RUNTIME_VERSION=${recipe.version} \\`,
1655
+ ` && NODE_RUNTIME_ROOT=${recipe.runtimeRoot} \\`,
1656
+ ` && NODE_ARCHIVE_URL="https://nodejs.org/dist/v\${NODE_RUNTIME_VERSION}/node-v\${NODE_RUNTIME_VERSION}-linux-\${ARCH}.tar.xz" \\`,
1657
+ ` && NODE_CHECKSUMS_URL="https://nodejs.org/dist/v\${NODE_RUNTIME_VERSION}/SHASUMS256.txt.asc" \\`,
1658
+ " && mkdir -p \"$NODE_RUNTIME_ROOT\" /tmp/node-source \\",
1659
+ " && cd /tmp/node-source \\",
1660
+ " && export GNUPGHOME=\"$(mktemp -d)\" \\",
1661
+ " && for key in \\",
1662
+ `${keys} \\`,
1663
+ " ; do \\",
1664
+ " { gpg --batch --no-options --keyserver hkps://keys.openpgp.org --recv-keys \"$key\" && gpg --batch --no-options --fingerprint \"$key\"; } || \\",
1665
+ " { gpg --batch --no-options --keyserver keyserver.ubuntu.com --recv-keys \"$key\" && gpg --batch --no-options --fingerprint \"$key\"; }; \\",
1666
+ " done \\",
1667
+ " && archive=\"node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\" \\",
1668
+ " && curl -fsSL --retry 3 -o \"$archive\" \"$NODE_ARCHIVE_URL\" \\",
1669
+ " && curl -fsSL --retry 3 -o SHASUMS256.txt.asc \"$NODE_CHECKSUMS_URL\" \\",
1670
+ " && gpg --batch --no-options --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \\",
1671
+ " && gpgconf --kill all \\",
1672
+ " && rm -rf \"$GNUPGHOME\" \\",
1673
+ " && awk -v archive=\"$archive\" \\",
1674
+ " '$2 == archive { print; found = 1; exit } END { if (!found) exit 1 }' \\",
1675
+ " SHASUMS256.txt > \"$archive.sha256\" \\",
1676
+ " && sha256sum --check \"$archive.sha256\" \\",
1677
+ " && tar --extract --xz --file \"$archive\" --directory \"$NODE_RUNTIME_ROOT\" \\",
1678
+ " --strip-components=1 --no-same-owner \\",
1679
+ " && rm \"$archive\" SHASUMS256.txt.asc SHASUMS256.txt \"$archive.sha256\" \\",
1680
+ " && find \"$NODE_RUNTIME_ROOT/include/node/openssl/archs\" -mindepth 1 -maxdepth 1 ! -name \"$OPENSSL_ARCH\" -exec rm -rf {} \\; \\",
1681
+ " && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \\",
1682
+ " && export PATH=\"$NODE_RUNTIME_ROOT/bin:$PATH\" \\",
1683
+ " && npm install -g npm \\",
1684
+ " && npm uninstall -g corepack \\",
1685
+ " && npm install -g yarn pnpm @antfu/ni \\",
1686
+ " && \"$NODE_RUNTIME_ROOT/bin/node\" --version \\",
1687
+ " && \"$NODE_RUNTIME_ROOT/bin/npm\" --version \\",
1688
+ " && rm -rf /tmp/node-source",
1689
+ ""
1690
+ ];
1691
+ }
1692
+ async function runDockerBuild(invocation, signal) {
1693
+ const build = spawn("docker", [
1694
+ "build",
1695
+ ...invocation.noCache ? ["--no-cache"] : [],
1696
+ "--tag",
1697
+ invocation.image,
1698
+ "--file",
1699
+ join(invocation.context, "Dockerfile"),
1700
+ invocation.context
1701
+ ], { stdio: "inherit" });
1702
+ const interrupt = () => build.kill("SIGINT");
1703
+ signal?.addEventListener("abort", interrupt, { once: true });
1704
+ let spawnFailed = false;
1705
+ build.on("error", () => {
1706
+ spawnFailed = true;
1707
+ });
1708
+ const [exitCode] = await once(build, "close");
1709
+ signal?.removeEventListener("abort", interrupt);
1710
+ if (signal?.aborted) throw new InterruptedError();
1711
+ if (spawnFailed || exitCode !== 0) return failure({
1712
+ kind: "operational",
1713
+ code: "docker-build-failed",
1714
+ observed: `Docker failed to build ${invocation.image}.`,
1715
+ nextAction: "Inspect the Docker output above; the prior Workspace image is unchanged."
1716
+ });
1717
+ return success(void 0);
1718
+ }
1719
+ //#endregion
1720
+ //#region src/cli.ts
1721
+ function createCli(signal, interactive) {
1722
+ const prompt = createConfigurationPrompter({ signal });
1723
+ const cli = cac("devbox");
1724
+ cli.command("").action(() => cli.outputHelp());
1725
+ cli.command("init", "Register the current directory as a Devbox Project.").action(async () => {
1726
+ if (!interactive) return interactiveFailure("devbox init requires an interactive terminal.", "Run devbox init from a TTY before it writes Project state.");
1727
+ const result = await initializeProject({
1728
+ signal,
1729
+ prompt
1730
+ });
1731
+ if (!result.ok) return result;
1732
+ return success({ message: result.value.created ? `Registered Project: ${result.value.root}` : result.value.confirmed === false ? "Project registration was not changed." : `Project is already registered: ${result.value.root}` });
1733
+ });
1734
+ cli.command("config", "Edit Global or Local configuration for the current Project.").action(async () => {
1735
+ if (!interactive) return interactiveFailure("devbox config requires an interactive terminal.", "Run devbox config from a TTY, or edit the supported Global or Local YAML directly.");
1736
+ if (await prompt.selectConfigurationScope() === "global") {
1737
+ const result = await configureGlobal({
1738
+ signal,
1739
+ prompt
1740
+ });
1741
+ if (!result.ok) return result;
1742
+ return success({ message: result.value.changed ? "Global configuration updated." : "Global configuration was not changed." });
1743
+ }
1744
+ const result = await configureLocalProject({
1745
+ signal,
1746
+ prompt
1747
+ });
1748
+ if (!result.ok) return result;
1749
+ return success({ message: result.value.changed ? "Local configuration updated." : "Local configuration was not changed." });
1750
+ });
1751
+ cli.command("build", "Build the shared Workspace image from Global configuration.").option("--cache, --no-cache", "Use Docker layer cache.", { default: true }).action(async (options = {}) => {
1752
+ const result = await buildWorkspace({
1753
+ signal,
1754
+ noCache: options.cache === false
1755
+ });
1756
+ if (!result.ok) return result;
1757
+ return success({ message: `Built Workspace image: ${result.value.image}` });
1758
+ });
1759
+ cli.command("rm", "Remove the current registered Project and its state.").option("--yes", "Skip the confirmation prompt.").action(async (options = {}) => {
1760
+ if (!interactive && !options.yes) return interactiveFailure("devbox rm requires a TTY or --yes.", "Run devbox rm from a TTY, or pass --yes to confirm removal.");
1761
+ const result = await removeProject({
1762
+ signal,
1763
+ confirm: prompt.confirm,
1764
+ yes: options.yes
1765
+ });
1766
+ if (!result.ok) return result;
1767
+ return success({ message: result.value.removed ? `Removed Project: ${result.value.root}` : "Project removal was not changed." });
1768
+ });
1769
+ cli.command("cleanup", "Remove explicitly selected disposable Devbox state.").option("--missing-projects", "Remove Missing-root Project registrations.").option("--yes", "Skip the confirmation prompt.").action(async (options = {}) => {
1770
+ if (!options.missingProjects) return failure({
1771
+ kind: "usage",
1772
+ code: "cleanup-mode-required",
1773
+ observed: "cleanup requires --missing-projects.",
1774
+ nextAction: "Run devbox cleanup --missing-projects."
1775
+ });
1776
+ if (!interactive && !options.yes) return interactiveFailure("devbox cleanup --missing-projects requires a TTY or --yes.", "Run devbox cleanup --missing-projects from a TTY, or pass --yes to confirm removal.");
1777
+ const result = await cleanupMissingProjects({
1778
+ signal,
1779
+ confirm: prompt.confirm,
1780
+ yes: options.yes
1781
+ });
1782
+ if (!result.ok) return result;
1783
+ return success({ message: result.value.removed ? `Removed Missing-root Projects: ${result.value.roots.join(", ")}` : "No Missing-root Projects were removed." });
1784
+ });
1785
+ for (const [command, description] of [
1786
+ ["up", "Start the current Project Sandbox."],
1787
+ ["down", "Stop and remove the current Project Sandbox."],
1788
+ ["stop", "Stop the current Project Sandbox."],
1789
+ ["sh", "Open a shell in the current Project Sandbox."]
1790
+ ]) cli.command(command, description).action(async () => {
1791
+ const result = await runSandboxLifecycle(command, { signal });
1792
+ return result.ok ? success({}) : result;
1793
+ });
1794
+ cli.help();
1795
+ return cli;
1796
+ }
1797
+ function present(result) {
1798
+ if (!result.ok) {
1799
+ process.stderr.write(`${result.error.observed}\n${result.error.nextAction}\n`);
1800
+ return result.error.kind === "usage" ? 2 : 1;
1801
+ }
1802
+ if (result.value.message !== void 0) process.stdout.write(`${result.value.message}\n`);
1803
+ return 0;
1804
+ }
1805
+ async function main() {
1806
+ const abortController = new AbortController();
1807
+ const interrupt = () => abortController.abort();
1808
+ process.once("SIGINT", interrupt);
1809
+ const cli = createCli(abortController.signal, process.stdin.isTTY === true && process.stdout.isTTY === true);
1810
+ try {
1811
+ cli.parse(process.argv, { run: false });
1812
+ const result = await cli.runMatchedCommand();
1813
+ return result === void 0 ? 0 : present(result);
1814
+ } catch (error) {
1815
+ if (error instanceof InterruptedError || abortController.signal.aborted) {
1816
+ process.stderr.write("Devbox command interrupted.\n");
1817
+ return 130;
1818
+ }
1819
+ if (error instanceof HostCommandError) return error.exitCode;
1820
+ throw error;
1821
+ } finally {
1822
+ process.off("SIGINT", interrupt);
1823
+ }
1824
+ }
1825
+ function interactiveFailure(observed, nextAction) {
1826
+ return failure({
1827
+ kind: "usage",
1828
+ code: "interactive-terminal-required",
1829
+ observed,
1830
+ nextAction
1831
+ });
1832
+ }
1833
+ process.exitCode = await main();
1834
+ //#endregion
1835
+ export {};