sandlot/src/commands/vm.ts
Chris Wanstrath d402a3f980 Derive repo name from repoRoot instead of storing it separately
Remove the redundant `repo` field from GlobalSession and compute it
via basename(repoRoot) at render time. Also fix prompt truncation
when terminal is extremely narrow, and simplify backfillPrompts to
avoid an intermediate array allocation.
2026-03-23 21:20:44 -07:00

150 lines
4.9 KiB
TypeScript

import { basename } from "path"
import type { Command } from "commander"
import * as vm from "../vm.ts"
import * as git from "../git.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { reset, dim, green, yellow, cyan, red } from "../fmt.ts"
export function register(program: Command) {
const vmCmd = program.command("vm").description("Manage the sandlot VM")
vmCmd
.command("create")
.description("Create and provision the VM")
.action(async () => {
const spin = spinner("Creating VM")
try {
await vm.create((msg) => { spin.text = msg })
spin.succeed("VM created")
} catch (err) {
spin.fail(String((err as Error).message ?? err))
process.exit(1)
}
})
vmCmd
.command("start")
.description("Start the VM")
.action(async () => {
try {
await vm.start()
console.log("✔ VM started")
} catch (err) {
console.error(`${(err as Error).message ?? err}`)
process.exit(1)
}
})
vmCmd
.command("shell")
.description("Open a shell in the VM")
.action(async () => {
await vm.ensure()
await vm.shell()
})
vmCmd
.command("status")
.description("Show VM status and all sessions across repos")
.option("--json", "Output as JSON")
.action(async (opts: { json?: boolean }) => {
const s = await vm.status()
const sessions = await state.loadAll()
if (opts.json) {
console.log(JSON.stringify({ vm: s, sessions }, null, 2))
return
}
const statusColors: Record<string, string> = { running: green, stopped: yellow, missing: red }
console.log(`${dim}VM:${reset} ${statusColors[s] ?? ""}${s}${reset}`)
if (sessions.length === 0) {
console.log(`\n${dim}No active sessions.${reset}`)
return
}
// Determine status for each session in parallel
const statusEntries = await Promise.all(
sessions.map(async (sess): Promise<[string, string]> => {
const key = `${basename(sess.repoRoot)}/${sess.branch}`
try {
if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
if (await git.isDirty(sess.worktree)) return [key, "dirty"]
if (await git.hasNewCommits(sess.worktree)) return [key, "saved"]
} catch {}
return [key, "idle"]
})
)
const statuses = Object.fromEntries(statusEntries)
const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}` }
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green }
const repoWidth = Math.max(4, ...sessions.map(sess => basename(sess.repoRoot).length))
const branchWidth = Math.max(6, ...sessions.map(sess => sess.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = repoWidth + branchWidth + 6
console.log(`\n ${dim}${"REPO".padEnd(repoWidth)} ${"BRANCH".padEnd(branchWidth)} PROMPT${reset}`)
for (const sess of sessions) {
const prompt = (sess.prompt ?? "").split("\n")[0]
const key = `${basename(sess.repoRoot)}/${sess.branch}`
const status = statuses[key]
const icon = icons[status]
const bc = branchColors[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
console.log(`${icon} ${dim}${basename(sess.repoRoot).padEnd(repoWidth)}${reset} ${bc}${sess.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
}
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`)
})
vmCmd
.command("info")
.description("Show VM system info (via neofetch)")
.action(async () => {
await vm.ensure()
await vm.neofetch()
})
vmCmd
.command("stop")
.description("Stop the VM")
.action(async () => {
const spin = spinner("Stopping VM")
try {
await vm.stop()
spin.succeed("VM stopped")
} catch (err) {
spin.fail(String((err as Error).message ?? err))
process.exit(1)
}
})
vmCmd
.command("destroy")
.description("Stop and delete the VM")
.action(async () => {
const spin = spinner("Destroying VM")
try {
await vm.destroy()
spin.succeed("VM destroyed")
} catch (err) {
spin.fail(String((err as Error).message ?? err))
process.exit(1)
}
})
vmCmd
.command("uncache")
.description("Clear the package cache (next create will re-download)")
.action(async () => {
const had = await vm.clearCache()
console.log(had ? "✔ Package cache cleared" : "No cache to clear")
})
}