@ycs77/devbox 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +18 -15
  2. package/dist/cli.mjs +276 -157
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @ycs77/devbox
4
4
 
5
- **A opinionated devbox for AI agents.**
5
+ **An opinionated devbox for AI agents.**
6
6
 
7
7
  [![NPM version][ico-version]][link-npm]
8
8
  [![Software License][ico-license]](LICENSE)
@@ -17,20 +17,20 @@ English | [繁體中文](README-zh-TW.md)
17
17
 
18
18
  ## Overview
19
19
 
20
- Devbox gives each project an isolated Docker sandbox designed for AI agents, with Lucas's opinionated development setup. Choose the Node.js release lines and AI agents you use, then work from a consistent container without configuring it yourself.
20
+ Devbox gives each project an isolated Docker sandbox for AI agents, preconfigured with Lucas's development preferences.
21
21
 
22
22
  ## Features
23
23
 
24
24
  - **Container environment for AI agents:** choose Claude Code, Codex, and more during setup, then use them inside the sandbox.
25
- - **Simple setup and project lifecycle:** initialize, start, enter, stop, and remove a sandbox with a few commands.
26
- - **Flexible Node.js environments:** configure Node.js 22 and 24, then select a version for each project.
25
+ - **Simple project sandbox management:** initialize, start, enter, stop, and remove a sandbox with a few commands.
26
+ - **Flexible Node.js versions:** choose the versions to include, then select one for each project.
27
27
  - **Selective builds:** build only the Node.js versions and AI agents you choose.
28
28
  - **No project configuration files:** keep Devbox configuration local to your machine, not in your repository.
29
29
 
30
30
  ## Requirements
31
31
 
32
- - **Node.js 22 or later:** installs and runs the CLI with npm.
33
- - **Docker with Docker Compose:** runs the development sandbox.
32
+ - **Node.js 22 or later:** required to install and run the CLI with npm.
33
+ - **Docker with Docker Compose:** required to run the development sandbox.
34
34
  - **Windows:** use Devbox from a **WSL2** Linux distribution with Docker Desktop integration enabled.
35
35
 
36
36
  ## Getting started
@@ -47,9 +47,11 @@ From your project directory, initialize Devbox:
47
47
  devbox init
48
48
  ```
49
49
 
50
- The interactive setup lets you choose the Node.js release lines and AI agents to configure, then select the Node.js version for the current project.
50
+ The interactive setup lets you choose the Node.js versions and AI agents to include, then select a Node.js version for the current project.
51
51
 
52
- Build the shared workspace image once after setup or after changing global configuration:
52
+ Global configuration controls what goes into the shared Workspace Image. Each Node.js version or AI agent you add makes the image larger and uses more disk space. Projects can use only the Node.js versions included in the image.
53
+
54
+ Build the shared Workspace Image once after setup or whenever you change global configuration:
53
55
 
54
56
  ```bash
55
57
  devbox build
@@ -67,9 +69,9 @@ Open a shell inside the running sandbox and start working with your AI agent:
67
69
  devbox sh
68
70
  ```
69
71
 
70
- For example, run `claude`, `codex`, `agy`, or `omp` after opening the shell when that agent was selected during setup.
72
+ If you selected an agent during setup, run `claude`, `codex`, `agy`, or `omp` inside the shell.
71
73
 
72
- ## Everyday commands
74
+ ## Commands
73
75
 
74
76
  Run these commands from the initialized project directory:
75
77
 
@@ -79,23 +81,24 @@ Run these commands from the initialized project directory:
79
81
  | `devbox sh` | Open a Bash shell in the running sandbox. |
80
82
  | `devbox stop` | Stop the sandbox and preserve it for a later restart. |
81
83
  | `devbox down` | Stop and remove the sandbox container. |
82
- | `devbox config` | Change the globally configured or current project's Node.js selection. |
84
+ | `devbox config` | Change global settings or this project's configuration. |
85
+ | `devbox cleanup --missing-projects` | Remove registrations for projects whose root directory no longer exists. |
83
86
 
84
87
  ## Troubleshooting
85
88
 
86
- ### A command says a Devbox marker is occupied
89
+ ### Devbox reports that a command marker is in use
87
90
 
88
- An interrupted process or sudden system shutdown can leave a command marker behind. First confirm that no Devbox process is still running, then remove only the stale marker and retry:
91
+ An interrupted command or sudden system shutdown can leave a command marker behind. First confirm that no other Devbox command is still running, then remove the stale marker and retry:
89
92
 
90
93
  ```bash
91
94
  rm -rf ~/.devbox/locks/<marker>
