Add persistent Rust and Go tooling to container

This commit is contained in:
Chris Wanstrath 2026-03-14 21:18:02 -07:00
parent c4c0703bee
commit 3c6cd05ef7

View File

@ -9,7 +9,13 @@ const DEBUG = !!process.env.DEBUG
const CONTAINER_NAME = "sandlot" const CONTAINER_NAME = "sandlot"
const USER = "ubuntu" const USER = "ubuntu"
const CLAUDE_BIN = `/home/${USER}/.local/bin/claude` 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. */ /** Translate a host path to its corresponding container path. */
export function containerPath(hostPath: string): string { 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 // 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 $`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<void> {
// 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. */ /** 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( systemPromptLines.push(
"The host's ~/.sandlot is mounted at /sandlot.", "The host's ~/.sandlot is mounted at /sandlot.",
"Bun is installed at ~/.local/bin/bun. Use bun instead of node/npm.", "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) { if (opts?.print) {
systemPromptLines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.") 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 systemPrompt = systemPromptLines.join("\n")
const term = process.env.TERM || "xterm-256color" 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?.continue) args.push("--continue")
if (opts?.print) args.push("-p", opts.print) if (opts?.print) args.push("-p", opts.print)
else if (opts?.prompt) args.push(opts.prompt) 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<void> { export async function shell(workdir?: string): Promise<void> {
const args = ["container", "exec", "-it", "--user", USER] const args = ["container", "exec", "-it", "--user", USER]
if (workdir) args.push("--workdir", containerPath(workdir)) 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" }) const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
await proc.exited await proc.exited
} }
@ -317,7 +350,7 @@ export async function shell(workdir?: string): Promise<void> {
/** Run neofetch in the container. */ /** Run neofetch in the container. */
export async function neofetch(): Promise<void> { export async function neofetch(): Promise<void> {
const proc = Bun.spawn( 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" }, { stdin: "inherit", stdout: "inherit", stderr: "inherit" },
) )
await proc.exited await proc.exited
@ -325,7 +358,8 @@ export async function neofetch(): Promise<void> {
/** Run a bash command in the container at the given workdir, capturing output. */ /** 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 }> { 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 { return {
exitCode: result.exitCode, exitCode: result.exitCode,
stdout: result.stdout.toString().trim(), stdout: result.stdout.toString().trim(),