From 4f913b60913bab021bd506be8f68420b7641fd51 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 21 Feb 2026 08:24:26 -0800 Subject: [PATCH 1/4] Add tooling cache and install neofetch, difftastic, and Neovim in containers --- src/vm.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/vm.ts b/src/vm.ts index df8d144..4cd8886 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -47,9 +47,12 @@ async function createContainer(home: string): Promise { } /** Install base system packages (as root). */ -async function installPackages(): Promise { +async function installPackages(cached: boolean): Promise { + const packages = cached + ? "curl git fish" + : "curl git fish unzip" await run( - $`container exec ${CONTAINER_NAME} bash -c ${"apt update && apt install -y curl git neofetch fish unzip"}`, + $`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`, "Package installation") } @@ -60,8 +63,25 @@ async function createHostSymlinks(home: string): Promise { "Symlink creation") } -/** Install Bun and Claude Code (as ubuntu user). */ -async function installTooling(log?: (msg: string) => void): Promise { +const CACHE_DIR = join(homedir(), '.sandlot', '.cache') + +/** Check whether the package cache is populated. */ +async function hasCachedTooling(): Promise { + const files = ['bun', 'claude', 'neofetch', 'difft', '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 { + if (cached) { + log?.("Installing packages (cached)") + await run( + $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin && cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch /sandlot/.cache/difft ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch ~/.local/bin/difft && ln -sf bun ~/.local/bin/bunx && tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, + "Install from cache") + 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"}`, @@ -71,6 +91,25 @@ async function installTooling(log?: (msg: string) => void): Promise { 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 difftastic") + await $`mkdir -p ${CACHE_DIR}`.quiet() + await run( + $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://github.com/Wilfred/difftastic/releases/latest/download/difft-aarch64-unknown-linux-gnu.tar.gz | tar xz -C ~/.local/bin"}`, + "difftastic 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 /sandlot/.cache/nvim.tar.gz && tar xzf /sandlot/.cache/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 ~/.local/bin/difft /sandlot/.cache/"}`.nothrow().quiet() } /** Configure git identity, API key helper, activity hook, and Claude settings. */ @@ -136,14 +175,16 @@ export async function create(log?: (msg: string) => void): Promise { const home = homedir() + const cached = await hasCachedTooling() + log?.("Pulling image & creating container") await createContainer(home) log?.("Installing packages") - await installPackages() + await installPackages(cached) await createHostSymlinks(home) - await installTooling(log) + await installTooling(cached, log) log?.("Configuring environment") const apiKey = await getApiKey() @@ -235,7 +276,7 @@ export async function shell(workdir?: string): Promise { /** Run neofetch in the container. */ export async function info(): Promise { const proc = Bun.spawn( - ["container", "exec", "--user", USER, CONTAINER_NAME, "neofetch"], + ["container", "exec", "--user", USER, CONTAINER_NAME, "env", `PATH=/home/${USER}/.local/bin:/usr/bin:/bin`, "neofetch"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, ) await proc.exited From ca7af3d671856bf92baf47f4bccc3a79bc79b9d2 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 21 Feb 2026 08:31:02 -0800 Subject: [PATCH 2/4] Remove difftastic from tooling installation and cache --- src/vm.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/vm.ts b/src/vm.ts index 4cd8886..6a69762 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -67,17 +67,17 @@ const CACHE_DIR = join(homedir(), '.sandlot', '.cache') /** Check whether the package cache is populated. */ async function hasCachedTooling(): Promise { - const files = ['bun', 'claude', 'neofetch', 'difft', 'nvim.tar.gz'] + 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. */ +/** Install Bun, Claude Code, neofetch, and Neovim using cached binaries when available. */ async function installTooling(cached: boolean, log?: (msg: string) => void): Promise { if (cached) { log?.("Installing packages (cached)") await run( - $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin && cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch /sandlot/.cache/difft ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch ~/.local/bin/difft && ln -sf bun ~/.local/bin/bunx && tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, + $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin && 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 && tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, "Install from cache") return } @@ -97,19 +97,13 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro $`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 difftastic") - await $`mkdir -p ${CACHE_DIR}`.quiet() - await run( - $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://github.com/Wilfred/difftastic/releases/latest/download/difft-aarch64-unknown-linux-gnu.tar.gz | tar xz -C ~/.local/bin"}`, - "difftastic 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 /sandlot/.cache/nvim.tar.gz && tar xzf /sandlot/.cache/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 ~/.local/bin/difft /sandlot/.cache/"}`.nothrow().quiet() + await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/"}`.nothrow().quiet() } /** Configure git identity, API key helper, activity hook, and Claude settings. */ From 417bb666b2461b865bac62d5da41a95c3b5dbe14 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 21 Feb 2026 08:45:32 -0800 Subject: [PATCH 3/4] Refactor tooling install steps and extract PATH constant --- src/vm.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/vm.ts b/src/vm.ts index 6a69762..46c85d8 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -6,6 +6,7 @@ import { getApiKey } from "./env.ts" 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 { @@ -74,11 +75,20 @@ async function hasCachedTooling(): Promise { /** Install Bun, Claude Code, neofetch, and Neovim using cached binaries when available. */ async function installTooling(cached: boolean, log?: (msg: string) => void): Promise { + // 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 && 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 && tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, - "Install from cache") + $`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 } @@ -99,11 +109,11 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro 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 /sandlot/.cache/nvim.tar.gz && tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`, + $`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/"}`.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() } /** Configure git identity, API key helper, activity hook, and Claude settings. */ @@ -242,7 +252,7 @@ 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=/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--append-system-prompt", systemPrompt] + 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) @@ -262,7 +272,7 @@ 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=/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`, "fish", "--login") + 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 } @@ -270,7 +280,7 @@ export async function shell(workdir?: string): Promise { /** Run neofetch in the container. */ export async function info(): Promise { const proc = Bun.spawn( - ["container", "exec", "--user", USER, CONTAINER_NAME, "env", `PATH=/home/${USER}/.local/bin:/usr/bin:/bin`, "neofetch"], + ["container", "exec", "--user", USER, CONTAINER_NAME, "env", `PATH=${CONTAINER_PATH}`, "neofetch"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, ) await proc.exited From cfe8eab054c0c74c341421f5a5a3db3377d17b61 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 21 Feb 2026 08:53:06 -0800 Subject: [PATCH 4/4] add `vm uncache` command to clear the package cache --- src/commands/vm.ts | 8 ++++++++ src/vm.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src/commands/vm.ts b/src/commands/vm.ts index 789d2a8..3277280 100644 --- a/src/commands/vm.ts +++ b/src/commands/vm.ts @@ -71,4 +71,12 @@ export function register(program: Command) { await vm.destroy() console.log("✔ VM destroyed") }) + + vmCmd + .command("uncache") + .description("Clear the package cache (next create will re-download)") + .action(async () => { + const had = await vm.clearCache() + console.log(had ? "✔ Package cache cleared" : "No cache to clear") + }) } diff --git a/src/vm.ts b/src/vm.ts index 46c85d8..adee6f1 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -337,3 +337,10 @@ export async function destroy(): Promise { 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 { + const exists = await Bun.file(join(CACHE_DIR, 'bun')).exists() + await $`rm -rf ${CACHE_DIR}`.nothrow().quiet() + return exists +}