92
95
  ```
93
96
 
94
- Markers are directories named `global` or `project-<hash>`. Do not remove a marker while its Devbox command is still running.
97
+ A marker is a directory named `global` or `project-<hash>`. Do not remove a marker while its Devbox command is still running.
95
98
 
96
99
  ## Sponsor
97
100
 
98
- If you think this package has helped you, please consider [Becoming a sponsor](https://www.patreon.com/ycs77) to support my work~ and your avatar will be visible on my major projects.
101
+ If Devbox has helped you, consider sponsoring my work on Patreon. Your avatar will appear on my major projects.
99
102
 
100
103
  <p align="center">
101
104
  <a href="https://www.patreon.com/ycs77">
package/dist/cli.mjs CHANGED
@@ -10,6 +10,9 @@ import { execFile, spawn } from "node:child_process";
10
10
  import { promisify } from "node:util";
11
11
  import { parse as parse$1, parseDocument, stringify } from "yaml";
12
12
  import { once } from "node:events";
13
+ //#region package.json
14
+ var version = "0.6.0";
15
+ //#endregion
13
16
  //#region src/catalog/index.ts
14
17
  const NODE_RELEASE_KEYS = [
15
18
  "5BE8A3F6C8A5C01D106C0AD820B1A390B168D356",
@@ -27,12 +30,14 @@ const PACKAGED_NODE_RECIPES = {
27
30
  releaseLine: "24",
28
31
  version: "24.19.0",
29
32
  runtimeRoot: "/opt/devbox/runtimes/node/24",
33
+ distributionBaseUrl: "https://nodejs.org/dist",
30
34
  trustedReleaseKeys: NODE_RELEASE_KEYS
31
35
  },
32
36
  "22": {
33
37
  releaseLine: "22",
34
38
  version: "22.23.2",
35
39
  runtimeRoot: "/opt/devbox/runtimes/node/22",
40
+ distributionBaseUrl: "https://nodejs.org/dist",
36
41
  trustedReleaseKeys: NODE_RELEASE_KEYS
37
42
  }
38
43
  };
@@ -467,10 +472,7 @@ function isMissingFileError$1(error) {
467
472
  return typeof error === "object" && error !== null && error.code === "ENOENT";
468
473
  }
469
474
  //#endregion
470
- //#region src/workspace/image.ts
471
- const WORKSPACE_IMAGE = "devbox-workspace:latest";
472
- //#endregion
473
- //#region src/project/compose-fragments.ts
475
+ //#region src/agent/compose.ts
474
476
  const NOTIFICATION_FRAGMENT = {
475
477
  environment: { PULSE_SERVER: "unix:/tmp/pulse-socket" },
476
478
  volume: {
@@ -479,7 +481,7 @@ const NOTIFICATION_FRAGMENT = {
479
481
  readOnly: true
480
482
  }
481
483
  };
482
- function selectComposeFragments({ selectedNode, configuredAgents, agentNotifications }) {
484
+ function selectAgentComposeFragments(configuredAgents, notificationsEnabled) {
483
485
  const agents = [];
484
486
  let hasNotificationAgent = false;
485
487
  for (const agent of configuredAgents) {
@@ -497,19 +499,26 @@ function selectComposeFragments({ selectedNode, configuredAgents, agentNotificat
497
499
  }
498
500
  }
499
501
  return {
500
- node: selectedNode === null ? void 0 : { environment: { NODE_VERSION: selectedNode } },
501
502
  agents,
502
- notification: agentNotifications && hasNotificationAgent ? NOTIFICATION_FRAGMENT : void 0
503
+ notification: notificationsEnabled && hasNotificationAgent ? NOTIFICATION_FRAGMENT : void 0
503
504
  };
504
505
  }
505
506
  //#endregion
507
+ //#region src/runtimes/node/compose.ts
508
+ function selectNodeComposeFragment(selectedNode) {
509
+ return selectedNode === null ? void 0 : { environment: { NODE_VERSION: selectedNode } };
510
+ }
511
+ //#endregion
512
+ //#region src/workspace/image.ts
513
+ const WORKSPACE_IMAGE = "devbox-workspace:latest";
514
+ //#endregion
506
515
  //#region src/project/compose.ts
507
516
  function renderProjectCompose(input) {
508
- const fragments = selectComposeFragments(input);
517
+ const node = selectNodeComposeFragment(input.selectedNode);
518
+ const agent = selectAgentComposeFragments(input.configuredAgents, input.agentNotifications);
509
519
  const environment = {
510
- TERM: "xterm-256color",
511
- ...fragments.node?.environment,
512
- ...fragments.notification?.environment
520
+ ...node?.environment,
521
+ ...agent.notification?.environment
513
522
  };
514
523
  const volumes = [
515
524
  {
@@ -517,7 +526,7 @@ function renderProjectCompose(input) {
517
526
  source: input.projectRoot,
518
527
  target: `/workspace/${input.sandboxName}`
519
528
  },
520
- ...fragments.agents.map((fragment) => ({
529
+ ...agent.agents.map((fragment) => ({
521
530
  type: "volume",
522
531
  source: fragment.volume.name,
523
532
  target: fragment.volume.target
@@ -527,11 +536,11 @@ function renderProjectCompose(input) {
527
536
  source: input.claudeHostConfiguration,
528
537
  target: "/home/devbox/.claude.json"
529
538
  }],
530
- ...fragments.notification === void 0 ? [] : [{
539
+ ...agent.notification === void 0 ? [] : [{
531
540
  type: "bind",
532
- source: fragments.notification.volume.source,
533
- target: fragments.notification.volume.target,
534
- read_only: fragments.notification.volume.readOnly
541
+ source: agent.notification.volume.source,
542
+ target: agent.notification.volume.target,
543
+ read_only: agent.notification.volume.readOnly
535
544
  }]
536
545
  ];
537
546
  const definition = {
@@ -551,7 +560,7 @@ function renderProjectCompose(input) {
551
560
  ...Object.keys(environment).length === 0 ? {} : { environment },
552
561
  volumes
553
562
  } },
554
- ...fragments.agents.length === 0 ? {} : { volumes: Object.fromEntries(fragments.agents.map((fragment) => [fragment.volume.name, {
563
+ ...agent.agents.length === 0 ? {} : { volumes: Object.fromEntries(agent.agents.map((fragment) => [fragment.volume.name, {
555
564
  name: fragment.volume.name,
556
565
  external: fragment.volume.external
557
566
  }])) }
@@ -1191,9 +1200,12 @@ async function runSandboxLifecycle(command, input = {}) {
1191
1200
  global: false,
1192
1201
  projectRoots: [projectRoot],
1193
1202
  signal: input.signal
1194
- }, () => runSandboxLifecycleUnlocked(command, input, projectRoot));
1203
+ }, () => runSandboxCommandUnlocked(command, input, projectRoot));
1195
1204
  }
1196
- async function runSandboxLifecycleUnlocked(command, input, projectRoot) {
1205
+ async function runSandboxShell(input = {}) {
1206
+ return runSandboxCommandUnlocked("sh", input, input.root ?? process.cwd());
1207
+ }
1208
+ async function runSandboxCommandUnlocked(command, input, projectRoot) {
1197
1209
  if (input.signal?.aborted) throw new InterruptedError();
1198
1210
  const rootCheck = await validateProjectRoot(projectRoot);
1199
1211
  if (!rootCheck.ok) return rootCheck;
@@ -1532,18 +1544,20 @@ function createConfigurationPrompter({ signal, input, output }) {
1532
1544
  }));
1533
1545
  },
1534
1546
  selectConfigurationScope: async () => promptValue(p.select({
1535
- message: "Configuration scope",
1547
+ message: "Where should this configuration apply?",
1536
1548
  options: [{
1537
1549
  value: "local",
1538
- label: "Current Project"
1550
+ label: "Current project",
1551
+ hint: "Enabled when this project’s container starts."
1539
1552
  }, {
1540
1553
  value: "global",
1541
- label: "Global"
1554
+ label: "Workspace Docker image",
1555
+ hint: "Built into the shared image. It affects image size and disk usage."
1542
1556
  }],
1543
1557
  ...common
1544
1558
  })),
1545
1559
  editGlobal: async (configuration, catalog) => {
1546
- p.log.step(`${c.bold(c.cyan("Global configuration"))}\n${c.dim("Choose the runtimes and agents available to every Project.")}`, common);
1560
+ p.log.step(`${c.bold(c.cyan("Workspace Docker image"))}\n${c.dim("Add runtimes and agents to the shared image. They are available to every project and increase the image size.")}`, common);
1547
1561
  return {
1548
1562
  version: 1,
1549
1563
  node: await promptValue(p.multiselect({
@@ -1568,7 +1582,7 @@ function createConfigurationPrompter({ signal, input, output }) {
1568
1582
  };
1569
1583
  },
1570
1584
  editLocal: async (configuration, catalog, globalConfiguration, editPorts = true) => {
1571
- p.log.step(`${c.bold(c.cyan("Project configuration"))}\n${c.dim(editPorts ? "Choose the runtime and published ports for this Project." : "Choose the replacement runtime for this Project.")}`, common);
1585
+ p.log.step(`${c.bold(c.cyan("Project configuration"))}\n${c.dim(editPorts ? "Choose the Node version and ports to use when this project’s container starts." : "Choose the Node version to use when this project’s container starts.")}`, common);
1572
1586
  const node = await promptValue(p.select({
1573
1587
  message: "Selected Node release line",
1574
1588
  options: [{
@@ -1638,34 +1652,205 @@ function showLogo() {
1638
1652
  for (let i = 0; i < ASCII_ART.length; i++) console.log(`${COLORS[i]}${ASCII_ART[i]}${REST}`);
1639
1653
  }
1640
1654
  //#endregion
1641
- //#region \0devbox-workspace-image-default:.bash_aliases.mjs
1642
- var _devbox_workspace_image_default__bash_aliases_default = "alias ..='cd ..'\nalias ...='cd ../..'\nalias c='clear'\nalias g='git'\nalias ccc='claude --dangerously-skip-permissions'\nalias ccx='codex --sandbox workspace-write --ask-for-approval never'\nalias ggg='agy --dangerously-skip-permissions'\n\nfunction mkcd() {\n mkdir \"$1\"\n cd \"$1\"\n}\n\n# Laravel Artisan\nalias pa='php artisan'\nalias pav='php artisan serve'\nalias pam='php artisan migrate'\nalias pams='php artisan migrate --seed'\nalias pamr='php artisan migrate:rollback'\nalias pamf='php artisan migrate:fresh'\nalias pamfs='php artisan migrate:fresh --seed'\nalias pas='php artisan db:seed'\nalias par='php artisan route:list'\nalias paq='php artisan queue:work'\nalias pah='php artisan horizon'\nalias pat='php artisan tinker'\nalias phpunit='./vendor/bin/phpunit --colors=always'\nalias pu='phpunit'\nalias pest='./vendor/bin/pest --colors=always'\nalias p='pest'\nalias pint='./vendor/bin/pint'\nalias phpstan='./vendor/bin/phpstan analyse --ansi'\nalias rector='./vendor/bin/rector'\n\n# NPM (requires @antfu/ni)\nalias nv='nr dev' # nd exists in @antfu/ni\nalias nb='nr build'\nalias nll='nr lint' # nl is a Linux command\nalias nlf='nr lint --fix'\nalias nf='nr fmt'\nalias nfc='nr fmt --check'\nalias nc='nr check'\nalias nt='nr test'\nalias ntr='nr test --run'\n";
1643
- //#endregion
1644
- //#region \0devbox-workspace-image-default:.claude/settings.json.mjs
1655
+ //#region \0devbox-image-default:/home/runner/work/devbox/devbox/src/agent/image-defaults/.claude/settings.json.mjs
1645
1656
  var settings_json_default = "{\n \"$schema\": \"https://json.schemastore.org/claude-code-settings.json\",\n \"env\": {\n \"CLAUDE_CODE_USE_POWERSHELL_TOOL\": \"0\"\n },\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"includeGitInstructions\": false,\n \"autoMemoryEnabled\": false\n}\n";
1646
1657
  //#endregion
1647
- //#region \0devbox-workspace-image-default:.gitconfig.mjs
1648
- var _devbox_workspace_image_default__gitconfig_default = "[core]\n autocrlf = input\n fileMode = false\n pager = less -r\n quotepath = false\n[init]\n defaultBranch = main\n[color]\n diff = auto\n status = auto\n branch = auto\n[alias]\n cf = config\n cfl = config --list\n s = status\n a = add .\n l = log --oneline --graph\n la = log --oneline --graph --all\n last = log -1 HEAD\n c = commit\n cm = commit -m\n ca = commit --amend -m\n can = commit --amend --no-edit\n co = checkout\n cp = cherry-pick\n b = branch\n m = merge\n mnf = merge --no-ff\n rs = reset\n rs0h = reset HEAD --hard\n rs1 = reset HEAD~1\n rs1h = reset HEAD~1 --hard\n rb = rebase\n rbo = rebase --onto\n rbi = rebase -i\n rbc = rebase --continue\n rba = rebase --abort\n rm = remote\n r = remote -v\n ra = remote add\n rr = remote remove\n rp = remote prune\n f = fetch\n fp = fetch --prune\n p = push\n pl = pull\n";
1649
- //#endregion
1650
- //#region \0devbox-workspace-image-default:.omp/agent/config.yml.mjs
1658
+ //#region \0devbox-image-default:/home/runner/work/devbox/devbox/src/agent/image-defaults/.omp/agent/config.yml.mjs
1651
1659
  var config_yml_default = "setupVersion: 2\n\n# Basic\nmodelRoles:\n default: openai-codex/gpt-5.6-terra:high\n smol: openai-codex/gpt-5.6-luna:high\n slow: openai-codex/gpt-5.6-sol:max\n plan: openai-codex/gpt-5.6-sol:max\nsymbolPreset: unicode\n\n# Theme\ntheme:\n dark: titanium\n light: light\ncomposer:\n shape: claude\n\n# Auto QA\ndev:\n autoqa: false\n autoqaConsent: denied\n";
1652
1660
  //#endregion
1661
+ //#region src/agent/build.ts
1662
+ function createAgentWorkspaceContribution(input) {
1663
+ const agents = input.configuredAgents.map((name) => ({
1664
+ name,
1665
+ recipe: PACKAGED_AGENTS[name]
1666
+ }));
1667
+ const buildContextAssets = selectBuildContextAssets(agents);
1668
+ const skillAgents = agents.filter((agent) => agent.recipe.supportsSkillInstallation).map((agent) => agent.name);
1669
+ return {
1670
+ buildContextAssets,
1671
+ dockerignoreEntries: selectDockerignoreEntries(buildContextAssets),
1672
+ installationDockerfileLines: renderAgentInstallations(agents, input.buildNodeRuntimeRoot, skillAgents),
1673
+ assetDockerfileLines: renderAgentAssets(agents),
1674
+ notificationDockerfileLines: renderAgentNotifications(agents, input.notificationsEnabled),
1675
+ entrypointSetup: renderAgentEntrypointSetup(agents)
1676
+ };
1677
+ }
1678
+ function renderAgentInstallations(agents, buildNodeRuntimeRoot, skillAgents) {
1679
+ const lines = [];
1680
+ for (const agent of agents) {
1681
+ const preInstallCommands = agent.recipe.installation.preInstallCommands ?? [];
1682
+ lines.push(`# Install ${agent.name}`, `RUN mkdir -p ${agent.recipe.home.target} \\`, ...preInstallCommands.map((command) => ` && ${command} \\`), ` && curl -fsSL ${agent.recipe.installation.url} | ${agent.recipe.installation.shell}`, "");
1683
+ }
1684
+ if (agents.length > 0) lines.push("# Set the PATH to include AI tools", "ENV PATH=\"/home/devbox/.local/bin:${PATH}\"", "");
1685
+ if (buildNodeRuntimeRoot !== void 0 && skillAgents.length > 0) {
1686
+ const skillAgentArguments = skillAgents.map((agent) => `-a ${agent}`).join(" ");
1687
+ lines.push("# Install Agent Skills", "RUN set -eux \\", " && mkdir -p /home/devbox/.agents/skills \\", ` && export PATH="${buildNodeRuntimeRoot}/bin:$PATH" \\`, ` && npx -y skills add ycs77/skills -g ${skillAgentArguments} -s '*' -y`, "");
1688
+ }
1689
+ return lines;
1690
+ }
1691
+ function selectDockerignoreEntries(assets) {
1692
+ const entries = [];
1693
+ if (assets.some((asset) => asset.relativePath === ".claude/settings.json")) entries.push("!.claude", "!.claude/settings.json");
1694
+ if (assets.some((asset) => asset.relativePath === ".omp/agent/config.yml")) entries.push("!.omp", "!.omp/agent", "!.omp/agent/config.yml");
1695
+ return entries;
1696
+ }
1697
+ function selectBuildContextAssets(agents) {
1698
+ const assets = [];
1699
+ if (agents.some((agent) => agent.name === "claude-code")) assets.push({
1700
+ relativePath: ".claude/settings.json",
1701
+ content: settings_json_default
1702
+ });
1703
+ if (agents.some((agent) => agent.name === "omp")) assets.push({
1704
+ relativePath: ".omp/agent/config.yml",
1705
+ content: config_yml_default
1706
+ });
1707
+ return assets;
1708
+ }
1709
+ function renderAgentAssets(agents) {
1710
+ const hasClaudeCode = agents.some((agent) => agent.name === "claude-code");
1711
+ const hasOmp = agents.some((agent) => agent.name === "omp");
1712
+ if (!hasClaudeCode && !hasOmp) return [];
1713
+ const ownershipCommands = [];
1714
+ const lines = ["# Copy AI dotfiles"];
1715
+ if (hasClaudeCode) {
1716
+ lines.push("COPY .claude/settings.json /home/devbox/.claude/settings.json");
1717
+ ownershipCommands.push("chown devbox:devbox /home/devbox/.claude/settings.json");
1718
+ }
1719
+ if (hasOmp) {
1720
+ lines.push("COPY .omp/agent/config.yml /home/devbox/.omp/agent/config.yml");
1721
+ ownershipCommands.push("chown devbox:devbox /home/devbox/.omp/agent", "chown devbox:devbox /home/devbox/.omp/agent/config.yml");
1722
+ }
1723
+ lines.push(`RUN ${ownershipCommands.join(" \\\n && ")}`, "");
1724
+ return lines;
1725
+ }
1726
+ function renderAgentNotifications(agents, notificationsEnabled) {
1727
+ const commands = notificationsEnabled ? agents.flatMap((agent) => agent.recipe.supportsNotifications ? agent.recipe.notificationInstallationCommands : []) : [];
1728
+ if (commands.length === 0) return [];
1729
+ return [
1730
+ "# Install Agent Notification Plugins",
1731
+ "USER devbox",
1732
+ "RUN set -eux \\",
1733
+ ...commands.map((command, index) => ` && ${command}${index < commands.length - 1 ? " \\" : ""}`),
1734
+ "USER root",
1735
+ ""
1736
+ ];
1737
+ }
1738
+ function renderAgentEntrypointSetup(agents) {
1739
+ if (!agents.some((agent) => agent.name === "agy")) return [];
1740
+ return [
1741
+ "# Link shared Agent Skills",
1742
+ "if [ ! -L \"/home/devbox/.gemini/skills\" ]; then",
1743
+ " ln -sfn /home/devbox/.agents/skills /home/devbox/.gemini/skills",
1744
+ " chown -h devbox:devbox /home/devbox/.gemini/skills",
1745
+ "fi",
1746
+ ""
1747
+ ];
1748
+ }
1749
+ //#endregion
1750
+ //#region src/runtimes/node/build.ts
1751
+ function createNodeWorkspaceContribution(configuredReleaseLines) {
1752
+ const runtimes = configuredReleaseLines.map((releaseLine) => PACKAGED_NODE_RECIPES[releaseLine]);
1753
+ const buildRuntime = selectBuildNodeRuntime(runtimes);
1754
+ return {
1755
+ buildNodeRuntimeRoot: buildRuntime?.runtimeRoot,
1756
+ runtimeInstallations: runtimes.flatMap(renderNodeRuntimeStage),
1757
+ sandboxUserSetup: buildRuntime === void 0 ? [] : [
1758
+ "# Configure pnpm for the non-root Sandbox user",
1759
+ "RUN mkdir -p /home/devbox/.config/pnpm /home/devbox/.pnpm-store \\",
1760
+ " && printf 'storeDir: /home/devbox/.pnpm-store\\n' > /home/devbox/.config/pnpm/config.yaml \\",
1761
+ " && chown devbox:devbox /home/devbox/.config/pnpm/config.yaml",
1762
+ ""
1763
+ ],
1764
+ entrypointSetup: renderNodeEntrypointSetup(runtimes)
1765
+ };
1766
+ }
1767
+ function selectBuildNodeRuntime(runtimes) {
1768
+ let buildRuntime;
1769
+ for (const runtime of runtimes) if (buildRuntime === void 0 || Number.parseFloat(runtime.releaseLine) > Number.parseFloat(buildRuntime.releaseLine)) buildRuntime = runtime;
1770
+ return buildRuntime;
1771
+ }
1772
+ function renderNodeEntrypointSetup(runtimes) {
1773
+ if (runtimes.length === 0) return [];
1774
+ return [
1775
+ "# Set the Node.js release line to use",
1776
+ "NODE_VERSION=\"${NODE_VERSION:-}\"",
1777
+ "if [ -n \"$NODE_VERSION\" ]; then",
1778
+ " case \"$NODE_VERSION\" in",
1779
+ " *[!0-9]*)",
1780
+ " echo \"NODE_VERSION must be a numeric release line: $NODE_VERSION\" >&2",
1781
+ " exit 1",
1782
+ " ;;",
1783
+ " esac",
1784
+ "",
1785
+ " NODE_RUNTIME_ROOT=\"/opt/devbox/runtimes/node/$NODE_VERSION\"",
1786
+ " if [ ! -x \"$NODE_RUNTIME_ROOT/bin/node\" ]; then",
1787
+ " echo \"Node.js release line $NODE_VERSION is not installed.\" >&2",
1788
+ " exit 1",
1789
+ " fi",
1790
+ "",
1791
+ " if ! grep -q \"# Devbox\" /etc/bash.bashrc; then",
1792
+ " printf \"\\n# Devbox\\nexport PATH=\\\"$NODE_RUNTIME_ROOT/bin:\\$PATH\\\"\\n\" >> /etc/bash.bashrc",
1793
+ " fi",
1794
+ "",
1795
+ " export PATH=\"$NODE_RUNTIME_ROOT/bin:$PATH\"",
1796
+ "fi",
1797
+ ""
1798
+ ];
1799
+ }
1800
+ function renderNodeRuntimeStage(recipe) {
1801
+ const keys = recipe.trustedReleaseKeys.map((key) => ` ${key}`).join(" \\\n");
1802
+ return [
1803
+ `# Install Node.js ${recipe.releaseLine}`,
1804
+ "RUN ARCH= OPENSSL_ARCH= && dpkgArch=\"$(dpkg --print-architecture)\" \\",
1805
+ " && case \"${dpkgArch##*-}\" in \\",
1806
+ " amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; \\",
1807
+ " ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; \\",
1808
+ " s390x) ARCH='s390x' OPENSSL_ARCH='linux*-s390x';; \\",
1809
+ " arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; \\",
1810
+ " *) echo \"unsupported architecture\"; exit 1 ;; \\",
1811
+ " esac \\",
1812
+ " && set -eux \\",
1813
+ ` && NODE_RUNTIME_VERSION=${recipe.version} \\`,
1814
+ ` && NODE_RUNTIME_ROOT=${recipe.runtimeRoot} \\`,
1815
+ " && mkdir -p \"$NODE_RUNTIME_ROOT\" /tmp/node-source \\",
1816
+ " && cd /tmp/node-source \\",
1817
+ " && export GNUPGHOME=\"$(mktemp -d)\" \\",
1818
+ " && for key in \\",
1819
+ `${keys} \\`,
1820
+ " ; do \\",
1821
+ " { gpg --batch --no-options --keyserver hkps://keys.openpgp.org --recv-keys \"$key\" && gpg --batch --no-options --fingerprint \"$key\"; } || \\",
1822
+ " { gpg --batch --no-options --keyserver keyserver.ubuntu.com --recv-keys \"$key\" && gpg --batch --no-options --fingerprint \"$key\"; }; \\",
1823
+ " done \\",
1824
+ " && archive=\"node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\" \\",
1825
+ ` && curl -fsSLO --compressed "${recipe.distributionBaseUrl}/v\${NODE_RUNTIME_VERSION}/node-v\${NODE_RUNTIME_VERSION}-linux-\${ARCH}.tar.xz" \\`,
1826
+ ` && curl -fsSLO --compressed "${recipe.distributionBaseUrl}/v\${NODE_RUNTIME_VERSION}/SHASUMS256.txt.asc" \\`,
1827
+ " && gpg --batch --no-options --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \\",
1828
+ " && gpgconf --kill all \\",
1829
+ " && rm -rf \"$GNUPGHOME\" \\",
1830
+ " && grep \" node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\\$\" SHASUMS256.txt | sha256sum -c - \\",
1831
+ " && tar -xJf \"node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\" -C \"$NODE_RUNTIME_ROOT\" --strip-components=1 --no-same-owner \\",
1832
+ " && rm \"node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\" SHASUMS256.txt.asc SHASUMS256.txt \\",
1833
+ " && find \"$NODE_RUNTIME_ROOT/include/node/openssl/archs\" -mindepth 1 -maxdepth 1 ! -name \"$OPENSSL_ARCH\" -exec rm -rf {} \\; \\",
1834
+ " && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \\",
1835
+ " && export PATH=\"$NODE_RUNTIME_ROOT/bin:$PATH\" \\",
1836
+ " && npm install -g npm \\",
1837
+ " && npm uninstall -g corepack \\",
1838
+ " && npm install -g yarn pnpm @antfu/ni --allow-scripts=pnpm,yarn \\",
1839
+ " && \"$NODE_RUNTIME_ROOT/bin/node\" --version \\",
1840
+ " && \"$NODE_RUNTIME_ROOT/bin/npm\" --version \\",
1841
+ " && rm -rf /tmp/node-source",
1842
+ ""
1843
+ ];
1844
+ }
1845
+ //#endregion
1653
1846
  //#region src/workspace/build.ts
