191 lines
7.7 KiB
TypeScript
191 lines
7.7 KiB
TypeScript
import { $ } from "bun"
|
|
import { homedir } from "os"
|
|
|
|
const CONTAINER_NAME = "sandlot"
|
|
const USER = "ubuntu"
|
|
const CLAUDE_BIN = `/home/${USER}/.local/bin/claude`
|
|
|
|
/** Translate a host path to its corresponding container path. */
|
|
export function containerPath(hostPath: string): string {
|
|
const home = homedir()
|
|
if (hostPath.startsWith(`${home}/.sandlot`)) {
|
|
return "/sandlot" + hostPath.slice(`${home}/.sandlot`.length)
|
|
}
|
|
if (hostPath.startsWith(`${home}/dev`)) {
|
|
return "/host" + hostPath.slice(`${home}/dev`.length)
|
|
}
|
|
return hostPath
|
|
}
|
|
|
|
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()
|
|
|
|
const s = await status()
|
|
if (s === "running") return
|
|
|
|
if (s === "stopped") {
|
|
await $`container start ${CONTAINER_NAME}`.quiet()
|
|
return
|
|
}
|
|
|
|
// Create from scratch
|
|
const home = homedir()
|
|
log?.("Pulling image & creating container")
|
|
await $`container run -d --name ${CONTAINER_NAME} -m 4G --mount type=bind,source=${home}/dev,target=/host,readonly -v ${home}/.sandlot:/sandlot ubuntu:24.04 sleep infinity`.quiet()
|
|
|
|
// Provision (as root)
|
|
log?.("Installing packages")
|
|
await $`container exec ${CONTAINER_NAME} bash -c ${"apt update && apt install -y curl git neofetch fish"}`.quiet()
|
|
|
|
// Create symlinks so git worktree absolute paths from the host resolve inside the container
|
|
await $`container exec ${CONTAINER_NAME} bash -c ${`mkdir -p '${home}' && ln -s /host '${home}/dev' && ln -s /sandlot '${home}/.sandlot'`}`.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()
|
|
|
|
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 $`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)
|
|
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, effortCalloutDismissed: 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} bash -c ${"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.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 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, opts?: { prompt?: string; print?: string }): Promise<string | void> {
|
|
const cwd = containerPath(workdir)
|
|
const systemPrompt = [
|
|
"You are running inside a sandlot container (Apple Container, ubuntu:24.04).",
|
|
`Your working directory is ${cwd}, a git worktree managed by sandlot.`,
|
|
"The host's ~/dev is mounted read-only at /host.",
|
|
"The host's ~/.sandlot is mounted at /sandlot.",
|
|
].join("\n")
|
|
|
|
const term = process.env.TERM || "xterm-256color"
|
|
const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", `TERM=${term}`, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--append-system-prompt", systemPrompt]
|
|
if (opts?.print) args.push("-p", opts.print)
|
|
else if (opts?.prompt) args.push(opts.prompt)
|
|
|
|
if (opts?.print) {
|
|
const proc = Bun.spawn(args, { stdin: "inherit", stdout: "pipe", stderr: "inherit" })
|
|
const output = await new Response(proc.stdout).text()
|
|
await proc.exited
|
|
return output
|
|
}
|
|
|
|
const proc = Bun.spawn(args, { stdin: "inherit", stdout: "inherit", stderr: "inherit" })
|
|
await proc.exited
|
|
}
|
|
|
|
/** Open an interactive fish shell in the container. */
|
|
export async function shell(): Promise<void> {
|
|
const proc = Bun.spawn(
|
|
["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 container. */
|
|
export async function info(): Promise<void> {
|
|
const proc = Bun.spawn(
|
|
["container", "exec", "--user", USER, CONTAINER_NAME, "neofetch"],
|
|
{ stdin: "inherit", stdout: "inherit", stderr: "inherit" },
|
|
)
|
|
await proc.exited
|
|
}
|
|
|
|
/** 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 $`container exec --user ${USER} --workdir ${containerPath(workdir)} ${CONTAINER_NAME} bash -c ${"export PATH=$HOME/.local/bin:$PATH; " + command}`.nothrow().quiet()
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.toString().trim(),
|
|
stderr: result.stderr.toString().trim(),
|
|
}
|
|
}
|
|
|
|
/** Get host paths of worktrees where claude is currently running in the container. */
|
|
export async function activeWorktrees(): Promise<string[]> {
|
|
const s = await status()
|
|
if (s !== "running") return []
|
|
|
|
const result = await $`container exec ${CONTAINER_NAME} bash -c ${'for pid in $(pgrep -x claude 2>/dev/null); do readlink /proc/$pid/cwd 2>/dev/null; done'}`.nothrow().quiet().text()
|
|
|
|
const home = homedir()
|
|
return result.trim().split("\n").filter(Boolean).map(p => {
|
|
if (p.startsWith("/sandlot")) return `${home}/.sandlot${p.slice("/sandlot".length)}`
|
|
if (p.startsWith("/host")) return `${home}/dev${p.slice("/host".length)}`
|
|
return p
|
|
})
|
|
}
|
|
|
|
/** Stop the container. */
|
|
export async function stop(): Promise<void> {
|
|
await $`container stop ${CONTAINER_NAME}`.nothrow().quiet()
|
|
}
|
|
|
|
/** Stop and delete the container. */
|
|
export async function destroy(): Promise<void> {
|
|
await stop()
|
|
await $`container delete ${CONTAINER_NAME}`.nothrow().quiet()
|
|
}
|