apple container
This commit is contained in:
parent
f90cd3a38d
commit
0f18f1e5ff
31
README.md
31
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 <branch> # re-enter a session's VM
|
||||
sandlot stop <branch> # stop a VM without destroying it
|
||||
sandlot rm <branch> # tear down session (VM, worktree, local branch)
|
||||
sandlot open <branch> # re-enter a session
|
||||
sandlot stop <branch> # stop a container without destroying it
|
||||
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.
|
||||
|
||||
## 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
24
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 <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
|
||||
|
||||
- [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/<branch>/` (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 <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
|
||||
|
|
@ -75,11 +75,11 @@ root@fix-POST:~#
|
|||
|
||||
### `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>`
|
||||
|
||||
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/<branch>/` 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.
|
||||
|
|
|
|||
33
src/cli.ts
33
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 <branch> ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -33,13 +33,21 @@ program
|
|||
}
|
||||
|
||||
const spin = spinner("Creating worktree")
|
||||
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.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 .")
|
||||
|
|
|
|||
127
src/vm.ts
127
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<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()
|
||||
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)
|
||||
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]
|
||||
}
|
||||
|
||||
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 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()
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
/** Check VM status. */
|
||||
/** Check container status. */
|
||||
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 {
|
||||
const instance = JSON.parse(line)
|
||||
if (instance.name === VM_NAME) {
|
||||
return instance.status?.toLowerCase() === "running" ? "running" : "stopped"
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<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> {
|
||||
await stop()
|
||||
await $`limactl delete ${VM_NAME}`.nothrow().quiet()
|
||||
await $`container delete ${CONTAINER_NAME}`.nothrow().quiet()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user