1654
1847
  const BASE_WORKSPACE_IMAGE_DEFAULTS = [{
1655
1848
  relativePath: ".bash_aliases",
1656
- content: _devbox_workspace_image_default__bash_aliases_default
1849
+ content: "alias ..='cd ..'\nalias ...='cd ../..'\nalias c='clear'\nalias g='git'\nalias ccc='claude --dangerously-skip-permissions'\nalias ccx='codex --sandbox workspace-write --ask-for-approval never'\nalias ggg='agy --dangerously-skip-permissions'\n\nfunction mkcd() {\n mkdir \"$1\"\n cd \"$1\"\n}\n\n# Laravel Artisan\nalias pa='php artisan'\nalias pav='php artisan serve'\nalias pam='php artisan migrate'\nalias pams='php artisan migrate --seed'\nalias pamr='php artisan migrate:rollback'\nalias pamf='php artisan migrate:fresh'\nalias pamfs='php artisan migrate:fresh --seed'\nalias pas='php artisan db:seed'\nalias par='php artisan route:list'\nalias paq='php artisan queue:work'\nalias pah='php artisan horizon'\nalias pat='php artisan tinker'\nalias phpunit='./vendor/bin/phpunit --colors=always'\nalias pu='phpunit'\nalias pest='./vendor/bin/pest --colors=always'\nalias p='pest'\nalias pint='./vendor/bin/pint'\nalias phpstan='./vendor/bin/phpstan analyse --ansi'\nalias rector='./vendor/bin/rector'\n\n# NPM (requires @antfu/ni)\nalias nv='nr dev' # nd exists in @antfu/ni\nalias nb='nr build'\nalias nll='nr lint' # nl is a Linux command\nalias nlf='nr lint --fix'\nalias nf='nr fmt'\nalias nfc='nr fmt --check'\nalias nc='nr check'\nalias nt='nr test'\nalias ntr='nr test --run'\n"
1657
1850
  }, {
1658
1851
  relativePath: ".gitconfig",
1659
- content: _devbox_workspace_image_default__gitconfig_default
1852
+ content: "[core]\n autocrlf = input\n fileMode = false\n pager = less -r\n quotepath = false\n[init]\n defaultBranch = main\n[color]\n diff = auto\n status = auto\n branch = auto\n[alias]\n cf = config\n cfl = config --list\n s = status\n a = add .\n l = log --oneline --graph\n la = log --oneline --graph --all\n last = log -1 HEAD\n c = commit\n cm = commit -m\n ca = commit --amend -m\n can = commit --amend --no-edit\n co = checkout\n cp = cherry-pick\n b = branch\n m = merge\n mnf = merge --no-ff\n rs = reset\n rs0h = reset HEAD --hard\n rs1 = reset HEAD~1\n rs1h = reset HEAD~1 --hard\n rb = rebase\n rbo = rebase --onto\n rbi = rebase -i\n rbc = rebase --continue\n rba = rebase --abort\n rm = remote\n r = remote -v\n ra = remote add\n rr = remote remove\n rp = remote prune\n f = fetch\n fp = fetch --prune\n p = push\n pl = pull\n"
1660
1853
  }];
