sandlot/src/vm.ts

389 lines
16 KiB
TypeScript

import { $ } from "bun"
import { existsSync } from "fs"
import { homedir } from "os"
import { dirname, join } from "path"
import { getApiKey } from "./env.ts"
import { info } from "./fmt.ts"
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`
/** 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 args = ["container", "run", "-d", "--name", CONTAINER_NAME, "-m", "4G"]
if (mounts.dev) args.push("--mount", `type=bind,source=${home}/dev,target=/host/dev`)
if (mounts.code) args.push("--mount", `type=bind,source=${home}/code,target=/host/code`)
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"
: "curl git fish unzip"
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()
}
/** Configure git identity, API key helper, activity hook, and Claude settings. */
async function configureEnvironment(home: string, log?: (msg: string) => void, 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()
if (!apiKey) {
log?.("Warning: ANTHROPIC_API_KEY not found in ~/.env — claude will require manual login")
}
const activityBin = `/home/${USER}/.local/bin/sandlot-activity`
const hooks = {
UserPromptSubmit: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
Stop: [{ hooks: [{ type: "command", command: `${activityBin} idle` }] }],
}
const settingsJson = JSON.stringify(apiKey
? { apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks }
: { skipDangerousModePermissionPrompt: true, hooks })
const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: 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`.
if (apiKey) {
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()
}
// Install activity tracking hook script
const activityTmp = `${home}/.sandlot/.sandlot-activity.tmp`
await Bun.write(activityTmp, [
'#!/bin/bash',
'P="${CLAUDE_PROJECT_DIR%/}"',
'echo "$1" > "$(dirname "$P")/.activity-$(basename "$P")"',
'',
].join('\n'))
await $`chmod +x ${activityTmp}`.quiet()
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.sandlot-activity.tmp ~/.local/bin/sandlot-activity"}`.quiet()
await Bun.file(activityTmp).unlink()
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 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")
const apiKey = await getApiKey()
await configureEnvironment(home, log, 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()
// 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 at /host/dev.")
if (mounts.code) systemPromptLines.push("The host's ~/code is mounted 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.",
)
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 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]
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))
args.push(CONTAINER_NAME, "env", "TERM=xterm-256color", `PATH=${CONTAINER_PATH}`, "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}`, "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 result = await $`container exec --user ${USER} --workdir ${containerPath(workdir)} ${CONTAINER_NAME} bash -c ${"export PATH=$HOME/.local/bin:$PATH; " + 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 tmpPath = join(homedir(), '.sandlot', '.claude-pipe-tmp')
try {
await Bun.write(tmpPath, input)
return await exec(
join(homedir(), '.sandlot'),
`cat /sandlot/.claude-pipe-tmp | claude -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
}
}
/** 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
}