sandbox-vibe 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/dist/cli.mjs ADDED
@@ -0,0 +1,1005 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import { readFileSync as readFileSync3 } from "fs";
6
+ import { homedir as homedir2 } from "os";
7
+ import { dirname as dirname2, join as join6 } from "path";
8
+ import { fileURLToPath as fileURLToPath2 } from "url";
9
+
10
+ // src/config.ts
11
+ import { readFileSync, unlinkSync, writeFileSync } from "fs";
12
+ import { join } from "path";
13
+ var CONFIG_FILE_NAME = "config.json";
14
+ var STACKS = [
15
+ "none",
16
+ "php",
17
+ "dotnet",
18
+ "python",
19
+ "go",
20
+ "rust"
21
+ ];
22
+ var DEFAULT_PLUGINS = [
23
+ "security-guidance@claude-plugins-official",
24
+ "commit-commands@claude-plugins-official",
25
+ "code-review@claude-plugins-official",
26
+ "pr-review-toolkit@claude-plugins-official",
27
+ "claude-md-management@claude-plugins-official",
28
+ "hookify@claude-plugins-official",
29
+ "feature-dev@claude-plugins-official",
30
+ "superpowers@superpowers-dev"
31
+ ];
32
+ var DEFAULT_MARKETPLACES = [
33
+ "anthropics/claude-plugins-official",
34
+ "obra/superpowers"
35
+ ];
36
+ var DEFAULT_RESOURCES = {
37
+ cpus: 4,
38
+ memoryGB: 4,
39
+ pids: 256,
40
+ tmpfsMB: 512
41
+ };
42
+ var PLUGIN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9._/-]*$/;
43
+ var MARKETPLACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
44
+ var MCP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
45
+ function validatePluginRef(value) {
46
+ if (!PLUGIN_PATTERN.test(value)) {
47
+ return "must match name@marketplace using letters, digits, '.', '_', '-' (no spaces or shell metacharacters)";
48
+ }
49
+ return true;
50
+ }
51
+ function validateMarketplaceRef(value) {
52
+ if (!MARKETPLACE_PATTERN.test(value)) {
53
+ return "must match owner/repo using letters, digits, '.', '_', '-' (no spaces or shell metacharacters)";
54
+ }
55
+ return true;
56
+ }
57
+ function validateMcpName(value) {
58
+ if (!MCP_NAME_PATTERN.test(value)) {
59
+ return "must contain only letters, digits, '_' and '-'";
60
+ }
61
+ return true;
62
+ }
63
+ function validateMcpUrl(value) {
64
+ if (/[\r\n\t]/.test(value)) {
65
+ return "URL must not contain newline, carriage return, or tab";
66
+ }
67
+ let parsed;
68
+ try {
69
+ parsed = new URL(value);
70
+ } catch {
71
+ return "must be a valid URL";
72
+ }
73
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
74
+ return "URL must use http:// or https://";
75
+ }
76
+ if (parsed.username !== "" || parsed.password !== "") {
77
+ return "URL must not contain basic-auth credentials (user:pass@); use Authorization headers instead";
78
+ }
79
+ return true;
80
+ }
81
+ function validateHostPath(value) {
82
+ if (/[\r\n]/.test(value)) {
83
+ return "path must not contain newline or carriage return";
84
+ }
85
+ if (value.includes(":")) {
86
+ return "path must not contain ':'";
87
+ }
88
+ if (!value.startsWith("/")) {
89
+ return "path must be absolute (start with '/')";
90
+ }
91
+ return true;
92
+ }
93
+ function validateContainerPath(value) {
94
+ if (/[\r\n]/.test(value)) {
95
+ return "path must not contain newline or carriage return";
96
+ }
97
+ if (value.includes(":")) {
98
+ return "path must not contain ':'";
99
+ }
100
+ if (!value.startsWith("/")) {
101
+ return "container path must be absolute (start with '/')";
102
+ }
103
+ return true;
104
+ }
105
+ function loadConfig(vibeDir) {
106
+ const configPath = join(vibeDir, CONFIG_FILE_NAME);
107
+ const raw = readFileSync(configPath, "utf-8");
108
+ const parsed = JSON.parse(raw);
109
+ validateConfig(parsed);
110
+ return parsed;
111
+ }
112
+ function saveConfig(vibeDir, config) {
113
+ const configPath = join(vibeDir, CONFIG_FILE_NAME);
114
+ try {
115
+ unlinkSync(configPath);
116
+ } catch {
117
+ }
118
+ writeFileSync(
119
+ configPath,
120
+ JSON.stringify(config, null, 2) + "\n",
121
+ "utf-8"
122
+ );
123
+ }
124
+ function validateConfig(value) {
125
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
126
+ throw new Error("config.json: expected an object at the root.");
127
+ }
128
+ const cfg = value;
129
+ if (cfg.schemaVersion !== 1) {
130
+ throw new Error(
131
+ `config.json: unsupported schemaVersion ${String(cfg.schemaVersion)}; expected 1.`
132
+ );
133
+ }
134
+ if (typeof cfg.workspacePath !== "string" || cfg.workspacePath.length === 0) {
135
+ throw new Error("config.json: workspacePath must be a non-empty string.");
136
+ }
137
+ const wsCheck = validateHostPath(cfg.workspacePath);
138
+ if (wsCheck !== true) {
139
+ throw new Error(`config.json: workspacePath ${wsCheck}.`);
140
+ }
141
+ if (typeof cfg.marker !== "string" || cfg.marker.length === 0) {
142
+ throw new Error("config.json: marker must be a non-empty string.");
143
+ }
144
+ if (!/^bootstrap-[a-f0-9]{16}(-r\d+)?$/.test(cfg.marker)) {
145
+ throw new Error(
146
+ "config.json: marker must match pattern 'bootstrap-<16 hex chars>' or with optional '-r<N>' suffix."
147
+ );
148
+ }
149
+ if (typeof cfg.stack !== "string" || !STACKS.includes(cfg.stack)) {
150
+ throw new Error(
151
+ `config.json: stack must be one of ${STACKS.join("/")}; got ${String(cfg.stack)}.`
152
+ );
153
+ }
154
+ if (!Array.isArray(cfg.plugins) || cfg.plugins.some((p) => typeof p !== "string")) {
155
+ throw new Error("config.json: plugins must be an array of strings.");
156
+ }
157
+ for (const p of cfg.plugins) {
158
+ const r = validatePluginRef(p);
159
+ if (r !== true) {
160
+ throw new Error(`config.json: plugin '${String(p)}' invalid \u2014 ${r}.`);
161
+ }
162
+ }
163
+ if (!Array.isArray(cfg.marketplaces) || cfg.marketplaces.some((m) => typeof m !== "string")) {
164
+ throw new Error("config.json: marketplaces must be an array of strings.");
165
+ }
166
+ for (const m of cfg.marketplaces) {
167
+ const r = validateMarketplaceRef(m);
168
+ if (r !== true) {
169
+ throw new Error(`config.json: marketplace '${String(m)}' invalid \u2014 ${r}.`);
170
+ }
171
+ }
172
+ if (!Array.isArray(cfg.mcps) || !cfg.mcps.every(isMcp)) {
173
+ throw new Error(
174
+ "config.json: mcps must be an array of { name: string, transport: 'http'|'sse', url: string }."
175
+ );
176
+ }
177
+ for (const m of cfg.mcps) {
178
+ const nameCheck = validateMcpName(m.name);
179
+ if (nameCheck !== true) {
180
+ throw new Error(`config.json: mcps[].name '${m.name}' invalid \u2014 ${nameCheck}.`);
181
+ }
182
+ const urlCheck = validateMcpUrl(m.url);
183
+ if (urlCheck !== true) {
184
+ throw new Error(`config.json: mcps[].url '${m.url}' invalid \u2014 ${urlCheck}.`);
185
+ }
186
+ }
187
+ if (!Array.isArray(cfg.additionalMounts) || !cfg.additionalMounts.every(isAdditionalMount)) {
188
+ throw new Error(
189
+ "config.json: additionalMounts must be an array of { hostPath: string, containerPath: string, readonly: boolean }."
190
+ );
191
+ }
192
+ for (const m of cfg.additionalMounts) {
193
+ const hostCheck = validateHostPath(m.hostPath);
194
+ if (hostCheck !== true) {
195
+ throw new Error(
196
+ `config.json: additionalMounts[].hostPath '${m.hostPath}' invalid \u2014 ${hostCheck}.`
197
+ );
198
+ }
199
+ const containerCheck = validateContainerPath(m.containerPath);
200
+ if (containerCheck !== true) {
201
+ throw new Error(
202
+ `config.json: additionalMounts[].containerPath '${m.containerPath}' invalid \u2014 ${containerCheck}.`
203
+ );
204
+ }
205
+ }
206
+ if (!isResources(cfg.resources)) {
207
+ throw new Error(
208
+ "config.json: resources must be { cpus, memoryGB, pids, tmpfsMB } with positive numbers (pids and tmpfsMB must be integers)."
209
+ );
210
+ }
211
+ }
212
+ function isMcp(value) {
213
+ if (typeof value !== "object" || value === null) return false;
214
+ const obj = value;
215
+ return typeof obj.name === "string" && typeof obj.url === "string" && (obj.transport === "http" || obj.transport === "sse");
216
+ }
217
+ function isAdditionalMount(value) {
218
+ if (typeof value !== "object" || value === null) return false;
219
+ const obj = value;
220
+ return typeof obj.hostPath === "string" && typeof obj.containerPath === "string" && typeof obj.readonly === "boolean";
221
+ }
222
+ function isResources(value) {
223
+ if (typeof value !== "object" || value === null) return false;
224
+ const obj = value;
225
+ return typeof obj.cpus === "number" && Number.isFinite(obj.cpus) && obj.cpus > 0 && typeof obj.memoryGB === "number" && Number.isFinite(obj.memoryGB) && obj.memoryGB > 0 && typeof obj.pids === "number" && Number.isInteger(obj.pids) && obj.pids > 0 && typeof obj.tmpfsMB === "number" && Number.isInteger(obj.tmpfsMB) && obj.tmpfsMB > 0;
226
+ }
227
+
228
+ // src/log.ts
229
+ var PREFIX = "[sandbox-vibe]";
230
+ function log(message) {
231
+ console.log(`${PREFIX} ${message}`);
232
+ }
233
+ function logError(message) {
234
+ console.error(`${PREFIX} ${message}`);
235
+ }
236
+
237
+ // src/paths.ts
238
+ import { statSync } from "fs";
239
+ import { join as join2, resolve } from "path";
240
+ var SANDBOX_VIBE_DIR = ".sandbox-vibe";
241
+ function findSandboxVibeDir(cwd = process.cwd()) {
242
+ const candidate = resolve(cwd, SANDBOX_VIBE_DIR);
243
+ try {
244
+ if (statSync(candidate).isDirectory()) return candidate;
245
+ } catch {
246
+ }
247
+ return null;
248
+ }
249
+ function sandboxVibePath(cwd, ...parts) {
250
+ return join2(cwd, SANDBOX_VIBE_DIR, ...parts);
251
+ }
252
+
253
+ // src/render.ts
254
+ import { createHash } from "crypto";
255
+ import { readFile, unlink, writeFile } from "fs/promises";
256
+ import { basename, dirname, join as join3 } from "path";
257
+ import { fileURLToPath } from "url";
258
+ var TEMPLATES_DIR = join3(
259
+ dirname(fileURLToPath(import.meta.url)),
260
+ "templates"
261
+ );
262
+ var TEMPLATE_FILES = {
263
+ baseDockerfile: {
264
+ template: "Dockerfile.sandbox.tpl",
265
+ output: "Dockerfile.sandbox"
266
+ },
267
+ baseCompose: {
268
+ template: "docker-compose.sandbox.yml.tpl",
269
+ output: "docker-compose.sandbox.yml"
270
+ },
271
+ overrideDockerfile: {
272
+ template: "Dockerfile.sandbox.override.tpl",
273
+ output: "Dockerfile.sandbox.override"
274
+ },
275
+ overrideCompose: {
276
+ template: "docker-compose.override.yml.tpl",
277
+ output: "docker-compose.override.yml"
278
+ }
279
+ };
280
+ async function renderTemplate(templateName, vars) {
281
+ const tplPath = join3(TEMPLATES_DIR, templateName);
282
+ const tpl = await readFile(tplPath, "utf-8");
283
+ const lookup = (key) => getRequiredVar(vars, key, templateName);
284
+ const afterCommentMarkers = tpl.replace(
285
+ /^[ \t]*# vibe-render:(\w+)[ \t]*$/gm,
286
+ (_match, key) => lookup(key)
287
+ );
288
+ return afterCommentMarkers.replace(
289
+ /\$\{(\w+)\}/g,
290
+ (_match, key) => lookup(key)
291
+ );
292
+ }
293
+ function getRequiredVar(vars, key, templateName) {
294
+ const value = vars[key];
295
+ if (value === void 0) {
296
+ throw new Error(`Template ${templateName}: missing variable '${key}'.`);
297
+ }
298
+ return value;
299
+ }
300
+ async function writeRendered(destDir, outputName, content) {
301
+ const target = join3(destDir, outputName);
302
+ try {
303
+ await unlink(target);
304
+ } catch {
305
+ }
306
+ await writeFile(target, content, "utf-8");
307
+ }
308
+ function computeMarker(config) {
309
+ const canonical = JSON.stringify({
310
+ plugins: [...config.plugins].sort(),
311
+ marketplaces: [...config.marketplaces].sort(),
312
+ mcps: [...config.mcps].map((m) => ({ name: m.name, transport: m.transport, url: m.url })).sort((a, b) => a.name.localeCompare(b.name))
313
+ });
314
+ const hash = createHash("sha256").update(canonical).digest("hex").slice(0, 16);
315
+ return `bootstrap-${hash}`;
316
+ }
317
+ function computeProjectSlug(config) {
318
+ const raw = basename(config.workspacePath).toLowerCase();
319
+ const cleaned = raw.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
320
+ const safeBase = cleaned.length > 0 ? cleaned : "vibe";
321
+ const pathHash = createHash("sha256").update(config.workspacePath).digest("hex").slice(0, 8);
322
+ return `${safeBase}-${pathHash}`;
323
+ }
324
+ function renderVolumesBlock(config, projectSlug) {
325
+ const lines = [
326
+ ` - ${projectSlug}-sandbox-home:/home/sandbox`,
327
+ ` - ${config.workspacePath}:/workspace`
328
+ ];
329
+ for (const m of config.additionalMounts) {
330
+ const ro = m.readonly ? ":ro" : "";
331
+ lines.push(` - ${m.hostPath}:${m.containerPath}${ro}`);
332
+ }
333
+ return lines.join("\n");
334
+ }
335
+ function renderAdditionalDirsBlock(config) {
336
+ const dirs = ["/workspace"];
337
+ for (const m of config.additionalMounts) {
338
+ dirs.push(m.containerPath);
339
+ }
340
+ return JSON.stringify(dirs, null, 2).replace(/\n/g, "\n ");
341
+ }
342
+ function renderEnabledPluginsBlock(config) {
343
+ const obj = {};
344
+ for (const p of config.plugins) obj[p] = true;
345
+ return JSON.stringify(obj, null, 2).replace(/\n/g, "\n ");
346
+ }
347
+ function renderMarketplacesBlock(config) {
348
+ if (config.marketplaces.length === 0) {
349
+ return "";
350
+ }
351
+ return config.marketplaces.map(
352
+ (m) => ` claude plugin marketplace add ${m} >>"$$BOOT_LOG" 2>&1`
353
+ ).join("\n");
354
+ }
355
+ function renderPluginLoopBlock(config) {
356
+ if (config.plugins.length === 0) {
357
+ return ` ""`;
358
+ }
359
+ return config.plugins.map((p, idx) => {
360
+ const continuation = idx === config.plugins.length - 1 ? "" : " \\";
361
+ return ` "${p}"${continuation}`;
362
+ }).join("\n");
363
+ }
364
+ function renderMcpsBlock(config) {
365
+ if (config.mcps.length === 0) {
366
+ return "";
367
+ }
368
+ const lines = config.mcps.map(
369
+ (m) => ` claude mcp add ${m.name} --scope user --transport ${m.transport} ${m.url} >>"$$BOOT_LOG" 2>&1`
370
+ );
371
+ return "\n" + lines.join("\n") + "\n";
372
+ }
373
+ function renderStackBlock(stack) {
374
+ switch (stack) {
375
+ case "none":
376
+ return "# (no extra stack selected)";
377
+ case "php":
378
+ return [
379
+ "# PHP intelephense (php-lsp plugin)",
380
+ "RUN npm install -g intelephense"
381
+ ].join("\n");
382
+ case "dotnet":
383
+ return [
384
+ "# C# / .NET csharp-ls (csharp-lsp plugin)",
385
+ "RUN apt-get update \\",
386
+ " && apt-get install -y --no-install-recommends wget ca-certificates libicu72 \\",
387
+ " && rm -rf /var/lib/apt/lists/* \\",
388
+ " && wget -qO /tmp/dotnet-install.sh https://dot.net/v1/dotnet-install.sh \\",
389
+ " && chmod +x /tmp/dotnet-install.sh \\",
390
+ " && /tmp/dotnet-install.sh --channel 10.0 --install-dir /usr/share/dotnet \\",
391
+ " && ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet \\",
392
+ " && rm /tmp/dotnet-install.sh \\",
393
+ " && DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1 \\",
394
+ " dotnet tool install --tool-path /usr/local/bin csharp-ls"
395
+ ].join("\n");
396
+ case "python":
397
+ return [
398
+ "# Python pyright (pyright-lsp plugin)",
399
+ "RUN apt-get update \\",
400
+ " && apt-get install -y --no-install-recommends python3-pip \\",
401
+ " && rm -rf /var/lib/apt/lists/* \\",
402
+ " && npm install -g pyright"
403
+ ].join("\n");
404
+ case "go":
405
+ return [
406
+ "# Go gopls (gopls-lsp plugin)",
407
+ "RUN apt-get update \\",
408
+ " && apt-get install -y --no-install-recommends golang-go \\",
409
+ " && rm -rf /var/lib/apt/lists/* \\",
410
+ " && GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest \\",
411
+ " && chmod a+rx /usr/local/bin/gopls"
412
+ ].join("\n");
413
+ case "rust":
414
+ return [
415
+ "# Rust rust-analyzer (rust-analyzer-lsp plugin)",
416
+ "RUN apt-get update \\",
417
+ " && apt-get install -y --no-install-recommends rustup \\",
418
+ " && rm -rf /var/lib/apt/lists/* \\",
419
+ " && rustup-init -y --default-toolchain stable --no-modify-path \\",
420
+ " && /root/.cargo/bin/rustup component add rust-analyzer \\",
421
+ " && cp /root/.cargo/bin/rust-analyzer /usr/local/bin/rust-analyzer \\",
422
+ " && chmod a+rx /usr/local/bin/rust-analyzer"
423
+ ].join("\n");
424
+ }
425
+ }
426
+ async function renderAll(destDir, config) {
427
+ const baseDockerfile = await renderTemplate(
428
+ TEMPLATE_FILES.baseDockerfile.template,
429
+ {}
430
+ );
431
+ await writeRendered(
432
+ destDir,
433
+ TEMPLATE_FILES.baseDockerfile.output,
434
+ baseDockerfile
435
+ );
436
+ const baseCompose = await renderTemplate(
437
+ TEMPLATE_FILES.baseCompose.template,
438
+ {
439
+ cpus: String(config.resources.cpus),
440
+ memoryGB: String(config.resources.memoryGB),
441
+ pids: String(config.resources.pids),
442
+ tmpfsMB: String(config.resources.tmpfsMB)
443
+ }
444
+ );
445
+ await writeRendered(
446
+ destDir,
447
+ TEMPLATE_FILES.baseCompose.output,
448
+ baseCompose
449
+ );
450
+ const overrideDockerfile = await renderTemplate(
451
+ TEMPLATE_FILES.overrideDockerfile.template,
452
+ {
453
+ stackBlock: renderStackBlock(config.stack)
454
+ }
455
+ );
456
+ await writeRendered(
457
+ destDir,
458
+ TEMPLATE_FILES.overrideDockerfile.output,
459
+ overrideDockerfile
460
+ );
461
+ const projectSlug = computeProjectSlug(config);
462
+ const overrideCompose = await renderTemplate(
463
+ TEMPLATE_FILES.overrideCompose.template,
464
+ {
465
+ volumesBlock: renderVolumesBlock(config, projectSlug),
466
+ additionalDirsBlock: renderAdditionalDirsBlock(config),
467
+ enabledPluginsBlock: renderEnabledPluginsBlock(config),
468
+ marketplacesBlock: renderMarketplacesBlock(config),
469
+ pluginLoopBlock: renderPluginLoopBlock(config),
470
+ mcpsBlock: renderMcpsBlock(config),
471
+ marker: config.marker,
472
+ projectSlug
473
+ }
474
+ );
475
+ await writeRendered(
476
+ destDir,
477
+ TEMPLATE_FILES.overrideCompose.output,
478
+ overrideCompose
479
+ );
480
+ }
481
+
482
+ // src/commands/bumpMarker.ts
483
+ async function bumpMarker() {
484
+ const cwd = process.cwd();
485
+ const vibeDir = findSandboxVibeDir(cwd);
486
+ if (!vibeDir) {
487
+ throw new Error(
488
+ `No ${SANDBOX_VIBE_DIR}/ found. Run sandbox-vibe init first.`
489
+ );
490
+ }
491
+ const config = loadConfig(vibeDir);
492
+ const previous = config.marker;
493
+ config.marker = nextMarker(previous);
494
+ await renderAll(vibeDir, config);
495
+ saveConfig(vibeDir, config);
496
+ log(`Marker: ${previous} -> ${config.marker}`);
497
+ log("Re-bootstrap on next up.");
498
+ }
499
+ function nextMarker(current) {
500
+ const match = /^(.*)-r(\d+)$/.exec(current);
501
+ if (!match) return `${current}-r1`;
502
+ const [, base = "", numStr = "0"] = match;
503
+ return `${base}-r${Number.parseInt(numStr, 10) + 1}`;
504
+ }
505
+
506
+ // src/commands/init.ts
507
+ import {
508
+ existsSync,
509
+ lstatSync,
510
+ mkdirSync,
511
+ readFileSync as readFileSync2,
512
+ realpathSync,
513
+ unlinkSync as unlinkSync2,
514
+ writeFileSync as writeFileSync2
515
+ } from "fs";
516
+ import { basename as basename2, join as join4, resolve as resolve2 } from "path";
517
+
518
+ // src/prompts.ts
519
+ import {
520
+ input,
521
+ select,
522
+ confirm,
523
+ checkbox,
524
+ Separator
525
+ } from "@inquirer/prompts";
526
+ function isAbortError(err) {
527
+ if (!(err instanceof Error)) return false;
528
+ return err.name === "ExitPromptError" || err.name === "AbortPromptError";
529
+ }
530
+
531
+ // src/sensitive-paths.ts
532
+ import { homedir } from "os";
533
+ var SENSITIVE_SYSTEM_PATHS = [
534
+ "/",
535
+ "/etc",
536
+ "/private/etc",
537
+ "/root",
538
+ "/var",
539
+ "/private/var",
540
+ "/sys",
541
+ "/proc",
542
+ "/usr",
543
+ "/boot",
544
+ "/dev"
545
+ ];
546
+ var SENSITIVE_HOME_SUBDIRS = [
547
+ ".ssh",
548
+ ".aws",
549
+ ".gnupg",
550
+ ".docker",
551
+ ".kube",
552
+ ".config/gh",
553
+ ".npmrc"
554
+ ];
555
+ function isSensitivePath(absolutePath) {
556
+ if (SENSITIVE_SYSTEM_PATHS.includes(absolutePath)) return true;
557
+ for (const sys of SENSITIVE_SYSTEM_PATHS) {
558
+ if (sys !== "/" && absolutePath.startsWith(`${sys}/`)) return true;
559
+ }
560
+ const home = homedir();
561
+ for (const suffix of SENSITIVE_HOME_SUBDIRS) {
562
+ const sensitive = `${home}/${suffix}`;
563
+ if (absolutePath === sensitive || absolutePath.startsWith(`${sensitive}/`)) {
564
+ return true;
565
+ }
566
+ }
567
+ return false;
568
+ }
569
+
570
+ // src/commands/init.ts
571
+ var STACK_CHOICES = [
572
+ { name: "none", value: "none" },
573
+ { name: "php (intelephense)", value: "php" },
574
+ { name: "dotnet (csharp-ls)", value: "dotnet" },
575
+ { name: "python (pyright)", value: "python" },
576
+ { name: "go (gopls)", value: "go" },
577
+ { name: "rust (rust-analyzer)", value: "rust" }
578
+ ];
579
+ async function confirmSensitiveMount(absolutePath) {
580
+ log(
581
+ `WARNING: '${absolutePath}' looks like a system or credentials path. Mounting it exposes its contents to the Claude agent inside the container.`
582
+ );
583
+ return confirm({
584
+ message: "Mount this path anyway?",
585
+ default: false
586
+ });
587
+ }
588
+ async function init(opts = {}) {
589
+ const cwd = process.cwd();
590
+ const existingDir = findSandboxVibeDir(cwd);
591
+ if (existingDir && !opts.force) {
592
+ if (opts.nonInteractive) {
593
+ throw new Error(
594
+ `${SANDBOX_VIBE_DIR}/ already exists. Use --force to overwrite.`
595
+ );
596
+ }
597
+ const overwrite = await confirm({
598
+ message: `${SANDBOX_VIBE_DIR}/ already exists. Overwrite?`,
599
+ default: false
600
+ });
601
+ if (!overwrite) {
602
+ log("Aborted by user.");
603
+ return;
604
+ }
605
+ }
606
+ const config = opts.nonInteractive ? buildDefaultConfig(cwd) : await runWizard(cwd);
607
+ config.marker = computeMarker(config);
608
+ const destDir = sandboxVibePath(cwd);
609
+ try {
610
+ if (lstatSync(destDir).isSymbolicLink()) {
611
+ throw new Error(
612
+ `${destDir} is a symbolic link; refusing to follow. Replace it with a regular directory and re-run.`
613
+ );
614
+ }
615
+ } catch (err) {
616
+ if (err instanceof Error && err.message.startsWith(`${destDir} is a symbolic link`)) {
617
+ throw err;
618
+ }
619
+ }
620
+ mkdirSync(destDir, { recursive: true });
621
+ await renderAll(destDir, config);
622
+ saveConfig(destDir, config);
623
+ if (!opts.nonInteractive) {
624
+ await maybeUpdateGitignore(cwd);
625
+ }
626
+ log(`Wrote ${SANDBOX_VIBE_DIR}/ with marker ${config.marker}.`);
627
+ log("Run 'sandbox-vibe up' to start the sandbox.");
628
+ }
629
+ function buildDefaultConfig(cwd) {
630
+ return {
631
+ schemaVersion: 1,
632
+ workspacePath: cwd,
633
+ additionalMounts: [],
634
+ resources: { ...DEFAULT_RESOURCES },
635
+ stack: "none",
636
+ plugins: [...DEFAULT_PLUGINS],
637
+ marketplaces: [...DEFAULT_MARKETPLACES],
638
+ mcps: [],
639
+ marker: ""
640
+ };
641
+ }
642
+ async function runWizard(cwd) {
643
+ const workspacePath = await promptHostPath(
644
+ "Workspace path (mounted as /workspace)",
645
+ cwd
646
+ );
647
+ const additionalMounts = await promptAdditionalMounts();
648
+ const stack = await select({
649
+ message: "Stack for LSP support",
650
+ choices: STACK_CHOICES,
651
+ default: "none"
652
+ });
653
+ const plugins = await checkbox({
654
+ message: "Plugins to enable",
655
+ choices: DEFAULT_PLUGINS.map((p) => ({
656
+ name: p,
657
+ value: p,
658
+ checked: true
659
+ }))
660
+ });
661
+ const mcps = await promptMcps();
662
+ const useDefaults = await confirm({
663
+ message: `Use default resources (${DEFAULT_RESOURCES.cpus} CPU, ${DEFAULT_RESOURCES.memoryGB}G mem, ${DEFAULT_RESOURCES.pids} PIDs, ${DEFAULT_RESOURCES.tmpfsMB}M tmpfs)?`,
664
+ default: true
665
+ });
666
+ const resources = useDefaults ? { ...DEFAULT_RESOURCES } : await promptResources();
667
+ return {
668
+ schemaVersion: 1,
669
+ workspacePath,
670
+ additionalMounts,
671
+ resources,
672
+ stack,
673
+ plugins,
674
+ marketplaces: [...DEFAULT_MARKETPLACES],
675
+ mcps,
676
+ marker: ""
677
+ };
678
+ }
679
+ async function promptHostPath(message, defaultValue) {
680
+ while (true) {
681
+ const raw = await input({
682
+ message,
683
+ default: defaultValue,
684
+ validate: (value) => {
685
+ const absolute2 = resolve2(value);
686
+ const formatCheck = validateHostPath(absolute2);
687
+ if (formatCheck !== true) return formatCheck;
688
+ if (!existsSync(absolute2)) return `Path does not exist: ${absolute2}`;
689
+ return true;
690
+ }
691
+ });
692
+ const absolute = resolve2(raw);
693
+ let real;
694
+ try {
695
+ real = realpathSync(absolute);
696
+ } catch {
697
+ log(`Cannot resolve real path for ${absolute}; please pick another.`);
698
+ continue;
699
+ }
700
+ if (isSensitivePath(real)) {
701
+ const ok = await confirmSensitiveMount(real);
702
+ if (!ok) {
703
+ log("Aborted mount; please pick another path.");
704
+ continue;
705
+ }
706
+ }
707
+ return real;
708
+ }
709
+ }
710
+ async function promptAdditionalMounts() {
711
+ const mounts = [];
712
+ let addMore = await confirm({
713
+ message: "Add sibling mounts?",
714
+ default: false
715
+ });
716
+ while (addMore) {
717
+ const hostPath = await promptHostPath("Host path (absolute)");
718
+ const defaultContainerPath = `/workspace/${basename2(hostPath)}`;
719
+ const containerPath = await input({
720
+ message: "Container path",
721
+ default: defaultContainerPath,
722
+ validate: (value) => validateContainerPath(value)
723
+ });
724
+ const readonly = await confirm({
725
+ message: "Read-only?",
726
+ default: false
727
+ });
728
+ mounts.push({ hostPath, containerPath, readonly });
729
+ addMore = await confirm({
730
+ message: "Add another mount?",
731
+ default: false
732
+ });
733
+ }
734
+ return mounts;
735
+ }
736
+ async function promptMcps() {
737
+ const mcps = [];
738
+ const addMcp = await confirm({
739
+ message: "Add MCP servers?",
740
+ default: false
741
+ });
742
+ if (!addMcp) return mcps;
743
+ let more = true;
744
+ while (more) {
745
+ const name = await input({
746
+ message: "MCP name",
747
+ default: "context7",
748
+ validate: (value) => validateMcpName(value)
749
+ });
750
+ const url = await input({
751
+ message: "MCP URL",
752
+ default: name === "context7" ? "https://mcp.context7.com/mcp" : "",
753
+ validate: (value) => validateMcpUrl(value)
754
+ });
755
+ mcps.push({ name, transport: "http", url });
756
+ more = await confirm({
757
+ message: "Add another MCP?",
758
+ default: false
759
+ });
760
+ }
761
+ return mcps;
762
+ }
763
+ async function promptResources() {
764
+ const cpus = await promptPositiveNumber(
765
+ "CPU limit (count)",
766
+ DEFAULT_RESOURCES.cpus,
767
+ false
768
+ );
769
+ const memoryGB = await promptPositiveNumber(
770
+ "Memory limit (GB)",
771
+ DEFAULT_RESOURCES.memoryGB,
772
+ false
773
+ );
774
+ const pids = await promptPositiveNumber(
775
+ "PID limit",
776
+ DEFAULT_RESOURCES.pids,
777
+ true
778
+ );
779
+ const tmpfsMB = await promptPositiveNumber(
780
+ "tmpfs /tmp size (MB)",
781
+ DEFAULT_RESOURCES.tmpfsMB,
782
+ true
783
+ );
784
+ return { cpus, memoryGB, pids, tmpfsMB };
785
+ }
786
+ async function promptPositiveNumber(message, defaultValue, integer) {
787
+ const raw = await input({
788
+ message,
789
+ default: String(defaultValue),
790
+ validate: (v) => {
791
+ const n = Number(v);
792
+ if (!Number.isFinite(n) || n <= 0) return "must be > 0";
793
+ if (integer && !Number.isInteger(n)) return "must be an integer";
794
+ return true;
795
+ }
796
+ });
797
+ return Number(raw);
798
+ }
799
+ async function maybeUpdateGitignore(cwd) {
800
+ const gitignorePath = join4(cwd, ".gitignore");
801
+ const entry = `/${SANDBOX_VIBE_DIR}/`;
802
+ let isSymlink = false;
803
+ let exists = false;
804
+ try {
805
+ const st = lstatSync(gitignorePath);
806
+ exists = true;
807
+ isSymlink = st.isSymbolicLink();
808
+ } catch {
809
+ }
810
+ if (isSymlink) {
811
+ throw new Error(
812
+ `.gitignore at ${gitignorePath} is a symbolic link; refusing to follow. Replace it with a regular file and re-run.`
813
+ );
814
+ }
815
+ if (!exists) {
816
+ const create = await confirm({
817
+ message: `No .gitignore found. Create one with '${entry}'?`,
818
+ default: true
819
+ });
820
+ if (create) {
821
+ writeGitignoreSafely(gitignorePath, entry + "\n");
822
+ log("Created .gitignore.");
823
+ }
824
+ return;
825
+ }
826
+ const content = readFileSync2(gitignorePath, "utf-8");
827
+ if (content.split(/\r?\n/).some((line) => line.trim() === entry)) {
828
+ return;
829
+ }
830
+ const add = await confirm({
831
+ message: `Add '${entry}' to .gitignore?`,
832
+ default: true
833
+ });
834
+ if (add) {
835
+ const trailing = content.endsWith("\n") ? "" : "\n";
836
+ writeGitignoreSafely(gitignorePath, content + trailing + entry + "\n");
837
+ log("Updated .gitignore.");
838
+ }
839
+ }
840
+ function writeGitignoreSafely(gitignorePath, content) {
841
+ try {
842
+ unlinkSync2(gitignorePath);
843
+ } catch {
844
+ }
845
+ writeFileSync2(gitignorePath, content, "utf-8");
846
+ }
847
+
848
+ // src/docker.ts
849
+ import { ExecaError, execa } from "execa";
850
+ import { join as join5 } from "path";
851
+ async function assertDockerAvailable() {
852
+ try {
853
+ await execa("docker", ["info"], {
854
+ timeout: 3e3,
855
+ stdio: "ignore"
856
+ });
857
+ } catch (err) {
858
+ if (err instanceof ExecaError && err.timedOut) {
859
+ throw new Error(
860
+ "Docker daemon did not respond within 3 seconds. Is Docker Desktop / colima running?"
861
+ );
862
+ }
863
+ throw new Error(
864
+ "Docker daemon not reachable. Start Docker Desktop / colima and retry."
865
+ );
866
+ }
867
+ let composeVersion;
868
+ try {
869
+ const result = await execa("docker", ["compose", "version", "--short"], {
870
+ timeout: 3e3
871
+ });
872
+ composeVersion = result.stdout.trim();
873
+ } catch (err) {
874
+ if (err instanceof ExecaError && err.timedOut) {
875
+ throw new Error(
876
+ "`docker compose version` did not respond within 3 seconds."
877
+ );
878
+ }
879
+ throw new Error(
880
+ "Requires Docker Compose v2 (docker compose). Could not detect version."
881
+ );
882
+ }
883
+ const normalized = composeVersion.startsWith("v") ? composeVersion.slice(1) : composeVersion;
884
+ const majorPart = normalized.split(".")[0] ?? "";
885
+ const major = Number.parseInt(majorPart, 10);
886
+ if (!Number.isFinite(major) || major < 2) {
887
+ throw new Error(
888
+ `Requires Docker Compose v2 or newer. Found '${composeVersion}'; please upgrade.`
889
+ );
890
+ }
891
+ }
892
+ function composeFlags(vibeDir) {
893
+ return [
894
+ "compose",
895
+ "-f",
896
+ join5(vibeDir, "docker-compose.sandbox.yml"),
897
+ "-f",
898
+ join5(vibeDir, "docker-compose.override.yml")
899
+ ];
900
+ }
901
+ async function composeBuild(vibeDir) {
902
+ await execa(
903
+ "docker",
904
+ [
905
+ "compose",
906
+ "-f",
907
+ join5(vibeDir, "docker-compose.sandbox.yml"),
908
+ "build"
909
+ ],
910
+ { stdio: "inherit" }
911
+ );
912
+ await execa("docker", [...composeFlags(vibeDir), "build"], {
913
+ stdio: "inherit"
914
+ });
915
+ }
916
+ async function composeRun(vibeDir) {
917
+ await execa(
918
+ "docker",
919
+ [...composeFlags(vibeDir), "run", "--rm", "sandbox"],
920
+ {
921
+ stdio: "inherit"
922
+ }
923
+ );
924
+ }
925
+
926
+ // src/commands/up.ts
927
+ async function up() {
928
+ const cwd = process.cwd();
929
+ const vibeDir = findSandboxVibeDir(cwd);
930
+ if (!vibeDir) {
931
+ throw new Error(
932
+ `No ${SANDBOX_VIBE_DIR}/ found in current directory. Run 'sandbox-vibe init' first or cd into the project root.`
933
+ );
934
+ }
935
+ await assertDockerAvailable();
936
+ log("Building images...");
937
+ await composeBuild(vibeDir);
938
+ log("Starting Claude REPL...");
939
+ await composeRun(vibeDir);
940
+ }
941
+
942
+ // src/cli.ts
943
+ var PKG_PATH = join6(
944
+ dirname2(fileURLToPath2(import.meta.url)),
945
+ "..",
946
+ "package.json"
947
+ );
948
+ function getVersion() {
949
+ try {
950
+ const pkg = JSON.parse(readFileSync3(PKG_PATH, "utf-8"));
951
+ return pkg.version;
952
+ } catch {
953
+ return "0.0.0";
954
+ }
955
+ }
956
+ var program = new Command();
957
+ program.name("sandbox-vibe").description(
958
+ "Plug-and-play Docker sandbox for Claude Code with idempotent plugin and MCP bootstrap and security limits enforced by default."
959
+ ).version(getVersion(), "-v, --version", "output the current version");
960
+ program.command("init").description(
961
+ "Generate the .sandbox-vibe/ directory in the current project."
962
+ ).option(
963
+ "-f, --force",
964
+ "overwrite an existing .sandbox-vibe/ without confirmation"
965
+ ).option(
966
+ "--non-interactive",
967
+ "skip the wizard and use defaults (suitable for CI)"
968
+ ).action(
969
+ async (opts) => {
970
+ await init({
971
+ force: opts.force,
972
+ nonInteractive: opts.nonInteractive
973
+ });
974
+ }
975
+ );
976
+ program.command("up").description("Build the sandbox images and drop into the Claude REPL.").action(async () => {
977
+ await up();
978
+ });
979
+ program.command("bump-marker").description(
980
+ "Increment the bootstrap marker to force re-bootstrap on next up."
981
+ ).action(async () => {
982
+ await bumpMarker();
983
+ });
984
+ try {
985
+ await program.parseAsync(process.argv);
986
+ } catch (err) {
987
+ if (isAbortError(err)) {
988
+ log("Aborted.");
989
+ process.exit(0);
990
+ }
991
+ logError(describeError(err));
992
+ process.exit(1);
993
+ }
994
+ function describeError(err) {
995
+ let raw;
996
+ if (err instanceof Error) {
997
+ const maybeExeca = err;
998
+ raw = typeof maybeExeca.shortMessage === "string" ? maybeExeca.shortMessage : err.message;
999
+ } else {
1000
+ raw = `Unknown error: ${String(err)}`;
1001
+ }
1002
+ const home = homedir2();
1003
+ return home.length > 0 ? raw.replaceAll(home, "~") : raw;
1004
+ }
1005
+ //# sourceMappingURL=cli.mjs.map