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.
150 lines
4.9 KiB
TypeScript
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")
|
|
})
|
|
}
|