diff --git a/src/vm.ts b/src/vm.ts index a79c78e..006df16 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -9,7 +9,13 @@ const DEBUG = !!process.env.DEBUG const CONTAINER_NAME = "sandlot" const USER = "ubuntu" const CLAUDE_BIN = `/home/${USER}/.local/bin/claude` -const CONTAINER_PATH = `/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` +const CONTAINER_PATH = `/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` +const CONTAINER_ENV = { + RUSTUP_HOME: "/sandlot/.rustup", + CARGO_HOME: "/sandlot/.cargo", + GOROOT: "/sandlot/.go", + GOPATH: "/sandlot/.gopath", +} /** Translate a host path to its corresponding container path. */ export function containerPath(hostPath: string): string { @@ -142,6 +148,30 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro // Cache binaries for next time await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet() + + await installPersistentTooling(log) +} + +/** Install Rust and Go to /sandlot/ so they persist across container recreates. */ +async function installPersistentTooling(log?: (msg: string) => void): Promise { + // Rust — skip if already installed on the persistent mount + const hasRust = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.cargo/bin/rustc`.nothrow().quiet() + if (hasRust.exitCode !== 0) { + log?.("Installing Rust") + await run( + $`container exec --user ${USER} ${CONTAINER_NAME} env ${`RUSTUP_HOME=${CONTAINER_ENV.RUSTUP_HOME}`} ${`CARGO_HOME=${CONTAINER_ENV.CARGO_HOME}`} bash -c ${"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"}`, + "Rust installation") + } + + // Go — skip if already installed on the persistent mount + const hasGo = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.go/bin/go`.nothrow().quiet() + if (hasGo.exitCode !== 0) { + log?.("Installing Go") + await run( + $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p /sandlot/.go && curl -fsSL https://go.dev/dl/go1.24.1.linux-arm64.tar.gz | tar xz -C /sandlot/.go --strip-components=1"}`, + "Go installation") + await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p /sandlot/.gopath"}`.nothrow().quiet() + } } /** Write a script to a temp file, copy it into ~/.local/bin/ in the container, then clean up. */ @@ -273,6 +303,7 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?: systemPromptLines.push( "The host's ~/.sandlot is mounted at /sandlot.", "Bun is installed at ~/.local/bin/bun. Use bun instead of node/npm.", + "Rust (cargo/rustc) is installed at /sandlot/.cargo/. Go is installed at /sandlot/.go/.", ) if (opts?.print) { systemPromptLines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.") @@ -280,7 +311,8 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?: const systemPrompt = systemPromptLines.join("\n") const term = process.env.TERM || "xterm-256color" - const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", `TERM=${term}`, `PATH=${CONTAINER_PATH}`, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--append-system-prompt", systemPrompt] + const envArgs = [`TERM=${term}`, `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`)] + const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", ...envArgs, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--append-system-prompt", systemPrompt] if (opts?.continue) args.push("--continue") if (opts?.print) args.push("-p", opts.print) else if (opts?.prompt) args.push(opts.prompt) @@ -309,7 +341,8 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?: export async function shell(workdir?: string): Promise { const args = ["container", "exec", "-it", "--user", USER] if (workdir) args.push("--workdir", containerPath(workdir)) - args.push(CONTAINER_NAME, "env", "TERM=xterm-256color", `PATH=${CONTAINER_PATH}`, "fish", "--login") + const envArgs = ["TERM=xterm-256color", `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`)] + args.push(CONTAINER_NAME, "env", ...envArgs, "fish", "--login") const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }) await proc.exited } @@ -317,7 +350,7 @@ export async function shell(workdir?: string): Promise { /** Run neofetch in the container. */ export async function neofetch(): Promise { const proc = Bun.spawn( - ["container", "exec", "--user", USER, CONTAINER_NAME, "env", `PATH=${CONTAINER_PATH}`, "neofetch"], + ["container", "exec", "--user", USER, CONTAINER_NAME, "env", `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`), "neofetch"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, ) await proc.exited @@ -325,7 +358,8 @@ export async function neofetch(): Promise { /** Run a bash command in the container at the given workdir, capturing output. */ export async function exec(workdir: string, command: string): Promise<{ exitCode: number; stdout: string; stderr: string }> { - const result = await $`container exec --user ${USER} --workdir ${containerPath(workdir)} ${CONTAINER_NAME} bash -c ${"export PATH=$HOME/.local/bin:$PATH; " + command}`.nothrow().quiet() + const envExports = Object.entries(CONTAINER_ENV).map(([k, v]) => `export ${k}=${v}`).join("; ") + const result = await $`container exec --user ${USER} --workdir ${containerPath(workdir)} ${CONTAINER_NAME} bash -c ${`export PATH=$HOME/.local/bin:$PATH; ${envExports}; ` + command}`.nothrow().quiet() return { exitCode: result.exitCode, stdout: result.stdout.toString().trim(),