sandlot/src/vm.ts
Chris Wanstrath e3e4419933 Refactor config command and fix default memory limit
Move defaults and normalization into KEYS metadata, validate config
file shape on load, and lower default container memory from 32G to 16G.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:24:57 -07:00

450 lines
20 KiB
TypeScript

import { $ } from "bun"
import { existsSync } from "fs"
import { homedir } from "os"
import { dirname, join } from "path"
import { requireApiKey } from "./env.ts"
import { info } from "./fmt.ts"
import { get as getConfig } from "./config.ts"
const DEBUG = !!process.env.DEBUG
const CONTAINER_NAME = "sandlot"
const USER = "ubuntu"
const CLAUDE_BIN = `/home/${USER}/.local/bin/claude`
const CONTAINER_PATH = `/sandlot/bin:/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",
RUSTC_WRAPPER: "/sandlot/.cargo/bin/sccache",
SCCACHE_DIR: "/sandlot/.sccache",
}
/** Translate a host path to its corresponding container path. */
export function containerPath(hostPath: string): string {
const home = homedir()
if (hostPath.startsWith(`${home}/.sandlot`)) {
return "/sandlot" + hostPath.slice(`${home}/.sandlot`.length)
}
if (hostPath.startsWith(`${home}/dev`)) {
return "/host/dev" + hostPath.slice(`${home}/dev`.length)
}
if (hostPath.startsWith(`${home}/code`)) {
return "/host/code" + hostPath.slice(`${home}/code`.length)
}
return hostPath
}
function requireContainer(): void {
if (!Bun.which("container")) {
console.error('✖ Apple Container is not installed. Install it with: brew install container')
process.exit(1)
}
}
/** Run a shell command, logging stderr on failure. */
async function run(cmd: ReturnType<typeof $>, step: string): Promise<void> {
const prepared = DEBUG ? cmd.nothrow() : cmd.nothrow().quiet()
const result = await prepared
if (result.exitCode !== 0) {
const stderr = result.stderr.toString().trim()
const stdout = result.stdout.toString().trim()
const detail = stderr || stdout || "(no output)"
throw new Error(`${step} failed (exit ${result.exitCode}):\n${detail}`)
}
}
// ── create() helpers (internal) ──────────────────────────────────────
/** Check which host source directories exist. */
function hostMounts(home: string): { dev: boolean; code: boolean } {
return {
dev: existsSync(`${home}/dev`),
code: existsSync(`${home}/code`),
}
}
/** Pull the image and start the container in detached mode. */
async function createContainer(home: string): Promise<void> {
const mounts = hostMounts(home)
const memory = (await getConfig("memory")) ?? "16G"
const args = ["container", "run", "-d", "--name", CONTAINER_NAME, "-m", memory]
if (mounts.dev) args.push("--mount", `type=bind,source=${home}/dev,target=/host/dev,readonly`)
if (mounts.code) args.push("--mount", `type=bind,source=${home}/code,target=/host/code,readonly`)
args.push("-v", `${home}/.sandlot:/sandlot`, "ubuntu:24.04", "sleep", "infinity")
const prepared = DEBUG ? $`${args}`.nothrow() : $`${args}`.nothrow().quiet()
const result = await prepared
if (result.exitCode !== 0) {
const stderr = result.stderr.toString().trim()
const stdout = result.stdout.toString().trim()
throw new Error(`Container creation failed (exit ${result.exitCode}):\n${stderr || stdout || "(no output)"}`)
}
}
/** Install base system packages (as root). */
async function installPackages(cached: boolean): Promise<void> {
const packages = cached
? "curl git fish build-essential"
: "curl git fish unzip build-essential"
await run(
$`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`,
"Package installation")
}
/** Create symlinks so git worktree absolute paths from the host resolve inside the container. */
async function createHostSymlinks(home: string): Promise<void> {
const mounts = hostMounts(home)
const cmds = [`mkdir -p '${home}'`, `ln -s /sandlot '${home}/.sandlot'`]
if (mounts.dev) cmds.push(`ln -s /host/dev '${home}/dev'`)
if (mounts.code) cmds.push(`ln -s /host/code '${home}/code'`)
await run(
$`container exec ${CONTAINER_NAME} bash -c ${cmds.join(" && ")}`,
"Symlink creation")
}
const CACHE_DIR = join(homedir(), '.sandlot', '.cache')
/** Check whether the package cache is populated. */
async function hasCachedTooling(): Promise<boolean> {
const files = ['bun', 'claude', 'neofetch', 'nvim.tar.gz']
const checks = await Promise.all(files.map(f => Bun.file(join(CACHE_DIR, f)).exists()))
return checks.every(Boolean)
}
/** Install Bun, Claude Code, neofetch, and Neovim using cached binaries when available. */
async function installTooling(cached: boolean, log?: (msg: string) => void): Promise<void> {
// Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container)
await $`mkdir -p ${CACHE_DIR}`.quiet()
if (cached) {
log?.("Installing packages (cached)")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`,
"Create bin directory")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`,
"Install cached binaries")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`,
"Install cached Neovim")
return
}
log?.("Installing Bun")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`,
"Bun installation")
log?.("Installing Claude Code")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`,
"Claude Code installation")
log?.("Installing neofetch")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch"}`,
"neofetch installation")
log?.("Installing Neovim")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-arm64.tar.gz -o /tmp/nvim.tar.gz && tar xzf /tmp/nvim.tar.gz -C ~/.local --strip-components=1"}`,
"Neovim installation")
// 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<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")
// Add musl target so Rust can link without a system C linker
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} env ${`RUSTUP_HOME=${CONTAINER_ENV.RUSTUP_HOME}`} ${`CARGO_HOME=${CONTAINER_ENV.CARGO_HOME}`} ${`PATH=${CONTAINER_ENV.CARGO_HOME}/bin:$PATH`} rustup target add aarch64-unknown-linux-musl`,
"Rust musl target")
}
// Ensure cargo config exists for musl target (even if Rust was already installed)
const hasCargoConfig = await $`container exec --user ${USER} ${CONTAINER_NAME} test -f /sandlot/.cargo/config.toml`.nothrow().quiet()
if (hasCargoConfig.exitCode !== 0) {
const cargoConfig = `[target.aarch64-unknown-linux-musl]\nlinker = "rust-lld"\n\n[build]\ntarget = "aarch64-unknown-linux-musl"\n`
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`echo -e '${cargoConfig}' > /sandlot/.cargo/config.toml`}`.quiet()
}
// sccache — skip if already installed on the persistent mount
const hasSccache = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.cargo/bin/sccache`.nothrow().quiet()
if (hasSccache.exitCode !== 0) {
log?.("Installing sccache")
await run(
$`container exec --user ${USER} ${CONTAINER_NAME} env ${`RUSTUP_HOME=${CONTAINER_ENV.RUSTUP_HOME}`} ${`CARGO_HOME=${CONTAINER_ENV.CARGO_HOME}`} ${`PATH=${CONTAINER_ENV.CARGO_HOME}/bin:$PATH`} cargo install sccache --locked`,
"sccache installation")
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p /sandlot/.sccache"}`.nothrow().quiet()
}
// 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. */
async function installScript(home: string, name: string, content: string): Promise<void> {
const tmp = `${home}/.sandlot/.${name}.tmp`
await Bun.write(tmp, content)
await $`chmod +x ${tmp}`.quiet()
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`cp /sandlot/.${name}.tmp ~/.local/bin/${name}`}`.quiet()
await Bun.file(tmp).unlink()
}
/** Configure git identity, API key helper, activity hook, and Claude settings. */
async function configureEnvironment(home: string, apiKey: string): Promise<void> {
const gitName = (await $`git config user.name`.quiet().text()).trim()
const gitEmail = (await $`git config user.email`.quiet().text()).trim()
if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet()
if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet()
const activityBin = `/home/${USER}/.local/bin/sandlot-activity`
const hooks = {
UserPromptSubmit: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
PreToolUse: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
Stop: [{ hooks: [{ type: "command", command: `${activityBin} idle` }] }],
}
const statusLine = { type: "command", command: `/home/${USER}/.local/bin/sandlot-statusline` }
const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks, statusLine })
const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true, projects: { "/": { hasTrustDialogAccepted: true } } })
// Write the helper script to a temp file and copy it in so the key
// never appears in a process argument visible in `ps`.
const tmp = `${home}/.sandlot/.api-key-helper.tmp`
await Bun.write(tmp, `#!/bin/sh\necho '${apiKey.replace(/'/g, "'\\''")}'\n`)
await $`chmod +x ${tmp}`.quiet()
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh"}`.quiet()
await Bun.file(tmp).unlink()
await installScript(home, "sandlot-activity", `#!/bin/bash\nP="\${CLAUDE_PROJECT_DIR%/}"\necho "$1" > "$(dirname "$P")/.activity-$(basename "$P")"\n`)
await installScript(home, "sandlot-statusline", `#!/bin/bash\ninput=$(cat)\ncwd=$(echo "$input" | grep -oP '"cwd"\\s*:\\s*"\\K[^"]+' | head -1)\n[ -n "$cwd" ] && printf '\\033[36m\u2387 %s\\033[0m\\n' "$(basename "$cwd")"\n`)
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`
mkdir -p ~/.claude
echo '${settingsJson}' > ~/.claude/settings.json
echo '${claudeJson}' > ~/.claude.json
`}`.quiet()
}
// ── create() ────────────────────────────────────────────────────────
/** Create and provision the container from scratch. Fails if it already exists. */
export async function create(log?: (msg: string) => void): Promise<void> {
requireContainer()
const apiKey = await requireApiKey()
const s = await status()
if (s !== "missing") {
throw new Error("Container already exists. Use 'sandlot vm destroy' first to recreate it.")
}
const home = homedir()
const cached = await hasCachedTooling()
log?.("Pulling image & creating container")
await createContainer(home)
log?.("Installing packages")
await installPackages(cached)
await createHostSymlinks(home)
await installTooling(cached, log)
log?.("Configuring environment")
await configureEnvironment(home, apiKey)
}
/** Start a stopped container. */
export async function start(): Promise<void> {
requireContainer()
const s = await status()
if (s === "running") return
if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.")
await run($`container start ${CONTAINER_NAME}`, "Container start")
}
/** Ensure the sandlot container exists and is running. Creates and provisions on first use. */
export async function ensure(log?: (msg: string) => void): Promise<void> {
requireContainer()
await requireApiKey()
// Ensure the container daemon is running (--enable-kernel-install skips interactive prompt)
if (DEBUG) await $`container system start --enable-kernel-install`.nothrow()
else await $`container system start --enable-kernel-install`.nothrow().quiet()
const s = await status()
if (s === "running") return
if (s === "stopped") {
await start()
return
}
await create(log)
}
/** Check container status. */
export async function status(): Promise<"running" | "stopped" | "missing"> {
const result = await $`container list --format json --all`.nothrow().quiet().text()
try {
const containers = JSON.parse(result.trim())
const container = containers.find((c: any) => c.configuration?.id === CONTAINER_NAME)
if (!container) return "missing"
return container.status?.toLowerCase() === "running" ? "running" : "stopped"
} catch {
return "missing"
}
}
/** Launch claude in the container at the given workdir. */
export async function claude(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> {
const cwd = containerPath(workdir)
const mounts = hostMounts(homedir())
const systemPromptLines = [
"You are running inside a sandlot container (Apple Container, ubuntu:24.04).",
`Your working directory is ${cwd}, a git worktree managed by sandlot.`,
]
if (mounts.dev) systemPromptLines.push("The host's ~/dev is mounted read-only at /host/dev.")
if (mounts.code) systemPromptLines.push("The host's ~/code is mounted read-only at /host/code.")
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/. sccache is configured as RUSTC_WRAPPER for build caching.",
)
if (opts?.print) {
systemPromptLines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.")
}
const systemPrompt = systemPromptLines.join("\n")
const term = process.env.TERM || "xterm-256color"
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)
if (opts?.print) {
const proc = Bun.spawn(args, { stdin: "inherit", stdout: "pipe", stderr: "inherit" })
const output = await new Response(proc.stdout).text()
const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue")
return claude(workdir, { ...opts, continue: false })
}
return { exitCode, output }
}
const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
const exitCode = await proc.exited
if (exitCode !== 0 && opts?.continue) {
info("Retrying without --continue")
return claude(workdir, { ...opts, continue: false })
}
return { exitCode }
}
/** Open an interactive fish shell in the container, optionally in a specific directory. */
export async function shell(workdir?: string): Promise<void> {
const args = ["container", "exec", "-it", "--user", USER]
if (workdir) args.push("--workdir", containerPath(workdir))
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
}
/** Run neofetch in the container. */
export async function neofetch(): Promise<void> {
const proc = Bun.spawn(
["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
}
/** 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 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=${CONTAINER_PATH}; ${envExports}; ` + command}`.nothrow().quiet()
return {
exitCode: result.exitCode,
stdout: result.stdout.toString().trim(),
stderr: result.stderr.toString().trim(),
}
}
/** Pipe input text to Claude in the container with a prompt, returning the output. */
export async function claudePipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const tmpName = `.claude-pipe-${crypto.randomUUID()}`
const tmpPath = join(homedir(), '.sandlot', tmpName)
try {
await Bun.write(tmpPath, input)
return await exec(
join(homedir(), '.sandlot'),
`cat /sandlot/${tmpName} | claude --model claude-opus-4-6 -p "${prompt.replace(/"/g, '\\"')}"`,
)
} finally {
await Bun.file(tmpPath).unlink().catch(() => {})
}
}
/** Check if Claude is actively working in the given worktree (based on activity hook). */
export async function isClaudeActive(worktree: string, branch: string): Promise<boolean> {
const file = `${dirname(worktree)}/.activity-${branch}`
try {
const content = await Bun.file(file).text()
return content.trim() === "active"
} catch {
return false
}
}
/** Set the activity marker for a worktree (e.g. during conflict resolution). */
export async function setActivity(worktree: string, branch: string): Promise<void> {
const file = `${dirname(worktree)}/.activity-${branch}`
await Bun.write(file, "active\n")
}
/** Remove the activity marker file for a worktree. */
export async function clearActivity(worktree: string, branch: string): Promise<void> {
const file = `${dirname(worktree)}/.activity-${branch}`
await Bun.file(file).unlink().catch(() => {})
}
/** Stop the container. */
export async function stop(): Promise<void> {
await $`container stop ${CONTAINER_NAME}`.nothrow().quiet()
}
/** Stop and delete the container. */
export async function destroy(): Promise<void> {
await stop()
await $`container delete ${CONTAINER_NAME}`.nothrow().quiet()
}
/** Clear the package cache so the next create re-downloads everything. */
export async function clearCache(): Promise<boolean> {
const exists = await Bun.file(join(CACHE_DIR, 'bun')).exists()
await $`rm -rf ${CACHE_DIR}`.nothrow().quiet()
return exists
}