@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/README.md +116 -0
- package/bin/devbox.mjs +2 -0
- package/dist/cli.mjs +1835 -0
- package/package.json +31 -11
- package/dist/cli.js +0 -293
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 {};
|