@ycs77/devbox 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/dist/cli.mjs +451 -554
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -2,16 +2,120 @@ import * as p from "@clack/prompts";
2
2
  import { intro, outro } from "@clack/prompts";
3
3
  import { cac } from "cac";
4
4
  import c from "picocolors";
5
- import { createHash, randomUUID } from "node:crypto";
6
5
  import { lstat, mkdir, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises";
7
6
  import { homedir } from "node:os";
8
7
  import { basename, dirname, isAbsolute, join, parse, relative, sep } from "node:path";
8
+ import { parse as parse$1, parseDocument, stringify } from "yaml";
9
9
  import { execFile, spawn } from "node:child_process";
10
10
  import { promisify } from "node:util";
11
- import { parse as parse$1, parseDocument, stringify } from "yaml";
11
+ import { createHash, randomUUID } from "node:crypto";
12
12
  import { once } from "node:events";
13
13
  //#region package.json
14
- var version = "0.6.0";
14
+ var version = "0.7.1";
15
+ //#endregion
16
+ //#region src/result.ts
17
+ function success(value) {
18
+ return {
19
+ ok: true,
20
+ value
21
+ };
22
+ }
23
+ function failure(error) {
24
+ return {
25
+ ok: false,
26
+ error
27
+ };
28
+ }
29
+ //#endregion
30
+ //#region src/host.ts
31
+ const execFile$1 = promisify(execFile);
32
+ var HostCommandError = class extends Error {
33
+ exitCode;
34
+ constructor(file, args, exitCode) {
35
+ super(`${file} ${args.join(" ")} exited with status ${exitCode}.`);
36
+ this.exitCode = exitCode;
37
+ this.name = "HostCommandError";
38
+ }
39
+ };
40
+ const supportedHostAction = "Start Docker Desktop and ensure Docker Compose is available.";
41
+ function currentHostEnvironment() {
42
+ return {
43
+ run: async (file, args) => {
44
+ await execFile$1(file, args);
45
+ },
46
+ runDirect: async (file, args) => new Promise((resolve, reject) => {
47
+ const command = spawn(file, args, { stdio: "inherit" });
48
+ command.once("error", reject);
49
+ command.once("close", (code) => {
50
+ if (code === 0) {
51
+ resolve();
52
+ return;
53
+ }
54
+ reject(new HostCommandError(file, args, code ?? 1));
55
+ });
56
+ })
57
+ };
58
+ }
59
+ async function validateSupportedHost(environment = currentHostEnvironment()) {
60
+ for (const [file, args, description] of [[
61
+ "docker",
62
+ ["--version"],
63
+ "Docker"
64
+ ], [
65
+ "docker",
66
+ ["compose", "version"],
67
+ "Docker Compose"
68
+ ]]) try {
69
+ await environment.run(file, args);
70
+ } catch {
71
+ return failure({
72
+ kind: "validation",
73
+ code: "missing-host-prerequisite",
74
+ observed: `${description} is unavailable.`,
75
+ nextAction: supportedHostAction
76
+ });
77
+ }
78
+ return success(void 0);
79
+ }
80
+ //#endregion
81
+ //#region src/agent/volumes.ts
82
+ async function ensureSharedAgentVolumes(composeSource, environment = currentHostEnvironment()) {
83
+ const volumeNames = /* @__PURE__ */ new Set();
84
+ try {
85
+ const definition = parse$1(composeSource);
86
+ if (!isMapping(definition)) throw new Error("Expected a Compose mapping.");
87
+ const volumes = definition.volumes;
88
+ if (volumes !== void 0) {
89
+ if (!isMapping(volumes)) throw new Error("Expected a volume mapping.");
90
+ for (const [key, volume] of Object.entries(volumes)) {
91
+ if (volume === null) continue;
92
+ if (!isMapping(volume)) throw new Error("Expected a volume definition.");
93
+ if (volume.external !== true) continue;
94
+ const name = volume.name ?? key;
95
+ if (typeof name !== "string" || name.length === 0) throw new Error("Expected an external volume name.");
96
+ volumeNames.add(name);
97
+ }
98
+ }
99
+ } catch {
100
+ return failure({
101
+ kind: "operational",
102
+ code: "invalid-project-compose",
103
+ observed: "Devbox could not read external volumes from the retained Sandbox definition.",
104
+ nextAction: "Run devbox config to publish the Sandbox definition, then retry devbox up."
105
+ });
106
+ }
107
+ for (const volumeName of volumeNames) await environment.run("docker", [
108
+ "volume",
109
+ "create",
110
+ "--driver",
111
+ "local",
112
+ volumeName
113
+ ]);
114
+ return success(void 0);
115
+ }
116
+ function isMapping(value) {
117
+ return typeof value === "object" && value !== null && !Array.isArray(value);
118
+ }
15
119
  //#endregion
16
120
  //#region src/catalog/index.ts
17
121
  const NODE_RELEASE_KEYS = [
@@ -55,7 +159,7 @@ const PACKAGED_AGENTS = {
55
159
  },
56
160
  supportsNotifications: true,
57
161
  notificationInstallationCommands: ["claude plugin marketplace add ycs77/claude-code-notifications", "claude plugin install notification-basic-wsl@ycs77-notifications"],
58
- supportsSkillInstallation: true
162
+ skillInstallationTarget: "claude-code"
59
163
  },
60
164
  codex: {
61
165
  home: {
@@ -67,7 +171,7 @@ const PACKAGED_AGENTS = {
67
171
  shell: "sh"
68
172
  },
69
173
  supportsNotifications: true,
70
- supportsSkillInstallation: true,
174
+ skillInstallationTarget: "codex",
71
175
  notificationInstallationCommands: ["codex plugin marketplace add ycs77/codex-notifications", "codex plugin add notification-basic-wsl@ycs77-notifications"]
72
176
  },
73
177
  agy: {
@@ -79,8 +183,7 @@ const PACKAGED_AGENTS = {
79
183
  url: "https://antigravity.google/cli/install.sh",
80
184
  shell: "bash"
81
185
  },
82
- supportsNotifications: false,
83
- supportsSkillInstallation: false
186
+ supportsNotifications: false
84
187
  },
85
188
  omp: {
86
189
  home: {
@@ -92,7 +195,7 @@ const PACKAGED_AGENTS = {
92
195
  shell: "sh"
93
196
  },
94
197
  supportsNotifications: true,
95
- supportsSkillInstallation: false,
198
+ skillInstallationTarget: "codex",
96
199
  notificationInstallationCommands: ["omp plugin marketplace add ycs77/omp-notifications", "omp plugin install notification-basic@ycs77-notifications"]
97
200
  }
98
201
  };
@@ -101,85 +204,6 @@ const PACKAGED_RUNTIME_CATALOG = {
101
204
  agents: Object.keys(PACKAGED_AGENTS)
102
205
  };
103
206
  //#endregion
104
- //#region src/result.ts
105
- function success(value) {
106
- return {
107
- ok: true,
108
- value
109
- };
110
- }
111
- function failure(error) {
112
- return {
113
- ok: false,
114
- error
115
- };
116
- }
117
- //#endregion
118
- //#region src/host.ts
119
- const execFile$1 = promisify(execFile);
120
- var HostCommandError = class extends Error {
121
- exitCode;
122
- constructor(file, args, exitCode) {
123
- super(`${file} ${args.join(" ")} exited with status ${exitCode}.`);
124
- this.exitCode = exitCode;
125
- this.name = "HostCommandError";
126
- }
127
- };
128
- const supportedHostAction = "Start Docker Desktop and ensure Docker Compose is available.";
129
- function currentHostEnvironment() {
130
- return {
131
- run: async (file, args) => {
132
- await execFile$1(file, args);
133
- },
134
- runDirect: async (file, args) => new Promise((resolve, reject) => {
135
- const command = spawn(file, args, { stdio: "inherit" });
136
- command.once("error", reject);
137
- command.once("close", (code) => {
138
- if (code === 0) {
139
- resolve();
140
- return;
141
- }
142
- reject(new HostCommandError(file, args, code ?? 1));
143
- });
144
- })
145
- };
146
- }
147
- async function validateSupportedHost(environment = currentHostEnvironment()) {
148
- for (const [file, args, description] of [[
149
- "docker",
150
- ["--version"],
151
- "Docker"
152
- ], [
153
- "docker",
154
- ["compose", "version"],
155
- "Docker Compose"
156
- ]]) try {
157
- await environment.run(file, args);
158
- } catch {
159
- return failure({
160
- kind: "validation",
161
- code: "missing-host-prerequisite",
162
- observed: `${description} is unavailable.`,
163
- nextAction: supportedHostAction
164
- });
165
- }
166
- return success(void 0);
167
- }
168
- //#endregion
169
- //#region src/agent/volumes.ts
170
- async function ensureSharedAgentVolumes(configuredAgents, environment = currentHostEnvironment()) {
171
- for (const configuredAgent of configuredAgents) {
172
- const volumeName = PACKAGED_AGENTS[configuredAgent]?.home.volumeName;
173
- if (volumeName !== void 0) await environment.run("docker", [
174
- "volume",
175
- "create",
176
- "--driver",
177
- "local",
178
- volumeName
179
- ]);
180
- }
181
- }
182
- //#endregion
183
207
  //#region src/configuration/index.ts
184
208
  function normalizeCatalog(catalog = PACKAGED_RUNTIME_CATALOG) {
185
209
  if (!isRecord(catalog.runtimes) || !Array.isArray(catalog.agents)) return invalid$1("invalid-runtime-catalog", "The packaged Runtime catalog is invalid.");
@@ -405,7 +429,7 @@ async function withStateLocks(input, operation) {
405
429
  try {
406
430
  await lstat(input.devboxHome);
407
431
  } catch (error) {
408
- if (isMissingFileError$1(error)) devboxHomeExisted = false;
432
+ if (isMissingFileError$2(error)) devboxHomeExisted = false;
409
433
  else throw error;
410
434
  }
411
435
  const acquired = [];
@@ -468,7 +492,7 @@ async function acquireLock(target) {
468
492
  function isFileExistsError(error) {
469
493
  return typeof error === "object" && error !== null && error.code === "EEXIST";
470
494
  }
471
- function isMissingFileError$1(error) {
495
+ function isMissingFileError$2(error) {
472
496
  return typeof error === "object" && error !== null && error.code === "ENOENT";
473
497
  }
474
498
  //#endregion
@@ -514,6 +538,7 @@ const WORKSPACE_IMAGE = "devbox-workspace:latest";
514
538
  //#endregion
515
539
  //#region src/project/compose.ts
516
540
  function renderProjectCompose(input) {
541
+ const containerName = `devbox-${input.sandboxName}`;
517
542
  const node = selectNodeComposeFragment(input.selectedNode);
518
543
  const agent = selectAgentComposeFragments(input.configuredAgents, input.agentNotifications);
519
544
  const environment = {
@@ -544,17 +569,17 @@ function renderProjectCompose(input) {
544
569
  }]
545
570
  ];
546
571
  const definition = {
547
- name: input.sandboxName,
572
+ name: containerName,
548
573
  "x-devbox": {
549
574
  version: 1,
550
575
  project_root: input.projectRoot,
551
- compose_name: input.sandboxName,
576
+ container_name: containerName,
552
577
  sandbox_service: "devbox"
553
578
  },
554
579
  services: { devbox: {
555
580
  image: WORKSPACE_IMAGE,
556
581
  hostname: "devbox",
557
- container_name: `devbox-${input.sandboxName}`,
582
+ container_name: containerName,
558
583
  working_dir: `/workspace/${input.sandboxName}`,
559
584
  ...input.ports.length === 0 ? {} : { ports: input.ports.map((port) => renderComposePortMapping(port)) },
560
585
  ...Object.keys(environment).length === 0 ? {} : { environment },
@@ -566,21 +591,21 @@ function renderProjectCompose(input) {
566
591
  }])) }
567
592
  };
568
593
  const source = stringify(definition);
569
- return validatesProjectCompose(source, input) ? success(source) : failure({
594
+ return validatesProjectCompose(source, input, containerName) ? success(source) : failure({
570
595
  kind: "operational",
571
596
  code: "invalid-project-compose",
572
597
  observed: `Devbox generated an invalid Compose definition for Project ${input.projectRoot}.`,
573
598
  nextAction: "Run the command again. If the problem continues, report it as a Devbox bug."
574
599
  });
575
600
  }
576
- function validatesProjectCompose(source, input) {
601
+ function validatesProjectCompose(source, input, containerName) {
577
602
  try {
578
603
  const definition = parse$1(source);
579
- if (definition.name !== input.sandboxName) return false;
604
+ if (definition.name !== containerName) return false;
580
605
  const metadata = definition["x-devbox"];
581
- if (metadata.version !== 1 || metadata.project_root !== input.projectRoot || metadata.compose_name !== input.sandboxName || metadata.sandbox_service !== "devbox") return false;
606
+ if (metadata.version !== 1 || metadata.project_root !== input.projectRoot || metadata.container_name !== containerName || metadata.sandbox_service !== "devbox") return false;
582
607
  const service = definition.services.devbox;
583
- if (service.image !== "devbox-workspace:latest" || service.container_name !== `devbox-${input.sandboxName}` || service.working_dir !== `/workspace/${input.sandboxName}` || "build" in service) return false;
608
+ if (service.image !== "devbox-workspace:latest" || service.container_name !== containerName || service.working_dir !== `/workspace/${input.sandboxName}` || "build" in service) return false;
584
609
  const ports = input.ports.map((port) => renderComposePortMapping(port));
585
610
  return JSON.stringify(service.ports ?? []) === JSON.stringify(ports) && (input.selectedNode === null && service.environment?.NODE_VERSION === void 0 || input.selectedNode !== null && service.environment?.NODE_VERSION === input.selectedNode);
586
611
  } catch {
@@ -658,6 +683,200 @@ function invalid(observed) {
658
683
  });
659
684
  }
660
685
  //#endregion
686
+ //#region src/project/publication.ts
687
+ async function publishProjectState(input) {
688
+ const definitions = [];
689
+ for (const project of input.projects) {
690
+ const prepared = project.preparedDefinition === void 0 ? await prepareSandboxDefinition(input.devboxHome, input.globalConfiguration, project) : success(project.preparedDefinition);
691
+ if (!prepared.ok) return prepared;
692
+ definitions.push(prepared.value);
693
+ }
694
+ const claudeHostConfiguration = await ensureClaudeHostConfiguration(input.devboxHome, input.globalConfiguration);
695
+ if (!claudeHostConfiguration.ok) return claudeHostConfiguration;
696
+ const writes = [
697
+ ...input.globalPrevious === void 0 ? [] : [{
698
+ path: join(input.devboxHome, "config.yaml"),
699
+ content: serializeGlobalConfiguration(input.globalConfiguration),
700
+ previous: input.globalPrevious
701
+ }],
702
+ ...input.projects.flatMap((project) => project.localPrevious === void 0 ? [] : [{
703
+ path: join(project.stateDirectory, "config.yaml"),
704
+ content: serializeLocalConfiguration(project.localConfiguration),
705
+ previous: project.localPrevious
706
+ }]),
707
+ ...input.registry === void 0 ? [] : [{
708
+ path: join(input.devboxHome, "projects.yaml"),
709
+ content: serializeProjectRegistry(input.registry.value),
710
+ previous: input.registry.previous
711
+ }],
712
+ ...definitions
713
+ ].filter((write) => !write.previous.exists || write.content !== write.previous.content);
714
+ const written = await writeStateTransaction(writes);
715
+ return written.ok ? success(writes.length > 0) : written;
716
+ }
717
+ async function prepareSandboxDefinition(devboxHome, globalConfiguration, project) {
718
+ const claudeHostConfiguration = globalConfiguration.agent.includes("claude-code") ? join(devboxHome, "agents", "claude", ".claude.json") : void 0;
719
+ const rendered = renderProjectCompose({
720
+ projectRoot: project.root,
721
+ sandboxName: project.name,
722
+ selectedNode: project.localConfiguration.node,
723
+ configuredAgents: globalConfiguration.agent,
724
+ agentNotifications: globalConfiguration.agent_notifications,
725
+ claudeHostConfiguration,
726
+ ports: project.localConfiguration.ports
727
+ });
728
+ if (!rendered.ok) return rendered;
729
+ const path = join(project.stateDirectory, "compose.yaml");
730
+ const previous = await readOptionalFile(path);
731
+ if (!previous.ok) return previous;
732
+ return success({
733
+ path,
734
+ content: rendered.value,
735
+ previous: previous.value
736
+ });
737
+ }
738
+ async function ensureClaudeHostConfiguration(devboxHome, globalConfiguration) {
739
+ if (!globalConfiguration.agent.includes("claude-code")) return success(void 0);
740
+ const path = join(devboxHome, "agents", "claude", ".claude.json");
741
+ try {
742
+ if ((await lstat(path)).isFile()) return success(path);
743
+ return failure({
744
+ kind: "operational",
745
+ code: "claude-host-configuration-invalid",
746
+ observed: `Claude host configuration is not a regular file: ${path}.`,
747
+ nextAction: "Replace it with a regular file, then run the command again."
748
+ });
749
+ } catch (error) {
750
+ if (!isMissingFileError$1(error)) return failure({
751
+ kind: "operational",
752
+ code: "claude-host-configuration-unreadable",
753
+ observed: `Devbox could not inspect Claude host configuration: ${path}.`,
754
+ nextAction: "Check access to ~/.devbox and run the command again."
755
+ });
756
+ }
757
+ try {
758
+ await mkdir(dirname(path), {
759
+ recursive: true,
760
+ mode: 448
761
+ });
762
+ await writeFile(path, "{}\n", {
763
+ encoding: "utf8",
764
+ flag: "wx",
765
+ mode: 384
766
+ });
767
+ return success(path);
768
+ } catch (error) {
769
+ if (error.code === "EEXIST") return ensureClaudeHostConfiguration(devboxHome, globalConfiguration);
770
+ return failure({
771
+ kind: "operational",
772
+ code: "claude-host-configuration-write-failed",
773
+ observed: `Devbox could not create Claude host configuration: ${path}.`,
774
+ nextAction: "Check write access to ~/.devbox and run the command again."
775
+ });
776
+ }
777
+ }
778
+ async function readOptionalFile(path) {
779
+ try {
780
+ return success({
781
+ exists: true,
782
+ content: await readFile(path, "utf8")
783
+ });
784
+ } catch (error) {
785
+ if (isMissingFileError$1(error)) return success({
786
+ exists: false,
787
+ content: ""
788
+ });
789
+ return failure({
790
+ kind: "operational",
791
+ code: "state-read-failed",
792
+ observed: `Devbox could not read state file: ${path}.`,
793
+ nextAction: "Check write access to ~/.devbox and try again."
794
+ });
795
+ }
796
+ }
797
+ async function writeAtomically(path, content) {
798
+ const staged = await stageStateWrite(path, content, "tmp");
799
+ if (!staged.ok) return staged;
800
+ try {
801
+ await rename(staged.value, path);
802
+ return success(void 0);
803
+ } catch {
804
+ await rm(staged.value, { force: true }).catch(() => void 0);
805
+ return failure({
806
+ kind: "operational",
807
+ code: "state-write-failed",
808
+ observed: `Devbox could not atomically write state file: ${path}.`,
809
+ nextAction: "Check write access to ~/.devbox and run the command again."
810
+ });
811
+ }
812
+ }
813
+ async function stageStateWrite(path, content, suffix) {
814
+ const directory = dirname(path);
815
+ const temporaryPath = join(directory, `.${basename(path)}-${process.pid}-${randomUUID()}.${suffix}`);
816
+ try {
817
+ await mkdir(directory, {
818
+ recursive: true,
819
+ mode: 448
820
+ });
821
+ await writeFile(temporaryPath, content, {
822
+ encoding: "utf8",
823
+ flag: "wx",
824
+ mode: 384
825
+ });
826
+ return success(temporaryPath);
827
+ } catch {
828
+ await rm(temporaryPath, { force: true }).catch(() => void 0);
829
+ return failure({
830
+ kind: "operational",
831
+ code: "state-write-failed",
832
+ observed: `Devbox could not atomically write state file: ${path}.`,
833
+ nextAction: "Check write access to ~/.devbox and run the command again."
834
+ });
835
+ }
836
+ }
837
+ async function writeStateTransaction(writes) {
838
+ const staged = [];
839
+ for (const write of writes) {
840
+ const stagedWrite = await stageStateWrite(write.path, write.content, "transaction.tmp");
841
+ if (!stagedWrite.ok) {
842
+ await Promise.all(staged.map((stage) => rm(stage.path, { force: true }).catch(() => void 0)));
843
+ return stagedWrite;
844
+ }
845
+ staged.push({
846
+ write,
847
+ path: stagedWrite.value
848
+ });
849
+ }
850
+ const published = [];
851
+ try {
852
+ for (const stage of staged) {
853
+ await rename(stage.path, stage.write.path);
854
+ published.push(stage.write);
855
+ }
856
+ return success(void 0);
857
+ } catch {
858
+ let restored = true;
859
+ for (const write of published.reverse()) if (write.previous.exists) {
860
+ const restoredWrite = await writeAtomically(write.path, write.previous.content);
861
+ restored &&= restoredWrite.ok;
862
+ } else try {
863
+ await rm(write.path, { force: true });
864
+ } catch {
865
+ restored = false;
866
+ }
867
+ await Promise.all(staged.map((stage) => rm(stage.path, { force: true }).catch(() => void 0)));
868
+ return failure({
869
+ kind: "operational",
870
+ code: "state-write-failed",
871
+ observed: restored ? "Devbox could not publish Project state; the previous state was restored." : "Devbox could not publish Project state or restore the previous state.",
872
+ nextAction: "Check write access to ~/.devbox and run the command again."
873
+ });
874
+ }
875
+ }
876
+ function isMissingFileError$1(error) {
877
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
878
+ }
879
+ //#endregion
661
880
  //#region src/project/index.ts
662
881
  function devboxPaths(devboxHome = join(homedir(), ".devbox")) {
663
882
  return {
@@ -719,24 +938,22 @@ async function initializeProjectUnlocked(input = {}) {
719
938
  if (!registryCheck.ok) return registryCheck;
720
939
  const registry = registryCheck.value;
721
940
  const registration = registry.projects[projectRoot];
722
- let identity;
723
- let name;
724
- if (registration !== void 0) {
725
- identity = registration.identity;
726
- name = registration.name;
727
- } else {
728
- const allocatedIdentity = await allocateSandboxIdentity(projectRoot, paths, registry);
729
- if (!allocatedIdentity.ok) return allocatedIdentity;
730
- const allocatedName = allocateSandboxName(projectRoot, registry);
731
- if (!allocatedName.ok) return allocatedName;
732
- identity = allocatedIdentity.value;
733
- name = allocatedName.value;
734
- }
941
+ if (registration !== void 0) return success({
942
+ root: projectRoot,
943
+ stateDirectory: projectStateDirectory(registration.identity, paths.home),
944
+ sandboxName: registration.name,
945
+ created: false,
946
+ globalConfigurationCreated: false
947
+ });
948
+ const allocatedIdentity = await allocateSandboxIdentity(projectRoot, paths, registry);
949
+ if (!allocatedIdentity.ok) return allocatedIdentity;
950
+ const allocatedName = allocateSandboxName(projectRoot, registry);
951
+ if (!allocatedName.ok) return allocatedName;
952
+ const identity = allocatedIdentity.value;
953
+ const name = allocatedName.value;
735
954
  const stateDirectory = projectStateDirectory(identity, paths.home);
736
- const registered = registration !== void 0;
737
955
  const globalState = await readOptionalFile(paths.globalConfiguration);
738
956
  if (!globalState.ok) return globalState;
739
- if (registered && !globalState.value.exists) return missingConfiguration("global", paths.globalConfiguration);
740
957
  let globalConfiguration;
741
958
  let globalChanged = false;
742
959
  if (globalState.value.exists) {
@@ -757,24 +974,11 @@ async function initializeProjectUnlocked(input = {}) {
757
974
  }
758
975
  globalChanged = true;
759
976
  }
760
- const localPath = join(stateDirectory, "config.yaml");
761
- const localState = registered ? await readOptionalFile(localPath) : success({
762
- exists: false,
763
- content: ""
764
- });
765
- if (!localState.ok) return localState;
766
- if (registered && !localState.value.exists) return missingConfiguration("local", localPath);
767
977
  let localConfiguration;
768
- let localChanged = false;
769
- if (localState.value.exists) {
770
- const parsedLocal = parseLocalConfiguration(localState.value.content, globalConfiguration, catalog);
771
- if (!parsedLocal.ok) return parsedLocal;
772
- localConfiguration = parsedLocal.value;
773
- } else if (input.initialLocalConfiguration !== void 0) {
978
+ if (input.initialLocalConfiguration !== void 0) {
774
979
  const initialLocal = validateLocalObject(input.initialLocalConfiguration, globalConfiguration, catalog);
775
980
  if (!initialLocal.ok) return initialLocal;
776
981
  localConfiguration = initialLocal.value;
777
- localChanged = true;
778
982
  } else {
779
983
  localConfiguration = defaultLocalConfiguration(globalConfiguration);
780
984
  if (input.prompt?.editLocal) {
@@ -782,35 +986,20 @@ async function initializeProjectUnlocked(input = {}) {
782
986
  if (!checkedLocal.ok) return checkedLocal;
783
987
  localConfiguration = checkedLocal.value;
784
988
  }
785
- localChanged = true;
786
989
  }
787
- const composeWrite = await composeStateWrite({
788
- projectRoot,
789
- sandboxName: name,
990
+ const definition = await prepareSandboxDefinition(paths.home, globalConfiguration, {
991
+ root: projectRoot,
992
+ name,
790
993
  stateDirectory,
791
- devboxHome: paths.home,
792
- globalConfiguration,
793
994
  localConfiguration
794
995
  });
795
- if (!composeWrite.ok) return composeWrite;
796
- if (registered) {
797
- if (!composeWrite.value.previous.exists || composeWrite.value.content !== composeWrite.value.previous.content) {
798
- const written = await writeStateTransaction([composeWrite.value]);
799
- if (!written.ok) return written;
800
- }
801
- return success({
802
- root: projectRoot,
803
- stateDirectory,
804
- sandboxName: name,
805
- created: false,
806
- confirmed: true
807
- });
808
- }
996
+ if (!definition.ok) return definition;
809
997
  if (!await (input.confirm ?? input.prompt?.confirm ?? (async () => true))("Save configuration?")) return success({
810
998
  root: projectRoot,
811
999
  stateDirectory,
812
1000
  sandboxName: name,
813
1001
  created: false,
1002
+ globalConfigurationCreated: false,
814
1003
  confirmed: false
815
1004
  });
816
1005
  const nextRegistry = {
@@ -823,30 +1012,33 @@ async function initializeProjectUnlocked(input = {}) {
823
1012
  }
824
1013
  }
825
1014
  };
826
- const written = await writeStateTransaction([
827
- ...globalChanged ? [{
828
- path: paths.globalConfiguration,
829
- content: serializeGlobalConfiguration(globalConfiguration),
830
- previous: globalState.value
831
- }] : [],
832
- ...localChanged ? [{
833
- path: localPath,
834
- content: serializeLocalConfiguration(localConfiguration),
835
- previous: localState.value
836
- }] : [],
837
- {
838
- path: paths.projectRegistry,
839
- content: serializeProjectRegistry(nextRegistry),
1015
+ const written = await publishProjectState({
1016
+ devboxHome: paths.home,
1017
+ globalConfiguration,
1018
+ globalPrevious: globalChanged ? globalState.value : void 0,
1019
+ registry: {
1020
+ value: nextRegistry,
840
1021
  previous: registryState.value
841
1022
  },
842
- composeWrite.value
843
- ]);
1023
+ projects: [{
1024
+ root: projectRoot,
1025
+ name,
1026
+ stateDirectory,
1027
+ localConfiguration,
1028
+ localPrevious: {
1029
+ exists: false,
1030
+ content: ""
1031
+ },
1032
+ preparedDefinition: definition.value
1033
+ }]
1034
+ });
844
1035
  if (!written.ok) return written;
845
1036
  return success({
846
1037
  root: projectRoot,
847
1038
  stateDirectory,
848
1039
  sandboxName: name,
849
1040
  created: true,
1041
+ globalConfigurationCreated: globalChanged,
850
1042
  confirmed: true
851
1043
  });
852
1044
  }
@@ -882,14 +1074,10 @@ async function configureLocalProjectUnlocked(input = {}) {
882
1074
  const localCheck = parseLocalConfiguration(localState.value.content, globalCheck.value, catalog);
883
1075
  if (!localCheck.ok) return localCheck;
884
1076
  let nextConfiguration = input.nextConfiguration ?? localCheck.value;
885
- if (input.nextConfiguration === void 0 && input.prompt?.editLocal) nextConfiguration = await input.prompt.editLocal(localCheck.value, catalog, globalCheck.value);
886
- const nextCheck = validateLocalObject(nextConfiguration, globalCheck.value, catalog);
887
- if (!nextCheck.ok) return nextCheck;
888
- if (configurationsEqual(localCheck.value, nextCheck.value)) return success({
889
- scope: "local",
890
- root: projectRoot,
891
- changed: false
892
- });
1077
+ if (input.nextConfiguration === void 0 && input.prompt?.editLocal) nextConfiguration = await input.prompt.editLocal(localCheck.value, catalog, globalCheck.value);
1078
+ const nextCheck = validateLocalObject(nextConfiguration, globalCheck.value, catalog);
1079
+ if (!nextCheck.ok) return nextCheck;
1080
+ const localChanged = !configurationsEqual(localCheck.value, nextCheck.value);
893
1081
  if (!await (input.confirm ?? input.prompt?.confirm ?? (async () => true))("Save Project configuration?", {
894
1082
  title: "Review configuration",
895
1083
  sections: [{
@@ -903,25 +1091,22 @@ async function configureLocalProjectUnlocked(input = {}) {
903
1091
  changed: false
904
1092
  });
905
1093
  if (input.signal?.aborted) throw new InterruptedError();
906
- const composeWrite = await composeStateWrite({
907
- projectRoot,
908
- sandboxName: registration.name,
909
- stateDirectory,
1094
+ const written = await publishProjectState({
910
1095
  devboxHome: paths.home,
911
1096
  globalConfiguration: globalCheck.value,
912
- localConfiguration: nextCheck.value
1097
+ projects: [{
1098
+ root: projectRoot,
1099
+ name: registration.name,
1100
+ stateDirectory,
1101
+ localConfiguration: nextCheck.value,
1102
+ localPrevious: localChanged ? localState.value : void 0
1103
+ }]
913
1104
  });
914
- if (!composeWrite.ok) return composeWrite;
915
- const written = await writeStateTransaction([{
916
- path: localPath,
917
- content: serializeLocalConfiguration(nextCheck.value),
918
- previous: localState.value
919
- }, composeWrite.value]);
920
1105
  if (!written.ok) return written;
921
1106
  return success({
922
1107
  scope: "local",
923
1108
  root: projectRoot,
924
- changed: true
1109
+ changed: written.value
925
1110
  });
926
1111
  }
927
1112
  async function configureGlobal(input = {}) {
@@ -986,10 +1171,7 @@ async function configureGlobalUnlocked(input = {}, locks) {
986
1171
  if (!replacementCheck.ok) return replacementCheck;
987
1172
  replacementConfigurations.set(root, replacementCheck.value);
988
1173
  }
989
- if (!(!globalState.value.exists || !configurationsEqual(currentConfiguration, nextCheck.value)) && replacementConfigurations.size === 0) return success({
990
- scope: "global",
991
- changed: false
992
- });
1174
+ const globalChanged = !globalState.value.exists || !configurationsEqual(currentConfiguration, nextCheck.value);
993
1175
  if (!await (input.confirm ?? input.prompt?.confirm ?? (async () => true))("Save Global configuration?", {
994
1176
  title: "Review configuration",
995
1177
  sections: [{
@@ -1006,39 +1188,25 @@ async function configureGlobalUnlocked(input = {}, locks) {
1006
1188
  changed: false
1007
1189
  });
1008
1190
  if (input.signal?.aborted) throw new InterruptedError();
1009
- const composeWrites = [];
1010
- for (const [root, registration] of Object.entries(registryCheck.value.projects)) {
1011
- const composeWrite = await composeStateWrite({
1012
- projectRoot: root,
1013
- sandboxName: registration.name,
1191
+ const written = await publishProjectState({
1192
+ devboxHome: paths.home,
1193
+ globalConfiguration: nextCheck.value,
1194
+ globalPrevious: globalChanged ? globalState.value : void 0,
1195
+ projects: Object.entries(registryCheck.value.projects).map(([root, registration]) => ({
1196
+ root,
1197
+ name: registration.name,
1014
1198
  stateDirectory: projectStateDirectory(registration.identity, paths.home),
1015
- devboxHome: paths.home,
1016
- globalConfiguration: nextCheck.value,
1017
- localConfiguration: replacementConfigurations.get(root) ?? localConfigurations.get(root)
1018
- });
1019
- if (!composeWrite.ok) return composeWrite;
1020
- if (!composeWrite.value.previous.exists || composeWrite.value.content !== composeWrite.value.previous.content) composeWrites.push(composeWrite.value);
1021
- }
1022
- const written = await writeStateTransaction([
1023
- {
1024
- path: paths.globalConfiguration,
1025
- content: serializeGlobalConfiguration(nextCheck.value),
1026
- previous: globalState.value
1027
- },
1028
- ...[...replacementConfigurations].map(([root, configuration]) => ({
1029
- path: join(paths.projects, registryCheck.value.projects[root].identity, "config.yaml"),
1030
- content: serializeLocalConfiguration(configuration),
1031
- previous: {
1199
+ localConfiguration: replacementConfigurations.get(root) ?? localConfigurations.get(root),
1200
+ localPrevious: replacementConfigurations.has(root) ? {
1032
1201
  exists: true,
1033
1202
  content: localConfigurationSources.get(root)
1034
- }
1035
- })),
1036
- ...composeWrites
1037
- ]);
1203
+ } : void 0
1204
+ }))
1205
+ });
1038
1206
  if (!written.ok) return written;
1039
1207
  return success({
1040
1208
  scope: "global",
1041
- changed: true
1209
+ changed: written.value
1042
1210
  });
1043
1211
  }
1044
1212
  async function removeProject(input = {}) {
@@ -1060,13 +1228,20 @@ async function removeProjectUnlocked(input = {}) {
1060
1228
  const registration = registryCheck.value.projects[projectRoot];
1061
1229
  if (registration === void 0) return notRegistered(projectRoot);
1062
1230
  if (!input.yes) {
1063
- if (!await (input.confirm ?? (async () => false))(`Remove Project ${projectRoot} and its Devbox state?`)) return success({
1231
+ if (!await (input.confirm ?? (async () => false))(`Remove Project ${projectRoot}, its Sandbox, and Devbox state?`)) return success({
1064
1232
  root: projectRoot,
1065
1233
  removed: false
1066
1234
  });
1067
1235
  }
1068
1236
  if (input.signal?.aborted) throw new InterruptedError();
1069
1237
  const stateDirectory = projectStateDirectory(registration.identity, paths.home);
1238
+ const environment = input.environment ?? currentHostEnvironment();
1239
+ await (environment.runDirect ?? environment.run)("docker", [
1240
+ "compose",
1241
+ "--file",
1242
+ join(stateDirectory, "compose.yaml"),
1243
+ "down"
1244
+ ]);
1070
1245
  try {
1071
1246
  await rm(stateDirectory, {
1072
1247
  recursive: true,
@@ -1092,107 +1267,6 @@ async function removeProjectUnlocked(input = {}) {
1092
1267
  removed: true
1093
1268
  });
1094
1269
  }
1095
- async function cleanupMissingProjects(input = {}) {
1096
- return withStateLocks({
1097
- devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
1098
- global: true,
1099
- projectRoots: [],
1100
- signal: input.signal
1101
- }, async (locks) => {
1102
- const discovery = await discoverMissingProjects(input);
1103
- if (!discovery.ok) return discovery;
1104
- await locks.acquireProjectScopes(discovery.value.missingRoots);
1105
- return cleanupMissingProjectsUnlocked(input, discovery.value);
1106
- });
1107
- }
1108
- async function discoverMissingProjects(input) {
1109
- if (input.signal?.aborted) throw new InterruptedError();
1110
- const paths = devboxPaths(input.devboxHome);
1111
- const registryState = await readOptionalFile(paths.projectRegistry);
1112
- if (!registryState.ok) return registryState;
1113
- if (!registryState.value.exists) return success({
1114
- paths,
1115
- registry: {
1116
- version: 1,
1117
- projects: {}
1118
- },
1119
- missingRoots: []
1120
- });
1121
- const registryCheck = parseProjectRegistry(registryState.value.content);
1122
- if (!registryCheck.ok) return registryCheck;
1123
- const missingRoots = [];
1124
- for (const root of Object.keys(registryCheck.value.projects)) {
1125
- if (input.signal?.aborted) throw new InterruptedError();
1126
- const exists = await projectRootExists(root);
1127
- if (exists === void 0) return failure({
1128
- kind: "operational",
1129
- code: "project-root-observation-failed",
1130
- observed: `Devbox could not inspect Project root: ${root}.`,
1131
- nextAction: "Check access to the registered Project root and try cleanup again."
1132
- });
1133
- if (!exists) missingRoots.push(root);
1134
- }
1135
- return success({
1136
- paths,
1137
- registry: registryCheck.value,
1138
- missingRoots
1139
- });
1140
- }
1141
- async function cleanupMissingProjectsUnlocked(input, discovery) {
1142
- if (input.signal?.aborted) throw new InterruptedError();
1143
- if (discovery.missingRoots.length === 0) return success({
1144
- roots: [],
1145
- removed: false
1146
- });
1147
- if (!input.yes) {
1148
- if (!await (input.confirm ?? (async () => false))(`Remove Missing-root Project registrations?\n${discovery.missingRoots.map((root) => `- ${root}`).join("\n")}`)) return success({
1149
- roots: [],
1150
- removed: false
1151
- });
1152
- }
1153
- const removedRoots = [];
1154
- const projects = { ...discovery.registry.projects };
1155
- for (const root of discovery.missingRoots) {
1156
- const rootExists = await projectRootExists(root);
1157
- if (rootExists === void 0) return failure({
1158
- kind: "operational",
1159
- code: "project-root-observation-failed",
1160
- observed: `Devbox could not recheck Project root: ${root}.`,
1161
- nextAction: "Check access to the registered Project root and try cleanup again."
1162
- });
1163
- if (rootExists) continue;
1164
- const registration = projects[root];
1165
- if (registration === void 0) continue;
1166
- try {
1167
- await rm(projectStateDirectory(registration.identity, discovery.paths.home), {
1168
- recursive: true,
1169
- force: true
1170
- });
1171
- } catch {
1172
- return failure({
1173
- kind: "operational",
1174
- code: "missing-project-state-removal-failed",
1175
- observed: `Devbox could not remove Missing-root Project state for ${root}.`,
1176
- nextAction: "Check write access to ~/.devbox and try cleanup again."
1177
- });
1178
- }
1179
- delete projects[root];
1180
- removedRoots.push(root);
1181
- }
1182
- if (removedRoots.length === 0) return success({
1183
- roots: [],
1184
- removed: false
1185
- });
1186
- const written = await writeAtomically(discovery.paths.projectRegistry, serializeProjectRegistry({
1187
- version: 1,
1188
- projects
1189
- }));
1190
- if (!written.ok) return written;
1191
- return success({
1192
- roots: removedRoots,
1193
- removed: true
1194
- });
1195
- }
1196
1270
  async function runSandboxLifecycle(command, input = {}) {
1197
1271
  const projectRoot = input.root ?? process.cwd();
1198
1272
  return withStateLocks({
@@ -1215,15 +1289,19 @@ async function runSandboxCommandUnlocked(command, input, projectRoot) {
1215
1289
  const registration = registryCheck.value.projects[projectRoot];
1216
1290
  if (registration === void 0) return notRegistered(projectRoot);
1217
1291
  const environment = input.environment ?? currentHostEnvironment();
1292
+ const composePath = join(projectStateDirectory(registration.identity, paths.home), "compose.yaml");
1218
1293
  if (command === "up") {
1219
- const globalState = await readOptionalFile(paths.globalConfiguration);
1220
- if (!globalState.ok) return globalState;
1221
- if (!globalState.value.exists) return missingConfiguration("global", paths.globalConfiguration);
1222
- const globalCheck = parseGlobalConfiguration(globalState.value.content);
1223
- if (!globalCheck.ok) return globalCheck;
1224
- await ensureSharedAgentVolumes(globalCheck.value.agent, environment);
1294
+ const composeState = await readOptionalFile(composePath);
1295
+ if (!composeState.ok) return composeState;
1296
+ if (!composeState.value.exists) return failure({
1297
+ kind: "operational",
1298
+ code: "missing-project-compose",
1299
+ observed: `Sandbox definition is missing: ${composePath}.`,
1300
+ nextAction: "Run devbox config to publish the Sandbox definition, then retry devbox up."
1301
+ });
1302
+ const volumes = await ensureSharedAgentVolumes(composeState.value.content, environment);
1303
+ if (!volumes.ok) return volumes;
1225
1304
  }
1226
- const composePath = join(projectStateDirectory(registration.identity, paths.home), "compose.yaml");
1227
1305
  const operation = command === "up" ? ["up", "-d"] : command === "sh" ? [
1228
1306
  "exec",
1229
1307
  "--user",
@@ -1311,174 +1389,6 @@ function allocateSandboxName(projectRoot, registry) {
1311
1389
  nextAction: "Remove stale Devbox state only through its supported command and try again."
1312
1390
  });
1313
1391
  }
1314
- async function projectRootExists(projectRoot) {
1315
- try {
1316
- return (await lstat(projectRoot)).isDirectory();
1317
- } catch (error) {
1318
- if (isMissingFileError(error)) return false;
1319
- return;
1320
- }
1321
- }
1322
- async function readOptionalFile(path) {
1323
- try {
1324
- return success({
1325
- exists: true,
1326
- content: await readFile(path, "utf8")
1327
- });
1328
- } catch (error) {
1329
- if (isMissingFileError(error)) return success({
1330
- exists: false,
1331
- content: ""
1332
- });
1333
- return failure({
1334
- kind: "operational",
1335
- code: "state-read-failed",
1336
- observed: `Devbox could not read state file: ${path}.`,
1337
- nextAction: "Check write access to ~/.devbox and try again."
1338
- });
1339
- }
1340
- }
1341
- async function writeAtomically(path, content) {
1342
- const staged = await stageStateWrite(path, content, "tmp");
1343
- if (!staged.ok) return staged;
1344
- try {
1345
- await rename(staged.value, path);
1346
- return success(void 0);
1347
- } catch {
1348
- await rm(staged.value, { force: true }).catch(() => void 0);
1349
- return failure({
1350
- kind: "operational",
1351
- code: "state-write-failed",
1352
- observed: `Devbox could not atomically write state file: ${path}.`,
1353
- nextAction: "Check write access to ~/.devbox and run the command again."
1354
- });
1355
- }
1356
- }
1357
- async function stageStateWrite(path, content, suffix) {
1358
- const directory = dirname(path);
1359
- const temporaryPath = join(directory, `.${basename(path)}-${process.pid}-${randomUUID()}.${suffix}`);
1360
- try {
1361
- await mkdir(directory, {
1362
- recursive: true,
1363
- mode: 448
1364
- });
1365
- await writeFile(temporaryPath, content, {
1366
- encoding: "utf8",
1367
- flag: "wx",
1368
- mode: 384
1369
- });
1370
- return success(temporaryPath);
1371
- } catch {
1372
- await rm(temporaryPath, { force: true }).catch(() => void 0);
1373
- return failure({
1374
- kind: "operational",
1375
- code: "state-write-failed",
1376
- observed: `Devbox could not atomically write state file: ${path}.`,
1377
- nextAction: "Check write access to ~/.devbox and run the command again."
1378
- });
1379
- }
1380
- }
1381
- async function composeStateWrite(input) {
1382
- const claudeHostConfiguration = await ensureClaudeHostConfiguration(input.devboxHome, input.globalConfiguration);
1383
- if (!claudeHostConfiguration.ok) return claudeHostConfiguration;
1384
- const rendered = renderProjectCompose({
1385
- projectRoot: input.projectRoot,
1386
- sandboxName: input.sandboxName,
1387
- selectedNode: input.localConfiguration.node,
1388
- configuredAgents: input.globalConfiguration.agent,
1389
- agentNotifications: input.globalConfiguration.agent_notifications,
1390
- claudeHostConfiguration: claudeHostConfiguration.value,
1391
- ports: input.localConfiguration.ports
1392
- });
1393
- if (!rendered.ok) return rendered;
1394
- const path = join(input.stateDirectory, "compose.yaml");
1395
- const previous = await readOptionalFile(path);
1396
- if (!previous.ok) return previous;
1397
- return success({
1398
- path,
1399
- content: rendered.value,
1400
- previous: previous.value
1401
- });
1402
- }
1403
- async function ensureClaudeHostConfiguration(devboxHome, globalConfiguration) {
1404
- if (!globalConfiguration.agent.includes("claude-code")) return success(void 0);
1405
- const path = devboxPaths(devboxHome).claudeHostConfiguration;
1406
- try {
1407
- if ((await lstat(path)).isFile()) return success(path);
1408
- return failure({
1409
- kind: "operational",
1410
- code: "claude-host-configuration-invalid",
1411
- observed: `Claude host configuration is not a regular file: ${path}.`,
1412
- nextAction: "Replace it with a regular file, then run the command again."
1413
- });
1414
- } catch (error) {
1415
- if (!isMissingFileError(error)) return failure({
1416
- kind: "operational",
1417
- code: "claude-host-configuration-unreadable",
1418
- observed: `Devbox could not inspect Claude host configuration: ${path}.`,
1419
- nextAction: "Check access to ~/.devbox and run the command again."
1420
- });
1421
- }
1422
- try {
1423
- await mkdir(dirname(path), {
1424
- recursive: true,
1425
- mode: 448
1426
- });
1427
- await writeFile(path, "{}\n", {
1428
- encoding: "utf8",
1429
- flag: "wx",
1430
- mode: 384
1431
- });
1432
- return success(path);
1433
- } catch (error) {
1434
- if (error.code === "EEXIST") return ensureClaudeHostConfiguration(devboxHome, globalConfiguration);
1435
- return failure({
1436
- kind: "operational",
1437
- code: "claude-host-configuration-write-failed",
1438
- observed: `Devbox could not create Claude host configuration: ${path}.`,
1439
- nextAction: "Check write access to ~/.devbox and run the command again."
1440
- });
1441
- }
1442
- }
1443
- async function writeStateTransaction(writes) {
1444
- const staged = [];
1445
- for (const write of writes) {
1446
- const stagedWrite = await stageStateWrite(write.path, write.content, "transaction.tmp");
1447
- if (!stagedWrite.ok) {
1448
- await Promise.all(staged.map((stage) => rm(stage.path, { force: true }).catch(() => void 0)));
1449
- return stagedWrite;
1450
- }
1451
- staged.push({
1452
- write,
1453
- path: stagedWrite.value
1454
- });
1455
- }
1456
- const published = [];
1457
- try {
1458
- for (const stage of staged) {
1459
- await rename(stage.path, stage.write.path);
1460
- published.push(stage.write);
1461
- }
1462
- return success(void 0);
1463
- } catch {
1464
- let restored = true;
1465
- for (const write of published.reverse()) if (write.previous.exists) {
1466
- const restoredWrite = await writeAtomically(write.path, write.previous.content);
1467
- restored &&= restoredWrite.ok;
1468
- } else try {
1469
- await rm(write.path, { force: true });
1470
- } catch {
1471
- restored = false;
1472
- }
1473
- await Promise.all(staged.map((stage) => rm(stage.path, { force: true }).catch(() => void 0)));
1474
- return failure({
1475
- kind: "operational",
1476
- code: "state-write-failed",
1477
- observed: restored ? "Devbox could not publish Project state; the previous state was restored." : "Devbox could not publish Project state or restore the previous state.",
1478
- nextAction: "Check write access to ~/.devbox and run the command again."
1479
- });
1480
- }
1481
- }
1482
1392
  function validateGlobalObject(configuration, catalog) {
1483
1393
  try {
1484
1394
  return parseGlobalConfiguration(serializeGlobalConfiguration(configuration), catalog);
@@ -1547,17 +1457,15 @@ function createConfigurationPrompter({ signal, input, output }) {
1547
1457
  message: "Where should this configuration apply?",
1548
1458
  options: [{
1549
1459
  value: "local",
1550
- label: "Current project",
1551
- hint: "Enabled when this project’s container starts."
1460
+ label: "Current project"
1552
1461
  }, {
1553
1462
  value: "global",
1554
- label: "Workspace Docker image",
1555
- hint: "Built into the shared image. It affects image size and disk usage."
1463
+ label: "Workspace Docker image"
1556
1464
  }],
1557
1465
  ...common
1558
1466
  })),
1559
1467
  editGlobal: async (configuration, catalog) => {
1560
- p.log.step(`${c.bold(c.cyan("Workspace Docker image"))}\n${c.dim("Add runtimes and agents to the shared image. They are available to every project and increase the image size.")}`, common);
1468
+ p.log.step(c.bold(c.cyan("Workspace Docker image")), common);
1561
1469
  return {
1562
1470
  version: 1,
1563
1471
  node: await promptValue(p.multiselect({
@@ -1582,7 +1490,7 @@ function createConfigurationPrompter({ signal, input, output }) {
1582
1490
  };
1583
1491
  },
1584
1492
  editLocal: async (configuration, catalog, globalConfiguration, editPorts = true) => {
1585
- p.log.step(`${c.bold(c.cyan("Project configuration"))}\n${c.dim(editPorts ? "Choose the Node version and ports to use when this project’s container starts." : "Choose the Node version to use when this project’s container starts.")}`, common);
1493
+ p.log.step(c.bold(c.cyan("Project configuration")), common);
1586
1494
  const node = await promptValue(p.select({
1587
1495
  message: "Selected Node release line",
1588
1496
  options: [{
@@ -1600,7 +1508,7 @@ function createConfigurationPrompter({ signal, input, output }) {
1600
1508
  node
1601
1509
  };
1602
1510
  const ports = normalizePortMappings((await promptValue(p.multiline({
1603
- message: "Published ports",
1511
+ message: "Published ports" + c.dim(" (multiline; one mapping per line; press Enter twice to submit)"),
1604
1512
  initialValue: configuration.ports.join("\n"),
1605
1513
  validate: (value) => {
1606
1514
  const ports = normalizePortMappings((value ?? "").split("\n"));
@@ -1652,20 +1560,28 @@ function showLogo() {
1652
1560
  for (let i = 0; i < ASCII_ART.length; i++) console.log(`${COLORS[i]}${ASCII_ART[i]}${REST}`);
1653
1561
  }
1654
1562
  //#endregion
1655
- //#region \0devbox-image-default:/home/runner/work/devbox/devbox/src/agent/image-defaults/.claude/settings.json.mjs
1656
- var settings_json_default = "{\n \"$schema\": \"https://json.schemastore.org/claude-code-settings.json\",\n \"env\": {\n \"CLAUDE_CODE_USE_POWERSHELL_TOOL\": \"0\"\n },\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"includeGitInstructions\": false,\n \"autoMemoryEnabled\": false\n}\n";
1657
- //#endregion
1658
- //#region \0devbox-image-default:/home/runner/work/devbox/devbox/src/agent/image-defaults/.omp/agent/config.yml.mjs
1659
- var config_yml_default = "setupVersion: 2\n\n# Basic\nmodelRoles:\n default: openai-codex/gpt-5.6-terra:high\n smol: openai-codex/gpt-5.6-luna:high\n slow: openai-codex/gpt-5.6-sol:max\n plan: openai-codex/gpt-5.6-sol:max\nsymbolPreset: unicode\n\n# Theme\ntheme:\n dark: titanium\n light: light\ncomposer:\n shape: claude\n\n# Auto QA\ndev:\n autoqa: false\n autoqaConsent: denied\n";
1660
- //#endregion
1661
1563
  //#region src/agent/build.ts
1564
+ const AGENT_BUILD_CONTEXT_ASSETS = {
1565
+ instructions: {
1566
+ relativePath: "AGENTS.md",
1567
+ content: "# 協作規範\n\n## 語言\n\n- 一律使用繁體中文台灣用語產生對話及結果\n- 嚴禁使用簡體字,或是繁體字但中國大陸用語\n- 用詞要口語、常見,避免生硬或翻譯腔的詞彙;例如「根因」改用「原因」、「根本原因」、「主要原因」\n\n## 開發規範\n\n### 共用原則\n\n- 優先遵循專案既有的 `AGENTS.md`、文件、設定檔、lockfile、指令與程式碼慣例;專案沒有既有規範時,才使用本文件的預設選擇\n- 優先使用目前環境提供的專用工具;只有在專用工具無法完成工作或需要執行專案指令時,才使用適合的 CLI 工具\n- 執行會修改檔案、安裝套件或變更環境的指令前,確認目前位於預期的工作目錄\n- 可以在目前 workspace 內存取所需的上層目錄;存取 workspace 以外的檔案或目錄前,必須先取得使用者同意\n- 嚴禁執行任何會遞迴刪除根目錄的指令(例如 `rm -rf /`),也不得改寫或規避為效果相同的形式\n- 執行其他可能造成不可逆資料遺失的操作前,必須說明影響並取得使用者明確同意\n\n### JavaScript / TypeScript\n\n- 優先使用專案既有的 Node.js 版本設定與套件管理器\n- 專案未指定套件管理器且不存在 lockfile 時,使用 npm\n- 需要執行專案套件提供的 CLI 時,使用專案既有套件管理器對應的執行方式;使用 npm 時使用 npx\n- 不得擅自更換套件管理器、刪除 lockfile 或產生不同格式的 lockfile\n- TypeScript 格式規則\n - 陣列型別優先使用 `T[]`,避免使用 `Array<T>`\n - 字典型物件優先使用 `Record<string, T>`,避免使用 `{ [key: string]: T }`\n- Vue 3 程式碼變更前,先讀取 `/ycs77-vue` skill\n\n### PHP\n\n- 優先使用專案既有的 Composer scripts\n\n### 常見 CLI 工具\n\n- 版本控制操作使用 `git`\n- 所有 Git 操作都必須直接執行 `git` CLI\n- 禁止透過 Eval、JavaScript、Bun、Node.js、Python 或臨時程式碼間接包裝或呼叫 Git 指令\n- GitHub 相關操作優先使用 `gh` CLI\n\n## 安全\n\n- 在任何對話、指令輸出或程式碼中,不得揭露密碼、金鑰、Access Token、API Key 等機密資料\n- 不得讀取或輸出 `.env`、`.env.*` 等可能包含機密資料的檔案;不含真實機密的範本檔案(例如 `.env.example`)除外\n- 開發時需要使用機密資料,應以環境變數名稱或假值代稱,不得嘗試取得實際值\n"
1568
+ },
1569
+ claude: {
1570
+ relativePath: ".claude/settings.json",
1571
+ content: "{\n \"$schema\": \"https://json.schemastore.org/claude-code-settings.json\",\n \"env\": {\n \"CLAUDE_CODE_USE_POWERSHELL_TOOL\": \"0\"\n },\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"includeGitInstructions\": false,\n \"autoMemoryEnabled\": false\n}\n"
1572
+ },
1573
+ omp: {
1574
+ relativePath: ".omp/agent/config.yml",
1575
+ content: "setupVersion: 2\n\n# Basic\nmodelRoles:\n default: openai-codex/gpt-5.6-terra:high\n smol: openai-codex/gpt-5.6-luna:high\n slow: openai-codex/gpt-5.6-sol:max\n plan: openai-codex/gpt-5.6-sol:max\nsymbolPreset: unicode\n\n# Theme\ntheme:\n dark: titanium\n light: light\ncomposer:\n shape: claude\n\n# Auto QA\ndev:\n autoqa: false\n autoqaConsent: denied\n"
1576
+ }
1577
+ };
1662
1578
  function createAgentWorkspaceContribution(input) {
1663
1579
  const agents = input.configuredAgents.map((name) => ({
1664
1580
  name,
1665
1581
  recipe: PACKAGED_AGENTS[name]
1666
1582
  }));
1667
1583
  const buildContextAssets = selectBuildContextAssets(agents);
1668
- const skillAgents = agents.filter((agent) => agent.recipe.supportsSkillInstallation).map((agent) => agent.name);
1584
+ const skillAgents = [...new Set(agents.flatMap((agent) => agent.recipe.skillInstallationTarget === void 0 ? [] : [agent.recipe.skillInstallationTarget]))];
1669
1585
  return {
1670
1586
  buildContextAssets,
1671
1587
  dockerignoreEntries: selectDockerignoreEntries(buildContextAssets),
@@ -1690,37 +1606,30 @@ function renderAgentInstallations(agents, buildNodeRuntimeRoot, skillAgents) {
1690
1606
  }
1691
1607
  function selectDockerignoreEntries(assets) {
1692
1608
  const entries = [];
1693
- if (assets.some((asset) => asset.relativePath === ".claude/settings.json")) entries.push("!.claude", "!.claude/settings.json");
1694
- if (assets.some((asset) => asset.relativePath === ".omp/agent/config.yml")) entries.push("!.omp", "!.omp/agent", "!.omp/agent/config.yml");
1609
+ if (assets.some((asset) => asset.relativePath === "AGENTS.md")) entries.push("!AGENTS.md");
1610
+ if (assets.some((asset) => asset.relativePath.startsWith(".claude/"))) entries.push("!.claude", "!.claude/settings.json");
1611
+ if (assets.some((asset) => asset.relativePath.startsWith(".omp/agent/"))) entries.push("!.omp", "!.omp/agent", "!.omp/agent/config.yml");
1695
1612
  return entries;
1696
1613
  }
1697
1614
  function selectBuildContextAssets(agents) {
1698
1615
  const assets = [];
1699
- if (agents.some((agent) => agent.name === "claude-code")) assets.push({
1700
- relativePath: ".claude/settings.json",
1701
- content: settings_json_default
1702
- });
1703
- if (agents.some((agent) => agent.name === "omp")) assets.push({
1704
- relativePath: ".omp/agent/config.yml",
1705
- content: config_yml_default
1706
- });
1616
+ if (agents.length > 0) assets.push(AGENT_BUILD_CONTEXT_ASSETS.instructions);
1617
+ if (agents.some((agent) => agent.name === "claude-code")) assets.push(AGENT_BUILD_CONTEXT_ASSETS.claude);
1618
+ if (agents.some((agent) => agent.name === "omp")) assets.push(AGENT_BUILD_CONTEXT_ASSETS.omp);
1707
1619
  return assets;
1708
1620
  }
1709
1621
  function renderAgentAssets(agents) {
1710
1622
  const hasClaudeCode = agents.some((agent) => agent.name === "claude-code");
1623
+ const hasCodex = agents.some((agent) => agent.name === "codex");
1624
+ const hasAgy = agents.some((agent) => agent.name === "agy");
1711
1625
  const hasOmp = agents.some((agent) => agent.name === "omp");
1712
- if (!hasClaudeCode && !hasOmp) return [];
1713
- const ownershipCommands = [];
1626
+ if (!hasClaudeCode && !hasCodex && !hasAgy && !hasOmp) return [];
1714
1627
  const lines = ["# Copy AI dotfiles"];
1715
- if (hasClaudeCode) {
1716
- lines.push("COPY .claude/settings.json /home/devbox/.claude/settings.json");
1717
- ownershipCommands.push("chown devbox:devbox /home/devbox/.claude/settings.json");
1718
- }
1719
- if (hasOmp) {
1720
- lines.push("COPY .omp/agent/config.yml /home/devbox/.omp/agent/config.yml");
1721
- ownershipCommands.push("chown devbox:devbox /home/devbox/.omp/agent", "chown devbox:devbox /home/devbox/.omp/agent/config.yml");
1722
- }
1723
- lines.push(`RUN ${ownershipCommands.join(" \\\n && ")}`, "");
1628
+ if (hasClaudeCode) lines.push("COPY --chown=devbox:devbox .claude/settings.json /home/devbox/.claude/settings.json", "COPY --chown=devbox:devbox AGENTS.md /home/devbox/.claude/CLAUDE.md");
1629
+ if (hasCodex) lines.push("COPY --chown=devbox:devbox AGENTS.md /home/devbox/.codex/AGENTS.md");
1630
+ if (hasAgy) lines.push("COPY --chown=devbox:devbox AGENTS.md /home/devbox/.gemini/GEMINI.md");
1631
+ if (hasOmp) lines.push("COPY --chown=devbox:devbox .omp/agent/config.yml /home/devbox/.omp/agent/config.yml", "COPY --chown=devbox:devbox AGENTS.md /home/devbox/.omp/agent/AGENTS.md");
1632
+ lines.push("");
1724
1633
  return lines;
1725
1634
  }
1726
1635
  function renderAgentNotifications(agents, notificationsEnabled) {
@@ -1872,7 +1781,7 @@ async function buildWorkspaceUnlocked(input) {
1872
1781
  kind: "operational",
1873
1782
  code: "global-configuration-missing",
1874
1783
  observed: "Global configuration is required before building the Workspace image.",
1875
- nextAction: "Run devbox config to commit Global configuration before devbox build."
1784
+ nextAction: "Run devbox init to commit Global configuration before devbox build."
1876
1785
  });
1877
1786
  }
1878
1787
  const parsedGlobal = parseGlobalConfiguration(globalSource, catalogCheck.value);
@@ -1892,7 +1801,7 @@ async function buildWorkspaceUnlocked(input) {
1892
1801
  agent
1893
1802
  });
1894
1803
  await mkdir(paths.buildContext, { recursive: true });
1895
- await seedBuildContextAssets(paths.buildContext, buildContextAssets);
1804
+ await refreshBuildContextAssets(paths.buildContext, buildContextAssets);
1896
1805
  await Promise.all([
1897
1806
  writeFile(join(paths.buildContext, "Dockerfile"), dockerfile),
1898
1807
  writeFile(join(paths.buildContext, ".dockerignore"), renderDockerignore(buildContextAssets, agent.dockerignoreEntries)),
@@ -1972,7 +1881,7 @@ function renderBuildDockerfile(input) {
1972
1881
  lines.push("WORKDIR /workspace", "USER devbox", "");
1973
1882
  lines.push(...input.node.sandboxUserSetup);
1974
1883
  lines.push(...input.agent.installationDockerfileLines);
1975
- lines.push("USER root", "", "# Copy dotfiles", "COPY .bash_aliases /home/devbox/.bash_aliases", "COPY .gitconfig /home/devbox/.gitconfig", "RUN chown devbox:devbox /home/devbox/.bash_aliases \\", " && chown devbox:devbox /home/devbox/.gitconfig", "");
1884
+ lines.push("USER root", "", "# Copy dotfiles", "COPY --chown=devbox:devbox .bash_aliases /home/devbox/.bash_aliases", "COPY --chown=devbox:devbox .gitconfig /home/devbox/.gitconfig", "");
1976
1885
  lines.push(...input.agent.assetDockerfileLines);
1977
1886
  lines.push(...input.agent.notificationDockerfileLines);
1978
1887
  lines.push("COPY entrypoint.sh /usr/local/bin/entrypoint.sh", "COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf", "RUN chmod +x /usr/local/bin/entrypoint.sh", "", "ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]");
@@ -1989,15 +1898,13 @@ function renderDockerignore(assets, additionalEntries) {
1989
1898
  ...additionalEntries
1990
1899
  ].join("\n")}\n`;
1991
1900
  }
1992
- async function seedBuildContextAssets(buildContext, assets) {
1901
+ async function refreshBuildContextAssets(buildContext, assets) {
1902
+ const requiredPaths = new Set(assets.map((asset) => asset.relativePath));
1903
+ for (const asset of Object.values(AGENT_BUILD_CONTEXT_ASSETS)) if (!requiredPaths.has(asset.relativePath)) await rm(join(buildContext, asset.relativePath), { force: true });
1993
1904
  await Promise.all(assets.map(async (asset) => {
1994
1905
  const destination = join(buildContext, asset.relativePath);
1995
1906
  await mkdir(dirname(destination), { recursive: true });
1996
- try {
1997
- await writeFile(destination, asset.content, { flag: "wx" });
1998
- } catch (error) {
1999
- if (typeof error !== "object" || error === null || !("code" in error) || error.code !== "EEXIST") throw error;
2000
- }
1907
+ await writeFile(destination, asset.content);
2001
1908
  }));
2002
1909
  }
2003
1910
  function renderEntrypoint(input) {
@@ -2088,7 +1995,7 @@ function createCli(signal, interactive) {
2088
1995
  outro("Project is already registered.");
2089
1996
  return success({});
2090
1997
  }
2091
- outro(`Next: run ${c.cyan(c.bold("devbox build"))} to prepare your workspace, then ${c.cyan(c.bold("devbox up"))} to start it.`);
1998
+ outro(result.value.globalConfigurationCreated ? `Next: run ${command("devbox build")} to prepare your workspace, then ${command("devbox up")} to start it.` : `Next: run ${command("devbox up")} to start it.`);
2092
1999
  return success({});
2093
2000
  });
2094
2001
  cli.command("config", "Edit Global or Local configuration for the current Project.").action(async () => {
@@ -2100,7 +2007,7 @@ function createCli(signal, interactive) {
2100
2007
  prompt
2101
2008
  });
2102
2009
  if (!result.ok) return result;
2103
- outro(result.value.changed ? "Global configuration updated." : "Global configuration was not changed.");
2010
+ outro(result.value.changed ? `Global configuration updated. Run ${command("devbox build")} to rebuild the Workspace image.` : "Global configuration was not changed.");
2104
2011
  return success({});
2105
2012
  }
2106
2013
  const result = await configureLocalProject({
@@ -2119,7 +2026,7 @@ function createCli(signal, interactive) {
2119
2026
  if (!result.ok) return result;
2120
2027
  return success({ message: `Built Workspace image: ${result.value.image}` });
2121
2028
  });
2122
- cli.command("rm", "Remove the current registered Project and its state.").option("--yes", "Skip the confirmation prompt.").action(async (options = {}) => {
2029
+ cli.command("rm", "Remove the current Project Sandbox and Devbox state.").option("--yes", "Skip the confirmation prompt.").action(async (options = {}) => {
2123
2030
  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.");
2124
2031
  const result = await removeProject({
2125
2032
  signal,
@@ -2129,22 +2036,6 @@ function createCli(signal, interactive) {
2129
2036
  if (!result.ok) return result;
2130
2037
  return success({ message: result.value.removed ? `Removed Project: ${result.value.root}` : "Project removal was not changed." });
2131
2038
  });
2132
- 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 = {}) => {
2133
- if (!options.missingProjects) return failure({
2134
- kind: "usage",
2135
- code: "cleanup-mode-required",
2136
- observed: "cleanup requires --missing-projects.",
2137
- nextAction: "Run devbox cleanup --missing-projects."
2138
- });
2139
- 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.");
2140
- const result = await cleanupMissingProjects({
2141
- signal,
2142
- confirm: prompt.confirm,
2143
- yes: options.yes
2144
- });
2145
- if (!result.ok) return result;
2146
- return success({ message: result.value.removed ? `Removed Missing-root Projects: ${result.value.roots.join(", ")}` : "No Missing-root Projects were removed." });
2147
- });
2148
2039
  for (const [command, description] of [
2149
2040
  ["up", "Start the current Project Sandbox."],
2150
2041
  ["down", "Stop and remove the current Project Sandbox."],
@@ -2163,7 +2054,7 @@ function createCli(signal, interactive) {
2163
2054
  }
2164
2055
  function present(result) {
2165
2056
  if (!result.ok) {
2166
- process.stderr.write(`${result.error.observed}\n${result.error.nextAction}\n`);
2057
+ process.stderr.write(`${highlightCommands(result.error.observed)}\n${highlightCommands(result.error.nextAction)}\n`);
2167
2058
  return result.error.kind === "usage" ? 2 : 1;
2168
2059
  }
2169
2060
  if (result.value.message !== void 0) process.stdout.write(`${result.value.message}\n`);
@@ -2196,6 +2087,12 @@ async function main() {
2196
2087
  function isCacError(error) {
2197
2088
  return error instanceof Error && error.name === "CACError";
2198
2089
  }
2090
+ function command(value) {
2091
+ return c.cyan(c.bold(value));
2092
+ }
2093
+ function highlightCommands(message) {
2094
+ return message.replace(/\bdevbox (?:init|build|config|up|down|stop|sh|rm)\b/g, command);
2095
+ }
2199
2096
  function interactiveFailure(observed, nextAction) {
2200
2097
  return failure({
2201
2098
  kind: "usage",