use lima
This commit is contained in:
parent
6f6d921c54
commit
45bb19a8d9
|
|
@ -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
|
||||
|
||||
|
|
|
|||
16
SPEC.md
16
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 <branch>` and destroyed with `sandlot rm <branch>`.
|
||||
|
||||
|
|
@ -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/<branch>/` (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 <branch>`
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
14
src/cli.ts
14
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 <branch> ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
127
src/vm.ts
127
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<string> {
|
||||
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<void> {
|
||||
await $`container stop ${vmId}`.nothrow().quiet()
|
||||
/** Start a stopped Lima instance. */
|
||||
export async function start(name: string): Promise<void> {
|
||||
await $`limactl start ${name}`
|
||||
}
|
||||
|
||||
/** Remove a container. */
|
||||
export async function rm(vmId: string): Promise<void> {
|
||||
await $`container rm ${vmId}`.nothrow().quiet()
|
||||
/** Stop a running Lima instance. */
|
||||
export async function stop(name: string): Promise<void> {
|
||||
await $`limactl stop ${name}`.nothrow().quiet()
|
||||
}
|
||||
|
||||
/** Stop and remove a container. */
|
||||
export async function destroy(vmId: string): Promise<void> {
|
||||
await stop(vmId)
|
||||
await rm(vmId)
|
||||
/** Delete a Lima instance. */
|
||||
export async function rm(name: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const proc = Bun.spawn(["container", "exec", "-it", vmId, "/bin/bash"], {
|
||||
/** Exec into a Lima instance shell interactively. */
|
||||
export async function shell(name: string): Promise<void> {
|
||||
const proc = Bun.spawn(["limactl", "shell", name], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
|
|
@ -68,19 +99,23 @@ export async function shell(vmId: string): Promise<void> {
|
|||
await proc.exited
|
||||
}
|
||||
|
||||
/** List all containers with their names and statuses. */
|
||||
export async function list(): Promise<Array<{ id: string; name: string; status: string }>> {
|
||||
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<Array<{ name: string; status: string }>> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user