@ycs77/devbox 0.1.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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/cli.js +293 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Lucas Yang <yangchenshin77@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { defineCommand, runCommand } from "citty";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { link, lstat, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { isAbsolute, join, parse, relative, sep } from "node:path";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
//#region src/result.ts
|
|
13
|
+
function success(value) {
|
|
14
|
+
return {
|
|
15
|
+
ok: true,
|
|
16
|
+
value
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function failure(error) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
error
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/host.ts
|
|
28
|
+
const execFile$1 = promisify(execFile);
|
|
29
|
+
const supportedHostAction = "Run Devbox from 64-bit WSL2 with Docker Desktop and Docker Compose available.";
|
|
30
|
+
function currentHostEnvironment() {
|
|
31
|
+
return {
|
|
32
|
+
platform: process.platform,
|
|
33
|
+
arch: process.arch,
|
|
34
|
+
procVersion: () => readFile("/proc/version", "utf8"),
|
|
35
|
+
run: async (file, args) => {
|
|
36
|
+
await execFile$1(file, args);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function validateSupportedHost(environment = currentHostEnvironment()) {
|
|
41
|
+
if (environment.platform !== "linux" || environment.arch !== "x64") return failure({
|
|
42
|
+
kind: "validation",
|
|
43
|
+
code: "unsupported-host",
|
|
44
|
+
observed: `Devbox supports only WSL2 linux/amd64; detected ${environment.platform}/${environment.arch}.`,
|
|
45
|
+
nextAction: supportedHostAction
|
|
46
|
+
});
|
|
47
|
+
let procVersion;
|
|
48
|
+
try {
|
|
49
|
+
procVersion = await environment.procVersion();
|
|
50
|
+
} catch {
|
|
51
|
+
return failure({
|
|
52
|
+
kind: "validation",
|
|
53
|
+
code: "unsupported-host",
|
|
54
|
+
observed: "Devbox could not verify that this Linux host is WSL2.",
|
|
55
|
+
nextAction: supportedHostAction
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!/microsoft|wsl/i.test(procVersion)) return failure({
|
|
59
|
+
kind: "validation",
|
|
60
|
+
code: "unsupported-host",
|
|
61
|
+
observed: "Devbox supports WSL2, but this Linux host is not WSL2.",
|
|
62
|
+
nextAction: supportedHostAction
|
|
63
|
+
});
|
|
64
|
+
for (const [file, args, description] of [[
|
|
65
|
+
"docker",
|
|
66
|
+
["--version"],
|
|
67
|
+
"Docker"
|
|
68
|
+
], [
|
|
69
|
+
"docker",
|
|
70
|
+
["compose", "version"],
|
|
71
|
+
"Docker Compose"
|
|
72
|
+
]]) try {
|
|
73
|
+
await environment.run(file, args);
|
|
74
|
+
} catch {
|
|
75
|
+
return failure({
|
|
76
|
+
kind: "validation",
|
|
77
|
+
code: "missing-host-prerequisite",
|
|
78
|
+
observed: `${description} is unavailable.`,
|
|
79
|
+
nextAction: supportedHostAction
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return success(void 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/project.ts
|
|
87
|
+
function projectStateDirectory(projectRoot, devboxHome) {
|
|
88
|
+
const root = parse(projectRoot).root;
|
|
89
|
+
const pathWithinRoot = relative(root, projectRoot);
|
|
90
|
+
if (!isAbsolute(projectRoot) || pathWithinRoot === ".." || pathWithinRoot.startsWith(`..${sep}`)) throw new TypeError("Project root must be an absolute path.");
|
|
91
|
+
return join(devboxHome, "projects", pathWithinRoot === "" ? "root" : pathWithinRoot.split(sep).map(escapePathSegment).join("-"));
|
|
92
|
+
}
|
|
93
|
+
function escapePathSegment(segment) {
|
|
94
|
+
const bytes = new TextEncoder().encode(segment);
|
|
95
|
+
let escaped = "";
|
|
96
|
+
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);
|
|
97
|
+
else escaped += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
98
|
+
return escaped;
|
|
99
|
+
}
|
|
100
|
+
async function initializeProject(input = {}) {
|
|
101
|
+
const projectRoot = input.root ?? process.cwd();
|
|
102
|
+
const rootCheck = await validateProjectRoot(projectRoot);
|
|
103
|
+
if (!rootCheck.ok) return rootCheck;
|
|
104
|
+
if (input.signal?.aborted) throw new InterruptedError();
|
|
105
|
+
const hostCheck = await (input.validateHost ?? validateSupportedHost)();
|
|
106
|
+
if (!hostCheck.ok) return hostCheck;
|
|
107
|
+
if (input.signal?.aborted) throw new InterruptedError();
|
|
108
|
+
const stateDirectory = projectStateDirectory(projectRoot, input.devboxHome ?? join(homedir(), ".devbox"));
|
|
109
|
+
const configurationPath = join(stateDirectory, "config.yaml");
|
|
110
|
+
try {
|
|
111
|
+
await mkdir(stateDirectory, {
|
|
112
|
+
recursive: true,
|
|
113
|
+
mode: 448
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
return failure({
|
|
117
|
+
kind: "operational",
|
|
118
|
+
code: "state-directory-unavailable",
|
|
119
|
+
observed: `Devbox could not create its Project state directory: ${stateDirectory}.`,
|
|
120
|
+
nextAction: "Check write access to ~/.devbox and run devbox init again."
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (input.signal?.aborted) throw new InterruptedError();
|
|
124
|
+
const temporaryPath = join(stateDirectory, `.config-${process.pid}-${randomUUID()}.tmp`);
|
|
125
|
+
const content = `version: 1\nprojectRoot: ${JSON.stringify(projectRoot)}\n`;
|
|
126
|
+
try {
|
|
127
|
+
await writeFile(temporaryPath, content, {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
flag: "wx",
|
|
130
|
+
mode: 384
|
|
131
|
+
});
|
|
132
|
+
await link(temporaryPath, configurationPath);
|
|
133
|
+
await unlink(temporaryPath);
|
|
134
|
+
return success({
|
|
135
|
+
root: projectRoot,
|
|
136
|
+
stateDirectory,
|
|
137
|
+
created: true
|
|
138
|
+
});
|
|
139
|
+
} catch (cause) {
|
|
140
|
+
await unlink(temporaryPath).catch(() => void 0);
|
|
141
|
+
if (isFileExistsError(cause)) return success({
|
|
142
|
+
root: projectRoot,
|
|
143
|
+
stateDirectory,
|
|
144
|
+
created: false
|
|
145
|
+
});
|
|
146
|
+
return failure({
|
|
147
|
+
kind: "operational",
|
|
148
|
+
code: "registration-write-failed",
|
|
149
|
+
observed: `Devbox could not register this Project in ${stateDirectory}.`,
|
|
150
|
+
nextAction: "Check write access to ~/.devbox and run devbox init again."
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function validateProjectRoot(projectRoot) {
|
|
155
|
+
if (!isAbsolute(projectRoot)) return failure({
|
|
156
|
+
kind: "validation",
|
|
157
|
+
code: "invalid-project-root",
|
|
158
|
+
observed: `The current Project directory is not absolute: ${projectRoot}.`,
|
|
159
|
+
nextAction: "Run devbox init from an existing directory."
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
if (!(await lstat(projectRoot)).isDirectory()) return failure({
|
|
163
|
+
kind: "validation",
|
|
164
|
+
code: "invalid-project-root",
|
|
165
|
+
observed: `The current Project path is not a directory: ${projectRoot}.`,
|
|
166
|
+
nextAction: "Run devbox init from an existing directory."
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
return failure({
|
|
170
|
+
kind: "validation",
|
|
171
|
+
code: "missing-project-root",
|
|
172
|
+
observed: `The current Project directory does not exist: ${projectRoot}.`,
|
|
173
|
+
nextAction: "Change to an existing directory and run devbox init again."
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return success(void 0);
|
|
177
|
+
}
|
|
178
|
+
function isFileExistsError(error) {
|
|
179
|
+
return typeof error === "object" && error !== null && error.code === "EEXIST";
|
|
180
|
+
}
|
|
181
|
+
var InterruptedError = class extends Error {
|
|
182
|
+
constructor() {
|
|
183
|
+
super("Devbox command interrupted.");
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/cli.ts
|
|
189
|
+
const initCommand = defineCommand({
|
|
190
|
+
meta: {
|
|
191
|
+
name: "init",
|
|
192
|
+
description: "Register the current directory as a Devbox Project."
|
|
193
|
+
},
|
|
194
|
+
run: async (context) => {
|
|
195
|
+
if (context.rawArgs.length > 0) return failure({
|
|
196
|
+
kind: "usage",
|
|
197
|
+
code: "unexpected-init-arguments",
|
|
198
|
+
observed: "devbox init does not accept arguments.",
|
|
199
|
+
nextAction: "Run devbox init without arguments."
|
|
200
|
+
});
|
|
201
|
+
const registration = await context.data.initializeProject();
|
|
202
|
+
if (!registration.ok) return registration;
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
value: {
|
|
206
|
+
projectRoot: registration.value.root,
|
|
207
|
+
created: registration.value.created
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
const rootCommand = defineCommand({
|
|
213
|
+
meta: {
|
|
214
|
+
name: "devbox",
|
|
215
|
+
description: "Prepare and run the Devbox Node Toolchain."
|
|
216
|
+
},
|
|
217
|
+
args: { command: {
|
|
218
|
+
type: "positional",
|
|
219
|
+
required: true
|
|
220
|
+
} },
|
|
221
|
+
run: async (context) => {
|
|
222
|
+
if (context.args.command !== "init") return failure({
|
|
223
|
+
kind: "usage",
|
|
224
|
+
code: "unknown-command",
|
|
225
|
+
observed: `Unknown command: ${context.args.command}.`,
|
|
226
|
+
nextAction: "Run devbox --help to see available commands."
|
|
227
|
+
});
|
|
228
|
+
const { result } = await runCommand(initCommand, {
|
|
229
|
+
rawArgs: context.rawArgs.slice(1),
|
|
230
|
+
data: context.data
|
|
231
|
+
});
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
async function runCli(rawArgs, dependencies = { initializeProject }) {
|
|
236
|
+
if (rawArgs.length === 1 && (rawArgs[0] === "--help" || rawArgs[0] === "-h")) return {
|
|
237
|
+
ok: true,
|
|
238
|
+
value: {
|
|
239
|
+
projectRoot: "",
|
|
240
|
+
created: false
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
try {
|
|
244
|
+
const { result } = await runCommand(rootCommand, {
|
|
245
|
+
rawArgs: [...rawArgs],
|
|
246
|
+
data: dependencies
|
|
247
|
+
});
|
|
248
|
+
return result;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (error instanceof InterruptedError) throw error;
|
|
251
|
+
return failure({
|
|
252
|
+
kind: "usage",
|
|
253
|
+
code: "invalid-command-usage",
|
|
254
|
+
observed: error instanceof Error ? error.message : "Invalid Devbox command usage.",
|
|
255
|
+
nextAction: "Run devbox --help to see available commands."
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function present(result, output = process) {
|
|
260
|
+
if (!result.ok) {
|
|
261
|
+
output.stderr.write(`${result.error.observed}\n${result.error.nextAction}\n`);
|
|
262
|
+
return result.error.kind === "usage" ? 2 : 1;
|
|
263
|
+
}
|
|
264
|
+
if (result.value.projectRoot === "") {
|
|
265
|
+
output.stdout.write("Usage: devbox init\n\nRegister the current directory as a Devbox Project.\n");
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
output.stdout.write(result.value.created ? `Registered Project: ${result.value.projectRoot}\n` : `Project is already registered: ${result.value.projectRoot}\n`);
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
async function main() {
|
|
272
|
+
const abortController = new AbortController();
|
|
273
|
+
const interrupt = () => abortController.abort();
|
|
274
|
+
process.once("SIGINT", interrupt);
|
|
275
|
+
try {
|
|
276
|
+
const result = await runCli(process.argv.slice(2), { initializeProject: () => initializeProject({ signal: abortController.signal }) });
|
|
277
|
+
process.exitCode = present(result);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof InterruptedError || abortController.signal.aborted) {
|
|
280
|
+
process.stderr.write("Devbox command interrupted.\n");
|
|
281
|
+
process.exitCode = 130;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
process.stderr.write("Devbox encountered an unexpected failure. Run devbox init again.\n");
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
} finally {
|
|
287
|
+
process.off("SIGINT", interrupt);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (process.argv[1] !== void 0 && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main();
|
|
291
|
+
|
|
292
|
+
//#endregion
|
|
293
|
+
export { present, runCli };
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ycs77/devbox",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Devbox Node-only MVP CLI",
|
|
5
|
+
"homepage": "https://github.com/ycs77/devbox",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ycs77/devbox.git",
|
|
10
|
+
"directory": "packages/devbox"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"devbox": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"citty": "^0.2.2"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^26.0.1",
|
|
27
|
+
"oxfmt": "^0.62.0",
|
|
28
|
+
"oxlint": "^1.77.0",
|
|
29
|
+
"oxlint-tsgolint": "^7.0.2001",
|
|
30
|
+
"tsdown": "^0.15.4",
|
|
31
|
+
"typescript": "^7.0.2",
|
|
32
|
+
"vitest": "^4.1.10"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsdown",
|
|
39
|
+
"lint": "oxlint",
|
|
40
|
+
"fmt": "oxfmt",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "vitest",
|
|
43
|
+
"check": "pnpm lint && pnpm fmt --check && pnpm typecheck && pnpm test --run"
|
|
44
|
+
}
|
|
45
|
+
}
|