apple container

This commit is contained in:
Chris Wanstrath 2026-02-18 23:30:59 -08:00
parent f90cd3a38d
commit 0f18f1e5ff
4 changed files with 134 additions and 101 deletions

View File

@ -1,14 +1,18 @@
# sandlot # sandlot
A CLI for branch-based development using git worktrees and [Lima](https://github.com/lima-vm/lima) VMs. Each branch gets its own worktree and isolated VM. A CLI for branch-based development using git worktrees and [Apple Container](https://github.com/apple/container). Each branch gets its own worktree and isolated container.
## Prerequisites ## Prerequisites
- macOS on Apple Silicon - macOS on Apple Silicon
- [Bun](https://bun.sh) - [Bun](https://bun.sh)
- [Lima](https://github.com/lima-vm/lima) (`brew install lima`) - [Apple Container](https://github.com/apple/container) (`brew install container`)
- Git - Git
### First-time setup
After installing Apple Container, run `container system start` in your terminal. It will prompt to install the Kata kernel — say yes. This is a one-time step.
## Install ## Install
```bash ```bash
@ -28,30 +32,15 @@ Run all commands from inside a cloned git repo.
sandlot new fix-POST sandlot new fix-POST
``` ```
Creates a worktree at `.sandlot/fix-POST/`, boots a Lima VM mapped to it, and drops you into a shell. Creates a worktree, boots a container, and launches Claude Code inside it.
### Other commands ### Other commands
```bash ```bash
sandlot list # show all sessions sandlot list # show all sessions
sandlot open <branch> # re-enter a session's VM sandlot open <branch> # re-enter a session
sandlot stop <branch> # stop a VM without destroying it sandlot stop <branch> # stop a container without destroying it
sandlot rm <branch> # tear down session (VM, worktree, local branch) sandlot rm <branch> # tear down session (container, worktree, local branch)
``` ```
Use git directly for commits, pushes, merges, etc. The worktree is a normal git checkout. Use git directly for commits, pushes, merges, etc. The worktree is a normal git checkout.
## Project config
Optionally add a `sandlot.json` to your repo root:
```json
{
"vm": {
"cpus": 4,
"memory": "8GB",
"image": "ubuntu:24.04",
"mounts": { "/path/to/deps": "/deps" }
}
}
```

24
SPEC.md
View File

@ -1,18 +1,18 @@
# sandlot # sandlot
A CLI for branch-based development using git worktrees and Lima VMs. Each branch gets its own worktree and isolated VM. A CLI for branch-based development using git worktrees and Apple Container. Each branch gets its own worktree and isolated container.
## Concepts ## Concepts
**Sandlot** is a thin workflow layer over two things: git worktrees and [Lima](https://github.com/lima-vm/lima). The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, use git to merge and `sandlot rm` to clean up. **Sandlot** is a thin workflow layer over two things: git worktrees and [Apple Container](https://github.com/apple/container). The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, use git to merge and `sandlot rm` to clean up.
A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot new <branch>` and destroyed with `sandlot rm <branch>`. A sandlot **session** is a (worktree, container) pair tied to a branch. Sessions are created with `sandlot new <branch>` and destroyed with `sandlot rm <branch>`.
## Tech Stack ## Tech Stack
- [Bun](https://bun.sh) runtime - [Bun](https://bun.sh) runtime
- [Commander](https://github.com/tj/commander.js) for CLI parsing - [Commander](https://github.com/tj/commander.js) for CLI parsing
- [Lima](https://github.com/lima-vm/lima) for VMs - [Apple Container](https://github.com/apple/container) for containers
## Setup ## Setup
@ -21,7 +21,7 @@ A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are cr
- macOS on Apple Silicon - macOS on Apple Silicon
- [Bun](https://bun.sh) installed - [Bun](https://bun.sh) installed
- Git installed - Git installed
- [Lima](https://github.com/lima-vm/lima) installed (`brew install lima`) - [Apple Container](https://github.com/apple/container) installed (`brew install container`)
### Install ### Install
@ -42,8 +42,8 @@ Create a new session. This:
1. Checks out the branch if it exists (local or remote), or creates a new branch from current HEAD 1. Checks out the branch if it exists (local or remote), or creates a new branch from current HEAD
2. Creates a git worktree at `.sandlot/<branch>/` (relative to the repo root) 2. Creates a git worktree at `.sandlot/<branch>/` (relative to the repo root)
3. Boots a Lima VM mapped to that worktree 3. Boots a container mapped to that worktree
4. Drops you into the VM shell 4. Drops you into the container shell
``` ```
$ sandlot new fix-POST $ sandlot new fix-POST
@ -65,7 +65,7 @@ refactor-auth Stopped .sandlot/refactor-auth/
### `sandlot open <branch>` ### `sandlot open <branch>`
Re-enter an existing session's VM. If the VM is stopped, boots it first. Re-enter an existing session's container. If the container is stopped, starts it first.
``` ```
$ sandlot open fix-POST $ sandlot open fix-POST
@ -75,11 +75,11 @@ root@fix-POST:~#
### `sandlot stop <branch>` ### `sandlot stop <branch>`
Stop a session's VM without destroying it. The worktree and branch remain. Stop a session's container without destroying it. The worktree and branch remain.
### `sandlot rm <branch>` ### `sandlot rm <branch>`
Tear down a session. Stops the VM, removes the worktree, deletes the local branch. Does not touch the remote branch. Tear down a session. Stops the container, removes the worktree, deletes the local branch. Does not touch the remote branch.
## Configuration ## Configuration
@ -120,12 +120,12 @@ Sandlot tracks sessions in `.sandlot/state.json` at the repo root:
## Edge Cases ## Edge Cases
- **Stale VMs**: If a VM crashes, `sandlot open` detects the dead VM and reboots it. - **Stale containers**: If a container crashes, `sandlot open` detects the dead container and restarts it.
- **Multiple repos**: State is per-repo. No global daemon. - **Multiple repos**: State is per-repo. No global daemon.
- **Branch name conflicts**: If `.sandlot/<branch>/` already exists as a directory but the session state is missing, prompt to clean up or recover. - **Branch name conflicts**: If `.sandlot/<branch>/` already exists as a directory but the session state is missing, prompt to clean up or recover.
## Non-Goals ## Non-Goals
- Not a CI/CD tool. No pipelines, no test runners. - Not a CI/CD tool. No pipelines, no test runners.
- Not a replacement for git. All git state lives in the real repo. Sandlot manages worktrees and VMs only. - Not a replacement for git. All git state lives in the real repo. Sandlot manages worktrees and containers only.
- No multi-user collaboration features. This is a single-developer workflow tool. - No multi-user collaboration features. This is a single-developer workflow tool.

View File

@ -13,7 +13,7 @@ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
const program = new Command() const program = new Command()
program.name("sandlot").description("Branch-based development with git worktrees and Lima VMs").version(pkg.version) program.name("sandlot").description("Branch-based development with git worktrees and Apple Container").version(pkg.version)
// ── sandlot new <branch> ────────────────────────────────────────────── // ── sandlot new <branch> ──────────────────────────────────────────────
@ -33,13 +33,21 @@ program
} }
const spin = spinner("Creating worktree") const spin = spinner("Creating worktree")
try {
await git.createWorktree(branch, worktreeAbs, root) await git.createWorktree(branch, worktreeAbs, root)
await mkdir(join(root, '.sandlot'), { recursive: true }) await mkdir(join(root, '.sandlot'), { recursive: true })
await symlink(worktreeAbs, join(root, '.sandlot', branch)) await symlink(worktreeAbs, join(root, '.sandlot', branch))
spin.text = "Starting VM" spin.text = "Starting container"
await vm.ensure() await vm.ensure((msg) => { spin.text = msg })
spin.succeed("Session ready") spin.succeed("Session ready")
} catch (err) {
spin.fail(String((err as Error).message ?? err))
await git.removeWorktree(worktreeAbs, root).catch(() => {})
await git.deleteLocalBranch(branch, root).catch(() => {})
await unlink(join(root, '.sandlot', branch)).catch(() => {})
process.exit(1)
}
await state.setSession(root, { await state.setSession(root, {
branch, branch,
@ -93,8 +101,8 @@ program
process.exit(1) process.exit(1)
} }
const spin = spinner("Starting VM") const spin = spinner("Starting container")
await vm.ensure() await vm.ensure((msg) => { spin.text = msg })
spin.succeed("Session ready") spin.succeed("Session ready")
await vm.claude(session.worktree) await vm.claude(session.worktree)
@ -108,11 +116,16 @@ const closeAction = async (branch: string) => {
const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch) const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch)
await git.removeWorktree(worktreeAbs, root) await git.removeWorktree(worktreeAbs, root)
await unlink(join(root, '.sandlot', branch)).catch(() => {}) .then(() => console.log(`Removed worktree ${worktreeAbs}/`))
console.log(`Removed worktree ${worktreeAbs}/`) .catch((e) => console.warn(`Failed to remove worktree ${worktreeAbs}: ${e.message}`))
await unlink(join(root, '.sandlot', branch))
.then(() => console.log(`Removed symlink .sandlot/${branch}`))
.catch(() => {}) // symlink may not exist
await git.deleteLocalBranch(branch, root) await git.deleteLocalBranch(branch, root)
console.log(`Deleted local branch ${branch}`) .then(() => console.log(`Deleted local branch ${branch}`))
.catch((e) => console.warn(`Failed to delete local branch ${branch}: ${e.message}`))
if (session) { if (session) {
await state.removeSession(root, branch) await state.removeSession(root, branch)
@ -162,8 +175,8 @@ program
const worktreeAbs = session.worktree const worktreeAbs = session.worktree
const spin = spinner("Starting VM") const spin = spinner("Starting container")
await vm.ensure() await vm.ensure((msg) => { spin.text = msg })
spin.text = "Staging changes" spin.text = "Staging changes"
await vm.exec(worktreeAbs, "git add .") await vm.exec(worktreeAbs, "git add .")

127
src/vm.ts
View File

@ -1,98 +1,129 @@
import { $ } from "bun" import { $ } from "bun"
import { homedir } from "os" import { homedir } from "os"
const VM_NAME = "sandlot" const CONTAINER_NAME = "sandlot"
const USER = "ubuntu"
const CLAUDE_BIN = `/home/${USER}/.local/bin/claude`
function requireContainer(): void {
if (!Bun.which("container")) {
console.error('Apple Container is not installed. Install it with: brew install container')
process.exit(1)
}
}
/** 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
await $`container system start`.nothrow().quiet()
/** Ensure the sandlot VM exists and is running. Creates and provisions on first use. */
export async function ensure(): Promise<void> {
const s = await status() const s = await status()
if (s === "running") return if (s === "running") return
if (s === "stopped") { if (s === "stopped") {
await $`limactl start ${VM_NAME}`.quiet() await $`container start ${CONTAINER_NAME}`.quiet()
return return
} }
// Create from scratch // Create from scratch
const home = homedir() const home = homedir()
await $`limactl create --name=${VM_NAME} --mount=${home}/dev --mount=${home}/.sandlot:w template:ubuntu-24.04`.quiet() log?.("Pulling image & creating container")
await $`limactl start ${VM_NAME}`.quiet() await $`container run -d --name ${CONTAINER_NAME} -m 4G -v ${home}/dev:${home}/dev -v ${home}/.sandlot:${home}/.sandlot ubuntu:24.04 sleep infinity`.quiet()
// Provision // Provision (as root)
await $`limactl shell ${VM_NAME} -- bash -c "curl -fsSL https://claude.ai/install.sh | bash"`.quiet() log?.("Installing packages")
await $`container exec ${CONTAINER_NAME} bash -c ${"apt update && apt install -y curl git neofetch fish"}`.quiet()
await $`limactl shell ${VM_NAME} -- sudo apt install -y neofetch fish`.quiet() // Install Claude Code and configure (as ubuntu user)
log?.("Installing Claude Code")
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`.quiet()
// Configure git from host settings log?.("Configuring environment")
const gitName = (await $`git config user.name`.quiet().text()).trim() const gitName = (await $`git config user.name`.quiet().text()).trim()
const gitEmail = (await $`git config user.email`.quiet().text()).trim() const gitEmail = (await $`git config user.email`.quiet().text()).trim()
if (gitName) await $`limactl shell ${VM_NAME} -- git config --global user.name ${gitName}`.quiet() if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet()
if (gitEmail) await $`limactl shell ${VM_NAME} -- git config --global user.email ${gitEmail}`.quiet() if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet()
// Configure claude to use API key from host ~/.env (skip login prompt) // Configure claude to use API key from host ~/.env (skip login prompt)
const helperScript = `#!/bin/sh\n. ${home}/.env 2>/dev/null\necho "$ANTHROPIC_API_KEY"\n` let apiKey: string | undefined
const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true }) const envFile = Bun.file(`${home}/.env`)
const tmpHelper = `${home}/.sandlot-tmp-helper.sh` if (await envFile.exists()) {
const tmpSettings = `${home}/.sandlot-tmp-settings.json` const envContent = await envFile.text()
await Bun.write(tmpHelper, helperScript, { mode: 0o755 }) apiKey = envContent.match(/^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?/m)?.[1]
await Bun.write(tmpSettings, settingsJson) }
if (!apiKey) {
log?.("Warning: ANTHROPIC_API_KEY not found in ~/.env — claude will require manual login")
}
const settingsJson = JSON.stringify(apiKey
? { apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true }
: { skipDangerousModePermissionPrompt: true })
const claudeJson = JSON.stringify({ hasCompletedOnboarding: true }) const claudeJson = JSON.stringify({ hasCompletedOnboarding: true })
const tmpClaudeJson = `${home}/.sandlot-tmp-claude.json`
await Bun.write(tmpClaudeJson, claudeJson) // Write the helper script to a temp file and copy it in so the key
await $`limactl shell ${VM_NAME} -- bash -c "mkdir -p ~/.claude && cp ${tmpHelper} ~/.claude/api-key-helper.sh && chmod +x ~/.claude/api-key-helper.sh && cp ${tmpSettings} ~/.claude/settings.json && cp ${tmpClaudeJson} ~/.claude.json"`.quiet() // never appears in a process argument visible in `ps`.
await Bun.file(tmpHelper).delete() if (apiKey) {
await Bun.file(tmpSettings).delete() const tmp = `${home}/.sandlot/.api-key-helper.tmp`
await Bun.file(tmpClaudeJson).delete() await Bun.write(tmp, `#!/bin/sh\necho '${apiKey.replace(/'/g, "'\\''")}'\n`)
await $`chmod +x ${tmp}`.quiet()
await $`container exec --user ${USER} ${CONTAINER_NAME} mkdir -p /home/${USER}/.claude`.quiet()
await $`container cp ${tmp} ${CONTAINER_NAME}:/home/${USER}/.claude/api-key-helper.sh`.quiet()
await Bun.file(tmp).unlink()
}
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`
mkdir -p ~/.claude
echo '${settingsJson}' > ~/.claude/settings.json
echo '${claudeJson}' > ~/.claude.json
`}`.quiet()
} }
/** Check VM status. */ /** Check container status. */
export async function status(): Promise<"running" | "stopped" | "missing"> { export async function status(): Promise<"running" | "stopped" | "missing"> {
const result = await $`limactl list --json`.nothrow().quiet().text() const result = await $`container list --format json --all`.nothrow().quiet().text()
for (const line of result.trim().split("\n")) {
if (!line) continue
try { try {
const instance = JSON.parse(line) const containers = JSON.parse(result.trim())
if (instance.name === VM_NAME) { const container = containers.find((c: any) => c.configuration?.id === CONTAINER_NAME)
return instance.status?.toLowerCase() === "running" ? "running" : "stopped" if (!container) return "missing"
} return container.status?.toLowerCase() === "running" ? "running" : "stopped"
} catch { } catch {
continue
}
}
return "missing" return "missing"
}
} }
/** Launch claude in the VM at the given workdir. */ /** Launch claude in the container at the given workdir. */
export async function claude(workdir: string, prompt?: string): Promise<void> { export async function claude(workdir: string, prompt?: string): Promise<void> {
const args = ["limactl", "shell", `--workdir=${workdir}`, VM_NAME, "claude", "--dangerously-skip-permissions"] const args = ["container", "exec", "-it", "--user", USER, "--workdir", workdir, CONTAINER_NAME, CLAUDE_BIN, "--dangerously-skip-permissions"]
if (prompt) args.push(prompt) if (prompt) args.push(prompt)
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
} }
/** Open an interactive fish shell in the VM. */ /** Open an interactive fish shell in the container. */
export async function shell(): Promise<void> { export async function shell(): Promise<void> {
const proc = Bun.spawn( const proc = Bun.spawn(
["limactl", "shell", VM_NAME, "--", "env", "TERM=xterm-256color", "fish", "--login"], ["container", "exec", "-it", "--user", USER, CONTAINER_NAME, "env", "TERM=xterm-256color", "fish", "--login"],
{ stdin: "inherit", stdout: "inherit", stderr: "inherit" }, { stdin: "inherit", stdout: "inherit", stderr: "inherit" },
) )
await proc.exited await proc.exited
} }
/** Run neofetch in the VM. */ /** Run neofetch in the container. */
export async function info(): Promise<void> { export async function info(): Promise<void> {
const proc = Bun.spawn( const proc = Bun.spawn(
["limactl", "shell", VM_NAME, "--", "neofetch"], ["container", "exec", "--user", USER, CONTAINER_NAME, "neofetch"],
{ stdin: "inherit", stdout: "inherit", stderr: "inherit" }, { stdin: "inherit", stdout: "inherit", stderr: "inherit" },
) )
await proc.exited await proc.exited
} }
/** Run a bash command in the VM 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 $`limactl shell --workdir=${workdir} ${VM_NAME} -- bash -c ${command}`.nothrow().quiet() const result = await $`container exec --user ${USER} --workdir ${workdir} ${CONTAINER_NAME} bash -c ${"export PATH=$HOME/.local/bin:$PATH; " + command}`.nothrow().quiet()
return { return {
exitCode: result.exitCode, exitCode: result.exitCode,
stdout: result.stdout.toString().trim(), stdout: result.stdout.toString().trim(),
@ -100,13 +131,13 @@ export async function exec(workdir: string, command: string): Promise<{ exitCode
} }
} }
/** Stop the VM. */ /** Stop the container. */
export async function stop(): Promise<void> { export async function stop(): Promise<void> {
await $`limactl stop ${VM_NAME}`.nothrow().quiet() await $`container stop ${CONTAINER_NAME}`.nothrow().quiet()
} }
/** Stop and delete the VM. */ /** Stop and delete the container. */
export async function destroy(): Promise<void> { export async function destroy(): Promise<void> {
await stop() await stop()
await $`limactl delete ${VM_NAME}`.nothrow().quiet() await $`container delete ${CONTAINER_NAME}`.nothrow().quiet()
} }