diff --git a/README.md b/README.md index 00aefec..b9da9fe 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ # 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 - macOS on Apple Silicon - [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 +### 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 ```bash @@ -28,30 +32,15 @@ Run all commands from inside a cloned git repo. 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 ```bash sandlot list # show all sessions -sandlot open # re-enter a session's VM -sandlot stop # stop a VM without destroying it -sandlot rm # tear down session (VM, worktree, local branch) +sandlot open # re-enter a session +sandlot stop # stop a container without destroying it +sandlot rm # tear down session (container, worktree, local branch) ``` 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" } - } -} -``` diff --git a/SPEC.md b/SPEC.md index 45c9e25..7362943 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,18 +1,18 @@ # 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 -**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 ` and destroyed with `sandlot rm `. +A sandlot **session** is a (worktree, container) pair tied to a branch. Sessions are created with `sandlot new ` and destroyed with `sandlot rm `. ## Tech Stack - [Bun](https://bun.sh) runtime - [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 @@ -21,7 +21,7 @@ A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are cr - macOS on Apple Silicon - [Bun](https://bun.sh) 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 @@ -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 2. Creates a git worktree at `.sandlot//` (relative to the repo root) -3. Boots a Lima VM mapped to that worktree -4. Drops you into the VM shell +3. Boots a container mapped to that worktree +4. Drops you into the container shell ``` $ sandlot new fix-POST @@ -65,7 +65,7 @@ refactor-auth Stopped .sandlot/refactor-auth/ ### `sandlot open ` -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 @@ -75,11 +75,11 @@ root@fix-POST:~# ### `sandlot stop ` -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 ` -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 @@ -120,12 +120,12 @@ Sandlot tracks sessions in `.sandlot/state.json` at the repo root: ## 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. - **Branch name conflicts**: If `.sandlot//` already exists as a directory but the session state is missing, prompt to clean up or recover. ## Non-Goals - 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. diff --git a/src/cli.ts b/src/cli.ts index a0632d2..921f528 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,7 @@ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json() 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 ────────────────────────────────────────────── @@ -33,13 +33,21 @@ program } const spin = spinner("Creating worktree") - await git.createWorktree(branch, worktreeAbs, root) - await mkdir(join(root, '.sandlot'), { recursive: true }) - await symlink(worktreeAbs, join(root, '.sandlot', branch)) + try { + await git.createWorktree(branch, worktreeAbs, root) + await mkdir(join(root, '.sandlot'), { recursive: true }) + await symlink(worktreeAbs, join(root, '.sandlot', branch)) - spin.text = "Starting VM" - await vm.ensure() - spin.succeed("Session ready") + spin.text = "Starting container" + await vm.ensure((msg) => { spin.text = msg }) + 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, { branch, @@ -93,8 +101,8 @@ program process.exit(1) } - const spin = spinner("Starting VM") - await vm.ensure() + const spin = spinner("Starting container") + await vm.ensure((msg) => { spin.text = msg }) spin.succeed("Session ready") await vm.claude(session.worktree) @@ -108,11 +116,16 @@ const closeAction = async (branch: string) => { const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch) await git.removeWorktree(worktreeAbs, root) - await unlink(join(root, '.sandlot', branch)).catch(() => {}) - console.log(`Removed worktree ${worktreeAbs}/`) + .then(() => 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) - 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) { await state.removeSession(root, branch) @@ -162,8 +175,8 @@ program const worktreeAbs = session.worktree - const spin = spinner("Starting VM") - await vm.ensure() + const spin = spinner("Starting container") + await vm.ensure((msg) => { spin.text = msg }) spin.text = "Staging changes" await vm.exec(worktreeAbs, "git add .") diff --git a/src/vm.ts b/src/vm.ts index 60f5e57..6086254 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -1,98 +1,129 @@ import { $ } from "bun" 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 { + 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 { const s = await status() if (s === "running") return if (s === "stopped") { - await $`limactl start ${VM_NAME}`.quiet() + await $`container start ${CONTAINER_NAME}`.quiet() return } // Create from scratch const home = homedir() - await $`limactl create --name=${VM_NAME} --mount=${home}/dev --mount=${home}/.sandlot:w template:ubuntu-24.04`.quiet() - await $`limactl start ${VM_NAME}`.quiet() + log?.("Pulling image & creating container") + 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 - await $`limactl shell ${VM_NAME} -- bash -c "curl -fsSL https://claude.ai/install.sh | bash"`.quiet() + // Provision (as root) + 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 gitEmail = (await $`git config user.email`.quiet().text()).trim() - if (gitName) await $`limactl shell ${VM_NAME} -- git config --global user.name ${gitName}`.quiet() - if (gitEmail) await $`limactl shell ${VM_NAME} -- git config --global user.email ${gitEmail}`.quiet() + 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() // 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` - const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true }) - const tmpHelper = `${home}/.sandlot-tmp-helper.sh` - const tmpSettings = `${home}/.sandlot-tmp-settings.json` - await Bun.write(tmpHelper, helperScript, { mode: 0o755 }) - await Bun.write(tmpSettings, settingsJson) - const claudeJson = JSON.stringify({ hasCompletedOnboarding: true }) - const tmpClaudeJson = `${home}/.sandlot-tmp-claude.json` - await Bun.write(tmpClaudeJson, claudeJson) - 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() - await Bun.file(tmpHelper).delete() - await Bun.file(tmpSettings).delete() - await Bun.file(tmpClaudeJson).delete() -} - -/** Check VM status. */ -export async function status(): Promise<"running" | "stopped" | "missing"> { - const result = await $`limactl list --json`.nothrow().quiet().text() - - for (const line of result.trim().split("\n")) { - if (!line) continue - try { - const instance = JSON.parse(line) - if (instance.name === VM_NAME) { - return instance.status?.toLowerCase() === "running" ? "running" : "stopped" - } - } catch { - continue - } + let apiKey: string | undefined + const envFile = Bun.file(`${home}/.env`) + if (await envFile.exists()) { + const envContent = await envFile.text() + apiKey = envContent.match(/^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?/m)?.[1] } - return "missing" + 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 }) + + // 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} 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() } -/** Launch claude in the VM at the given workdir. */ +/** 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, prompt?: string): Promise { - 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) const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }) await proc.exited } -/** Open an interactive fish shell in the VM. */ +/** Open an interactive fish shell in the container. */ export async function shell(): Promise { 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" }, ) await proc.exited } -/** Run neofetch in the VM. */ +/** Run neofetch in the container. */ export async function info(): Promise { const proc = Bun.spawn( - ["limactl", "shell", VM_NAME, "--", "neofetch"], + ["container", "exec", "--user", USER, CONTAINER_NAME, "neofetch"], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }, ) 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 }> { - 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 { exitCode: result.exitCode, 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 { - 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 { await stop() - await $`limactl delete ${VM_NAME}`.nothrow().quiet() + await $`container delete ${CONTAINER_NAME}`.nothrow().quiet() }