1661
- const CLAUDE_SETTINGS_DEFAULT = {
1662
- relativePath: ".claude/settings.json",
1663
- content: settings_json_default
1664
- };
1665
- const OMP_AGENT_CONFIG_DEFAULT = {
1666
- relativePath: ".omp/agent/config.yml",
1667
- content: config_yml_default
1668
- };
1669
1854
  async function buildWorkspace(input = {}) {
1670
1855
  return withStateLocks({
1671
1856
  devboxHome: input.devboxHome ?? join(homedir(), ".devbox"),
@@ -1695,27 +1880,25 @@ async function buildWorkspaceUnlocked(input) {
1695
1880
  const globalConfiguration = parsedGlobal.value;
1696
1881
  const hostIdentity = currentHostIdentity();
1697
1882
  if (!hostIdentity.ok) return hostIdentity;
1698
- const agents = globalConfiguration.agent.map((name) => ({
1699
- name,
1700
- recipe: PACKAGED_AGENTS[name]
1701
- }));
1702
- const buildContextAssets = selectBuildContextAssets(agents);
1703
- const nodeRuntimes = globalConfiguration.node.map((releaseLine) => PACKAGED_NODE_RECIPES[releaseLine]);
1883
+ const node = createNodeWorkspaceContribution(globalConfiguration.node);
1884
+ const agent = createAgentWorkspaceContribution({
1885
+ configuredAgents: globalConfiguration.agent,
1886
+ notificationsEnabled: globalConfiguration.agent_notifications,
1887
+ buildNodeRuntimeRoot: node.buildNodeRuntimeRoot
1888
+ });
1889
+ const buildContextAssets = [...BASE_WORKSPACE_IMAGE_DEFAULTS, ...agent.buildContextAssets];
1704
1890
  const dockerfile = renderBuildDockerfile({
1705
- nodeRuntimes,
1706
- buildNodeRuntime: buildNodeRuntimeRecipe(globalConfiguration),
1707
- agents,
1708
- agentNotifications: globalConfiguration.agent_notifications,
1709
- skillAgents: globalConfiguration.agent.filter((agent) => PACKAGED_AGENTS[agent]?.supportsSkillInstallation === true)
1891
+ node,
1892
+ agent
1710
1893
  });
1711
1894
  await mkdir(paths.buildContext, { recursive: true });
1712
1895
  await seedBuildContextAssets(paths.buildContext, buildContextAssets);
1713
1896
  await Promise.all([
1714
1897
  writeFile(join(paths.buildContext, "Dockerfile"), dockerfile),
1715
- writeFile(join(paths.buildContext, ".dockerignore"), renderDockerignore(buildContextAssets)),
1898
+ writeFile(join(paths.buildContext, ".dockerignore"), renderDockerignore(buildContextAssets, agent.dockerignoreEntries)),
1716
1899
  writeFile(join(paths.buildContext, "entrypoint.sh"), renderEntrypoint({
1717
- nodeRuntimes,
1718
- agents
1900
+ node,
1901
+ agent
1719
1902
  })),
1720
1903
  writeFile(join(paths.buildContext, "supervisord.conf"), renderSupervisorConfiguration())
1721
1904
  ]);
@@ -1729,14 +1912,6 @@ async function buildWorkspaceUnlocked(input) {
1729
1912
  if (!buildResult.ok) return buildResult;
1730
1913
  return success({ image: WORKSPACE_IMAGE });
1731
1914
  }
1732
- function buildNodeRuntimeRecipe(globalConfiguration) {
1733
- let buildNodeRuntime;
1734
- for (const releaseLine of globalConfiguration.node) {
1735
- const recipe = PACKAGED_NODE_RECIPES[releaseLine];
1736
- if (buildNodeRuntime === void 0 || Number.parseFloat(releaseLine) > Number.parseFloat(buildNodeRuntime.releaseLine)) buildNodeRuntime = recipe;
1737
- }
1738
- return buildNodeRuntime;
1739
- }
1740
1915
  function currentHostIdentity() {
1741
1916
  const userId = typeof process.getuid === "function" ? process.getuid() : void 0;
1742
1917
  const groupId = typeof process.getgid === "function" ? process.getgid() : void 0;
@@ -1762,6 +1937,9 @@ function renderBuildDockerfile(input) {
1762
1937
  "ENV DEBIAN_FRONTEND=noninteractive",
1763
1938
  "ENV TZ=UTC",
1764
1939
  "ENV LANG=C.UTF-8",
1940
+ "ENV TERM=xterm-256color",
1941
+ "ENV EDITOR=vim",
1942
+ "ENV VISUAL=vim",
1765
1943
  "",
1766
1944
  "RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone",
1767
1945
  "",
@@ -1790,51 +1968,26 @@ function renderBuildDockerfile(input) {
1790
1968
  " && chmod 0440 /etc/sudoers.d/devbox",
1791
1969
  ""
1792
1970
  ];
1793
- for (const recipe of input.nodeRuntimes) lines.push(...renderNodeRuntimeStage(recipe));
1971
+ lines.push(...input.node.runtimeInstallations);
1794
1972
  lines.push("WORKDIR /workspace", "USER devbox", "");
1795
- 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", "");
1796
- for (const agent of input.agents) {
1797
- const preInstallCommands = agent.recipe.installation.preInstallCommands ?? [];
1798
- lines.push(`# Install ${agent.name}`, `RUN mkdir -p ${agent.recipe.home.target} \\`, ...preInstallCommands.map((command) => ` && ${command} \\`), ` && curl -fsSL ${agent.recipe.installation.url} | ${agent.recipe.installation.shell}`, "");
1799
- }
1800
- if (input.agents.length > 0) lines.push("# Set the PATH to include AI tools", "ENV PATH=\"/home/devbox/.local/bin:${PATH}\"", "");
1801
- if (input.buildNodeRuntime !== void 0 && input.skillAgents.length > 0) {
1802
- const skillAgentArguments = input.skillAgents.map((agent) => `-a ${agent}`).join(" ");
1803
- 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`, "");
1804
- }
1973
+ lines.push(...input.node.sandboxUserSetup);
1974
+ lines.push(...input.agent.installationDockerfileLines);
1805
1975
  lines.push("USER root", "", "# Copy dotfiles", "COPY .bash_aliases /home/devbox/.bash_aliases", "COPY .gitconfig /home/devbox/.gitconfig", "RUN chown devbox:devbox /home/devbox/.bash_aliases \\", " && chown devbox:devbox /home/devbox/.gitconfig", "");
1806
- const hasClaudeCode = input.agents.some((agent) => agent.name === "claude-code");
1807
- const hasOmp = input.agents.some((agent) => agent.name === "omp");
1808
- if (hasClaudeCode || hasOmp) {
1809
- const ownershipCommands = [];
1810
- lines.push("# Copy AI dotfiles");
1811
- if (hasClaudeCode) {
1812
- lines.push("COPY .claude/settings.json /home/devbox/.claude/settings.json");
1813
- ownershipCommands.push("chown devbox:devbox /home/devbox/.claude/settings.json");
1814
- }
1815
- if (hasOmp) {
1816
- lines.push("COPY .omp/agent/config.yml /home/devbox/.omp/agent/config.yml");
1817
- ownershipCommands.push("chown devbox:devbox /home/devbox/.omp/agent", "chown devbox:devbox /home/devbox/.omp/agent/config.yml");
1818
- }
1819
- lines.push(`RUN ${ownershipCommands.join(" \\\n && ")}`, "");
1820
- }
1821
- const notificationInstallationCommands = input.agentNotifications ? input.agents.flatMap((agent) => agent.recipe.supportsNotifications ? agent.recipe.notificationInstallationCommands : []) : [];
1822
- if (notificationInstallationCommands.length > 0) lines.push("# Install Agent Notification Plugins", "USER devbox", "RUN set -eux \\", ...notificationInstallationCommands.map((command, index) => ` && ${command}${index < notificationInstallationCommands.length - 1 ? " \\" : ""}`), "USER root", "");
1976
+ lines.push(...input.agent.assetDockerfileLines);
1977
+ lines.push(...input.agent.notificationDockerfileLines);
1823
1978
  lines.push("COPY entrypoint.sh /usr/local/bin/entrypoint.sh", "COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf", "RUN chmod +x /usr/local/bin/entrypoint.sh", "", "ENTRYPOINT [\"/usr/local/bin/entrypoint.sh\"]");
1824
1979
  return `${lines.join("\n")}\n`;
1825
1980
  }
1826
- function renderDockerignore(assets) {
1827
- const lines = [
1981
+ function renderDockerignore(assets, additionalEntries) {
1982
+ return `${[
1828
1983
  "# Machine-owned Workspace build context",
1829
1984
  "*",
1830
1985
  "!Dockerfile",
1831
1986
  "!entrypoint.sh",
1832
1987
  "!supervisord.conf",
1833
- ...assets.map((asset) => `!${asset.relativePath}`)
1834
- ];
1835
- if (assets.some((asset) => asset.relativePath === ".claude/settings.json")) lines.push("!.claude", "!.claude/settings.json");
1836
- if (assets.some((asset) => asset.relativePath === ".omp/agent/config.yml")) lines.push("!.omp", "!.omp/agent", "!.omp/agent/config.yml");
1837
- return `${lines.join("\n")}\n`;
1988
+ ...assets.map((asset) => `!${asset.relativePath}`),
1989
+ ...additionalEntries
1990
+ ].join("\n")}\n`;
1838
1991
  }
1839
1992
  async function seedBuildContextAssets(buildContext, assets) {
1840
1993
  await Promise.all(assets.map(async (asset) => {
@@ -1847,18 +2000,18 @@ async function seedBuildContextAssets(buildContext, assets) {
1847
2000
  }
1848
2001
  }));
1849
2002
  }
1850
- function selectBuildContextAssets(agents) {
1851
- const assets = [...BASE_WORKSPACE_IMAGE_DEFAULTS];
1852
- if (agents.some((agent) => agent.name === "claude-code")) assets.push(CLAUDE_SETTINGS_DEFAULT);
1853
- if (agents.some((agent) => agent.name === "omp")) assets.push(OMP_AGENT_CONFIG_DEFAULT);
1854
- return assets;
1855
- }
1856
2003
  function renderEntrypoint(input) {
1857
- const lines = ["#!/usr/bin/env bash", ""];
1858
- if (input.nodeRuntimes.length > 0) lines.push("# Set the Node.js release line to use", "NODE_VERSION=\"${NODE_VERSION:-}\"", "if [ -n \"$NODE_VERSION\" ]; then", " case \"$NODE_VERSION\" in", " *[!0-9]*)", " echo \"NODE_VERSION must be a numeric release line: $NODE_VERSION\" >&2", " exit 1", " ;;", " esac", "", " NODE_RUNTIME_ROOT=\"/opt/devbox/runtimes/node/$NODE_VERSION\"", " if [ ! -x \"$NODE_RUNTIME_ROOT/bin/node\" ]; then", " echo \"Node.js release line $NODE_VERSION is not installed.\" >&2", " exit 1", " fi", "", " if ! grep -q \"# Devbox\" /etc/bash.bashrc; then", " printf \"\\n# Devbox\\nexport PATH=\\\"$NODE_RUNTIME_ROOT/bin:\\$PATH\\\"\\n\" >> /etc/bash.bashrc", " fi", "", " export PATH=\"$NODE_RUNTIME_ROOT/bin:$PATH\"", "fi", "");
1859
- if (input.agents.some((agent) => agent.name === "agy")) lines.push("# Link shared Agent Skills", "if [ ! -L \"/home/devbox/.gemini/skills\" ]; then", " ln -sfn /home/devbox/.agents/skills /home/devbox/.gemini/skills", " chown -h devbox:devbox /home/devbox/.gemini/skills", "fi", "");
1860
- lines.push("if [ $# -gt 0 ]; then", " exec gosu devbox \"$@\"", "else", " exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf", "fi");
1861
- return `${lines.join("\n")}\n`;
2004
+ return `${[
2005
+ "#!/usr/bin/env bash",
2006
+ "",
2007
+ ...input.node.entrypointSetup,
2008
+ ...input.agent.entrypointSetup,
2009
+ "if [ $# -gt 0 ]; then",
2010
+ " exec gosu devbox \"$@\"",
2011
+ "else",
2012
+ " exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf",
2013
+ "fi"
2014
+ ].join("\n")}\n`;
1862
2015
  }
1863
2016
  function renderSupervisorConfiguration() {
1864
2017
  return `${[
@@ -1879,51 +2032,6 @@ function renderSupervisorConfiguration() {
1879
2032
  "stderr_logfile=/dev/null"
1880
2033
  ].join("\n")}\n`;
1881
2034
  }
1882
- function renderNodeRuntimeStage(recipe) {
1883
- const keys = recipe.trustedReleaseKeys.map((key) => ` ${key}`).join(" \\\n");
1884
- return [
1885
- `# Install Node.js ${recipe.releaseLine}`,
1886
- "RUN ARCH= OPENSSL_ARCH= && dpkgArch=\"$(dpkg --print-architecture)\" \\",
1887
- " && case \"${dpkgArch##*-}\" in \\",
1888
- " amd64) ARCH='x64' OPENSSL_ARCH='linux-x86_64';; \\",
1889
- " ppc64el) ARCH='ppc64le' OPENSSL_ARCH='linux-ppc64le';; \\",
1890
- " s390x) ARCH='s390x' OPENSSL_ARCH='linux*-s390x';; \\",
1891
- " arm64) ARCH='arm64' OPENSSL_ARCH='linux-aarch64';; \\",
1892
- " *) echo \"unsupported architecture\"; exit 1 ;; \\",
1893
- " esac \\",
1894
- " && set -eux \\",
1895
- ` && NODE_RUNTIME_VERSION=${recipe.version} \\`,
1896
- ` && NODE_RUNTIME_ROOT=${recipe.runtimeRoot} \\`,
1897
- " && mkdir -p \"$NODE_RUNTIME_ROOT\" /tmp/node-source \\",
1898
- " && cd /tmp/node-source \\",
1899
- " && export GNUPGHOME=\"$(mktemp -d)\" \\",
1900
- " && for key in \\",
1901
- `${keys} \\`,
1902
- " ; do \\",
1903
- " { gpg --batch --no-options --keyserver hkps://keys.openpgp.org --recv-keys \"$key\" && gpg --batch --no-options --fingerprint \"$key\"; } || \\",
1904
- " { gpg --batch --no-options --keyserver keyserver.ubuntu.com --recv-keys \"$key\" && gpg --batch --no-options --fingerprint \"$key\"; }; \\",
1905
- " done \\",
1906
- " && archive=\"node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\" \\",
1907
- " && curl -fsSLO --compressed \"https://nodejs.org/dist/v${NODE_RUNTIME_VERSION}/node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\" \\",
1908
- " && curl -fsSLO --compressed \"https://nodejs.org/dist/v${NODE_RUNTIME_VERSION}/SHASUMS256.txt.asc\" \\",
1909
- " && gpg --batch --no-options --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \\",
1910
- " && gpgconf --kill all \\",
1911
- " && rm -rf \"$GNUPGHOME\" \\",
1912
- " && grep \" node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\\$\" SHASUMS256.txt | sha256sum -c - \\",
1913
- " && tar -xJf \"node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\" -C \"$NODE_RUNTIME_ROOT\" --strip-components=1 --no-same-owner \\",
1914
- " && rm \"node-v${NODE_RUNTIME_VERSION}-linux-${ARCH}.tar.xz\" SHASUMS256.txt.asc SHASUMS256.txt \\",
1915
- " && find \"$NODE_RUNTIME_ROOT/include/node/openssl/archs\" -mindepth 1 -maxdepth 1 ! -name \"$OPENSSL_ARCH\" -exec rm -rf {} \\; \\",
1916
- " && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \\",
1917
- " && export PATH=\"$NODE_RUNTIME_ROOT/bin:$PATH\" \\",
1918
- " && npm install -g npm \\",
1919
- " && npm uninstall -g corepack \\",
1920
- " && npm install -g yarn pnpm @antfu/ni --allow-scripts=pnpm,yarn \\",
1921
- " && \"$NODE_RUNTIME_ROOT/bin/node\" --version \\",
1922
- " && \"$NODE_RUNTIME_ROOT/bin/npm\" --version \\",
1923
- " && rm -rf /tmp/node-source",
1924
- ""
1925
- ];
1926
- }
1927
2035
  async function runDockerBuild(invocation, signal) {
1928
2036
  const build = spawn("docker", [
1929
2037
  "build",
@@ -2040,12 +2148,16 @@ function createCli(signal, interactive) {
2040
2148
  for (const [command, description] of [
2041
2149
  ["up", "Start the current Project Sandbox."],
2042
2150
  ["down", "Stop and remove the current Project Sandbox."],
2043
- ["stop", "Stop the current Project Sandbox."],
2044
- ["sh", "Open a shell in the current Project Sandbox."]
2151
+ ["stop", "Stop the current Project Sandbox."]
2045
2152
  ]) cli.command(command, description).action(async () => {
2046
2153
  const result = await runSandboxLifecycle(command, { signal });
2047
2154
  return result.ok ? success({}) : result;
2048
2155
  });
2156
+ cli.command("sh", "Open a shell in the current Project Sandbox.").action(async () => {
2157
+ const result = await runSandboxShell({ signal });
2158
+ return result.ok ? success({}) : result;
2159
+ });
2160
+ cli.version(version);
2049
2161
  cli.help();
2050
2162
  return cli;
2051
2163
  }
@@ -2072,11 +2184,18 @@ async function main() {
2072
2184
  return 130;
2073
2185
  }
2074
2186
  if (error instanceof HostCommandError) return error.exitCode;
2187
+ if (isCacError(error)) {
2188
+ process.stderr.write(`${error.message}\n`);
2189
+ return 2;
2190
+ }
2075
2191
  throw error;
2076
2192
  } finally {
2077
2193
  process.off("SIGINT", interrupt);
2078
2194
  }
2079
2195
  }
2196
+ function isCacError(error) {
2197
+ return error instanceof Error && error.name === "CACError";
2198
+ }
2080
2199
  function interactiveFailure(observed, nextAction) {
2081
2200
  return failure({
2082
2201
  kind: "usage",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ycs77/devbox",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A Lucas Yang's opinionated devbox for AI agents",
5
5
  "keywords": [
6
6
  "agent",