diff --git a/README.md b/README.md index 2aa9cef..00aefec 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # sandlot -A CLI for branch-based development using git worktrees and [Apple containers](https://github.com/apple/container). Each branch gets its own worktree and isolated VM. +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. ## Prerequisites - macOS on Apple Silicon - [Bun](https://bun.sh) -- [container](https://github.com/apple/container) installed and on PATH +- [Lima](https://github.com/lima-vm/lima) (`brew install lima`) - Git ## Install @@ -28,7 +28,7 @@ Run all commands from inside a cloned git repo. sandlot new fix-POST ``` -Creates a worktree at `.sandlot/fix-POST/`, boots a VM mapped to it, and drops you into a shell. +Creates a worktree at `.sandlot/fix-POST/`, boots a Lima VM mapped to it, and drops you into a shell. ### Other commands diff --git a/SPEC.md b/SPEC.md index 334e144..45c9e25 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,10 +1,10 @@ # sandlot -A CLI for branch-based development using git worktrees and Apple containers. Each branch gets its own worktree and isolated VM. +A CLI for branch-based development using git worktrees and Lima VMs. Each branch gets its own worktree and isolated VM. ## Concepts -**Sandlot** is a thin workflow layer over two things: git worktrees and [Apple containers](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. +**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. A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot new ` and destroyed with `sandlot rm `. @@ -12,7 +12,7 @@ A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are cr - [Bun](https://bun.sh) runtime - [Commander](https://github.com/tj/commander.js) for CLI parsing -- [Apple container](https://github.com/apple/container) for VMs +- [Lima](https://github.com/lima-vm/lima) for VMs ## 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 -- [container](https://github.com/apple/container) installed and available on PATH +- [Lima](https://github.com/lima-vm/lima) installed (`brew install lima`) ### Install @@ -42,7 +42,7 @@ 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 an Apple container VM mapped to that worktree +3. Boots a Lima VM mapped to that worktree 4. Drops you into the VM shell ``` @@ -59,8 +59,8 @@ Show all active sessions. ``` $ sandlot list BRANCH VM STATUS WORKTREE -fix-POST running .sandlot/fix-POST/ -refactor-auth stopped .sandlot/refactor-auth/ +fix-POST Running .sandlot/fix-POST/ +refactor-auth Stopped .sandlot/refactor-auth/ ``` ### `sandlot open ` @@ -108,7 +108,7 @@ Sandlot tracks sessions in `.sandlot/state.json` at the repo root: "fix-POST": { "branch": "fix-POST", "worktree": ".sandlot/fix-POST", - "vm_id": "container-abc123", + "vm_id": "sandlot-fix-POST", "created_at": "2026-02-16T10:30:00Z", "status": "running" } diff --git a/src/cli.ts b/src/cli.ts index 020630c..14802a8 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,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 Apple containers").version(pkg.version) +program.name("sandlot").description("Branch-based development with git worktrees and Lima VMs").version(pkg.version) // ── sandlot new ────────────────────────────────────────────── @@ -37,7 +37,8 @@ program await git.createWorktree(branch, worktreeAbs, root) console.log("Booting VM...") - const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm) + const name = vm.limaName(branch) + const vmId = await vm.boot(name, worktreeAbs, config.vm) await state.setSession(root, { branch, @@ -108,17 +109,12 @@ program // Stale VM, reboot console.log("VM is gone. Rebooting...") const worktreeAbs = join(root, session.worktree) - const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm) + const vmId = await vm.boot(vm.limaName(branch), worktreeAbs, config.vm) await state.setSession(root, { ...session, vm_id: vmId, status: "running" }) await vm.shell(vmId) } else if (vmStatus === "stopped") { console.log("Booting VM...") - // Need to start the existing container - const proc = Bun.spawn(["container", "start", session.vm_id], { - stdout: "inherit", - stderr: "inherit", - }) - await proc.exited + await vm.start(session.vm_id) await state.setSession(root, { ...session, status: "running" }) await vm.shell(session.vm_id) } else { diff --git a/src/vm.ts b/src/vm.ts index 5357450..65ea5ea 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -1,66 +1,97 @@ import { $ } from "bun" import type { VmConfig } from "./config.ts" -/** Boot a container VM mapped to a worktree directory. Returns the container ID. */ +/** Sanitize a branch name into a valid Lima instance name. */ +export function limaName(branch: string): string { + return "sandlot-" + branch.replace(/[^a-zA-Z0-9-]/g, "-") +} + +/** Map a container image string (e.g. "ubuntu:24.04") to a Lima template. */ +function mapImageToTemplate(image?: string): string { + const img = image ?? "ubuntu:24.04" + // "ubuntu:24.04" → "template://ubuntu-24.04" + const normalized = img.replace(":", "-") + return `template://${normalized}` +} + +/** Create and start a Lima VM mapped to a worktree directory. Returns the instance name. */ export async function boot( name: string, worktreePath: string, config?: VmConfig ): Promise { - const args: string[] = ["container", "run", "--name", name] + const args: string[] = ["limactl", "create", `--name=${name}`] - if (config?.cpus) args.push("--cpus", String(config.cpus)) - if (config?.memory) args.push("--memory", config.memory) + if (config?.cpus) args.push(`--cpus=${config.cpus}`) + if (config?.memory) args.push(`--memory=${config.memory}`) // Mount worktree as /root/work - args.push("--mount", `type=virtiofs,source=${worktreePath},target=/root/work`) + args.push(`--mount-writable=${worktreePath}:/root/work`) // Additional mounts from config if (config?.mounts) { for (const [source, target] of Object.entries(config.mounts)) { - args.push("--mount", `type=virtiofs,source=${source},target=${target}`) + args.push(`--mount-writable=${source}:${target}`) } } - const image = config?.image ?? "ubuntu:24.04" - args.push("-d", image) + const template = mapImageToTemplate(config?.image) + args.push(template) - const result = await $`${args}`.text() - return result.trim() + // Don't quiet create so user sees download progress + await $`${args}` + + await $`limactl start ${name}`.quiet() + + return name } -/** Stop a running container. */ -export async function stop(vmId: string): Promise { - await $`container stop ${vmId}`.nothrow().quiet() +/** Start a stopped Lima instance. */ +export async function start(name: string): Promise { + await $`limactl start ${name}` } -/** Remove a container. */ -export async function rm(vmId: string): Promise { - await $`container rm ${vmId}`.nothrow().quiet() +/** Stop a running Lima instance. */ +export async function stop(name: string): Promise { + await $`limactl stop ${name}`.nothrow().quiet() } -/** Stop and remove a container. */ -export async function destroy(vmId: string): Promise { - await stop(vmId) - await rm(vmId) +/** Delete a Lima instance. */ +export async function rm(name: string): Promise { + await $`limactl delete ${name}`.nothrow().quiet() } -/** Check if a container is running. Returns "running", "stopped", or "missing". */ -export async function status(vmId: string): Promise<"running" | "stopped" | "missing"> { - const result = await $`container inspect ${vmId} --format '{{.State.Status}}'` - .nothrow() - .quiet() - .text() +/** Stop and delete a Lima instance. */ +export async function destroy(name: string): Promise { + await stop(name) + await rm(name) +} + +/** Check if a Lima instance is running. Returns "running", "stopped", or "missing". */ +export async function status(name: string): Promise<"running" | "stopped" | "missing"> { + const result = await $`limactl list --json`.nothrow().quiet().text() + + // limactl list --json outputs JSONL (one JSON object per line) + for (const line of result.trim().split("\n")) { + if (!line) continue + try { + const instance = JSON.parse(line) + if (instance.name === name) { + const s = instance.status?.toLowerCase() + if (s === "running") return "running" + return "stopped" + } + } catch { + continue + } + } - const state = result.trim().replace(/'/g, "") - if (state.includes("running")) return "running" - if (state.includes("exited") || state.includes("stopped") || state.includes("created")) return "stopped" return "missing" } -/** Exec into a container shell interactively. */ -export async function shell(vmId: string): Promise { - const proc = Bun.spawn(["container", "exec", "-it", vmId, "/bin/bash"], { +/** Exec into a Lima instance shell interactively. */ +export async function shell(name: string): Promise { + const proc = Bun.spawn(["limactl", "shell", name], { stdin: "inherit", stdout: "inherit", stderr: "inherit", @@ -68,19 +99,23 @@ export async function shell(vmId: string): Promise { await proc.exited } -/** List all containers with their names and statuses. */ -export async function list(): Promise> { - const result = await $`container ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'` - .nothrow() - .quiet() - .text() +/** List all sandlot Lima instances with their names and statuses. */ +export async function list(): Promise> { + const result = await $`limactl list --json`.nothrow().quiet().text() - return result - .trim() - .split("\n") - .filter(Boolean) - .map((line) => { - const [id, name, status] = line.replace(/'/g, "").split("\t") - return { id, name, status } - }) + const instances: Array<{ name: string; status: string }> = [] + + for (const line of result.trim().split("\n")) { + if (!line) continue + try { + const instance = JSON.parse(line) + if (instance.name?.startsWith("sandlot-")) { + instances.push({ name: instance.name, status: instance.status ?? "Unknown" }) + } + } catch { + continue + } + } + + return instances }