Add config command and replace project registry with filesystem discovery
The global registry (registry.json, locking, scan-and-register) was unnecessary complexity — we can discover repos by scanning ~/.sandlot/ worktree .git pointers. This also moves the --add/--remove flags off `list` into a proper `config` subcommand for managing settings like VM memory. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
491c6d7eab
commit
99b0fc0f12
|
|
@ -4,6 +4,7 @@ import { Command, Option } from "commander"
|
||||||
import { yellow, reset } from "./fmt.ts"
|
import { yellow, reset } from "./fmt.ts"
|
||||||
import * as git from "./git.ts"
|
import * as git from "./git.ts"
|
||||||
import * as state from "./state.ts"
|
import * as state from "./state.ts"
|
||||||
|
import { action as configAction } from "./commands/config.ts"
|
||||||
import { action as newAction } from "./commands/new.ts"
|
import { action as newAction } from "./commands/new.ts"
|
||||||
import { action as listAction } from "./commands/list.ts"
|
import { action as listAction } from "./commands/list.ts"
|
||||||
import { action as openAction } from "./commands/open.ts"
|
import { action as openAction } from "./commands/open.ts"
|
||||||
|
|
@ -53,8 +54,6 @@ program
|
||||||
.description("Show all active sessions")
|
.description("Show all active sessions")
|
||||||
.option("--json", "Output as JSON")
|
.option("--json", "Output as JSON")
|
||||||
.option("-a, --all", "Show sessions across all projects")
|
.option("-a, --all", "Show sessions across all projects")
|
||||||
.option("--add <dir>", "Scan a directory for sandlot projects and register them")
|
|
||||||
.option("-r, --remove <dir>", "Remove a project from the registry")
|
|
||||||
.action(listAction)
|
.action(listAction)
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import { stat } from "fs/promises"
|
||||||
import * as git from "../git.ts"
|
import * as git from "../git.ts"
|
||||||
import * as vm from "../vm.ts"
|
import * as vm from "../vm.ts"
|
||||||
import * as state from "../state.ts"
|
import * as state from "../state.ts"
|
||||||
import { die, reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
|
import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
|
||||||
|
|
||||||
// ── Shared rendering ─────────────────────────────────────────────────
|
// ── Rendering ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const styles: Record<string, { icon: string; color: string }> = {
|
const styles: Record<string, { icon: string; color: string }> = {
|
||||||
idle: { icon: `${dim}◯${reset}`, color: dim },
|
idle: { icon: `${dim}◯${reset}`, color: dim },
|
||||||
|
|
@ -31,16 +31,12 @@ function renderSessions(
|
||||||
const status = statusMap.get(s) ?? "idle"
|
const status = statusMap.get(s) ?? "idle"
|
||||||
const { icon, color: bc } = styles[status]
|
const { icon, color: bc } = styles[status]
|
||||||
const maxPrompt = cols - prefixWidth
|
const maxPrompt = cols - prefixWidth
|
||||||
const truncated = maxPrompt < 1 ? "" : prompt.length <= maxPrompt ? prompt : maxPrompt > 3 ? prompt.slice(0, maxPrompt - 3) + "..." : prompt.slice(0, maxPrompt)
|
const truncated = prompt.length <= maxPrompt ? prompt : prompt.slice(0, maxPrompt - 3) + "..."
|
||||||
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
|
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLegend() {
|
// ── Status resolution ────────────────────────────────────────────────
|
||||||
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared logic ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function resolveStatus(
|
async function resolveStatus(
|
||||||
s: { branch: string; worktree: string; in_review?: boolean },
|
s: { branch: string; worktree: string; in_review?: boolean },
|
||||||
|
|
@ -101,13 +97,9 @@ async function backfillPrompts(sessions: { worktree: string; prompt?: string }[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Commands ─────────────────────────────────────────────────────────
|
// ── Command ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function action(opts: { json?: boolean; all?: boolean; add?: string; remove?: string }) {
|
export async function action(opts: { json?: boolean; all?: boolean }) {
|
||||||
if (opts.add) return actionAdd(opts.add)
|
|
||||||
if (opts.remove) return actionRemove(opts.remove)
|
|
||||||
|
|
||||||
// Load sessions with repoRoot attached
|
|
||||||
let sessions: state.GlobalSession[]
|
let sessions: state.GlobalSession[]
|
||||||
if (opts.all) {
|
if (opts.all) {
|
||||||
sessions = await state.loadAll()
|
sessions = await state.loadAll()
|
||||||
|
|
@ -128,7 +120,7 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
|
||||||
|
|
||||||
const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning)))
|
const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning)))
|
||||||
const statusMap = new Map(sessions.map((s, i) => [s, results[i]]))
|
const statusMap = new Map(sessions.map((s, i) => [s, results[i]]))
|
||||||
if (vmRunning) await clearStaleReviews(sessions, results)
|
await clearStaleReviews(sessions, results)
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
const withStatus = sessions.map(s => ({ ...s, status: statusMap.get(s) ?? "idle" }))
|
const withStatus = sessions.map(s => ({ ...s, status: statusMap.get(s) ?? "idle" }))
|
||||||
|
|
@ -146,38 +138,6 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
|
||||||
renderSessions(sessions, statusMap)
|
renderSessions(sessions, statusMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderLegend()
|
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`)
|
||||||
if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`)
|
if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function actionAdd(dir: string) {
|
|
||||||
let registered: string[]
|
|
||||||
try {
|
|
||||||
registered = await state.scanAndRegister(dir)
|
|
||||||
} catch (e) {
|
|
||||||
return die(e instanceof Error ? e.message : `Failed to scan ${dir}`)
|
|
||||||
}
|
|
||||||
if (registered.length === 0) {
|
|
||||||
console.log(`No sandlot projects found under ${dir}`)
|
|
||||||
} else {
|
|
||||||
for (const p of registered) {
|
|
||||||
console.log(`${green}+${reset} ${p}`)
|
|
||||||
}
|
|
||||||
console.log(`\nRegistered ${registered.length} project${registered.length === 1 ? "" : "s"}.`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function actionRemove(dir: string) {
|
|
||||||
const resolved = state.normalizePath(dir)
|
|
||||||
let removed = false
|
|
||||||
try {
|
|
||||||
removed = await state.unregisterProject(dir)
|
|
||||||
} catch {
|
|
||||||
return die("Could not acquire registry lock")
|
|
||||||
}
|
|
||||||
if (removed) {
|
|
||||||
console.log(`${red}-${reset} ${resolved}`)
|
|
||||||
} else {
|
|
||||||
die(`Project not found in registry: ${resolved}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
197
src/state.ts
197
src/state.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import { join, basename, resolve } from "path"
|
import { join, dirname } from "path"
|
||||||
import { readdir, rename, mkdir, rmdir, stat } from "fs/promises"
|
import { readdir, rename } from "fs/promises"
|
||||||
import { homedir } from "os"
|
import { homedir } from "os"
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
|
|
@ -45,7 +45,6 @@ export async function setSession(repoRoot: string, session: Session): Promise<vo
|
||||||
const state = await load(repoRoot)
|
const state = await load(repoRoot)
|
||||||
state.sessions[session.branch] = session
|
state.sessions[session.branch] = session
|
||||||
await save(repoRoot, state)
|
await save(repoRoot, state)
|
||||||
await registerProject(repoRoot).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
|
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
|
||||||
|
|
@ -54,171 +53,51 @@ export async function removeSession(repoRoot: string, branch: string): Promise<v
|
||||||
await save(repoRoot, state)
|
await save(repoRoot, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global state: ~/.sandlot/registry.json ─────────────────────────
|
|
||||||
|
|
||||||
interface GlobalState {
|
|
||||||
projects: string[] // repo root paths
|
|
||||||
}
|
|
||||||
|
|
||||||
const GLOBAL_DIR = join(homedir(), ".sandlot")
|
|
||||||
const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json")
|
|
||||||
|
|
||||||
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
const lockPath = join(GLOBAL_DIR, "registry.lock")
|
|
||||||
await mkdir(GLOBAL_DIR, { recursive: true })
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
try {
|
|
||||||
await mkdir(lockPath)
|
|
||||||
} catch {
|
|
||||||
// If the lock is older than 5 minutes, assume it's stale (crashed process)
|
|
||||||
try {
|
|
||||||
const info = await stat(lockPath)
|
|
||||||
if (Date.now() - info.mtimeMs > 300_000) {
|
|
||||||
await rmdir(lockPath).catch(() => {})
|
|
||||||
// Retry mkdir immediately to close the TOCTOU window
|
|
||||||
try { await mkdir(lockPath) } catch { continue }
|
|
||||||
} else {
|
|
||||||
if (i === 19) throw new Error("Could not acquire registry lock")
|
|
||||||
await Bun.sleep(50)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Lock dir vanished between our mkdir and stat — retry immediately
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await fn()
|
|
||||||
} finally {
|
|
||||||
await rmdir(lockPath).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("Could not acquire registry lock")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGlobal(): Promise<GlobalState> {
|
|
||||||
const file = Bun.file(GLOBAL_STATE_PATH)
|
|
||||||
if (await file.exists()) {
|
|
||||||
try {
|
|
||||||
const data = await file.json()
|
|
||||||
if (data && Array.isArray(data.projects)) return { projects: data.projects.filter((p: unknown) => typeof p === "string") }
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return { projects: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveGlobal(gs: GlobalState): Promise<void> {
|
|
||||||
const tmpPath = GLOBAL_STATE_PATH + ".tmp"
|
|
||||||
await Bun.write(tmpPath, JSON.stringify(gs, null, 2) + "\n")
|
|
||||||
await rename(tmpPath, GLOBAL_STATE_PATH)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePath(dir: string): string {
|
|
||||||
return resolve(dir.replace(/^~(?=\/|$)/, homedir()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerPaths(paths: string[]): Promise<void> {
|
|
||||||
if (paths.length === 0) return
|
|
||||||
await withGlobalLock(async () => {
|
|
||||||
const gs = await loadGlobal()
|
|
||||||
const existing = new Set(gs.projects)
|
|
||||||
let changed = false
|
|
||||||
for (const p of paths) {
|
|
||||||
if (!existing.has(p)) {
|
|
||||||
gs.projects.push(p)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) await saveGlobal(gs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Register a project directory in the global state. */
|
|
||||||
export async function registerProject(repoRoot: string): Promise<void> {
|
|
||||||
await registerPaths([normalizePath(repoRoot)])
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove a project directory from the global state. */
|
|
||||||
export async function unregisterProject(dir: string): Promise<boolean> {
|
|
||||||
const target = normalizePath(dir)
|
|
||||||
return withGlobalLock(async () => {
|
|
||||||
const gs = await loadGlobal()
|
|
||||||
const idx = gs.projects.indexOf(target)
|
|
||||||
if (idx === -1) return false
|
|
||||||
gs.projects.splice(idx, 1)
|
|
||||||
await saveGlobal(gs)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursively scan a directory for .sandlot dirs and register their parent projects. */
|
|
||||||
export async function scanAndRegister(dir: string, maxDepth = 5): Promise<string[]> {
|
|
||||||
const root = normalizePath(dir)
|
|
||||||
const found: string[] = []
|
|
||||||
|
|
||||||
async function walk(d: string, depth: number) {
|
|
||||||
if (depth >= maxDepth) return
|
|
||||||
let entries
|
|
||||||
try {
|
|
||||||
entries = await readdir(d, { withFileTypes: true })
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasSandlot = entries.some(e => e.isDirectory() && e.name === ".sandlot")
|
|
||||||
if (hasSandlot) {
|
|
||||||
const stateFile = Bun.file(join(d, ".sandlot", "state.json"))
|
|
||||||
if (await stateFile.exists()) {
|
|
||||||
found.push(d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const children = entries.filter(e => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith(".") && e.name !== "node_modules")
|
|
||||||
for (const entry of children) await walk(join(d, entry.name), depth + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
await walk(root, 0)
|
|
||||||
await registerPaths(found)
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GlobalSession extends Session {
|
export interface GlobalSession extends Session {
|
||||||
repo: string
|
repo: string
|
||||||
repoRoot: string
|
repoRoot: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load all sessions across all registered projects. Prunes stale entries. */
|
/** Discover all sessions across all repos by scanning ~/.sandlot/ */
|
||||||
export async function loadAll(): Promise<GlobalSession[]> {
|
export async function loadAll(): Promise<GlobalSession[]> {
|
||||||
const gs = await loadGlobal()
|
const sandlotDir = join(homedir(), ".sandlot")
|
||||||
const all: GlobalSession[] = []
|
const all: GlobalSession[] = []
|
||||||
const stale: string[] = []
|
|
||||||
|
|
||||||
await Promise.all(gs.projects.map(async (project) => {
|
let repoDirs
|
||||||
let st: State
|
try {
|
||||||
try {
|
repoDirs = await readdir(sandlotDir, { withFileTypes: true })
|
||||||
st = await load(project)
|
} catch {
|
||||||
} catch {
|
return []
|
||||||
// Corrupt state.json — skip but don't prune (self-heals on next session activity)
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Object.keys(st.sessions).length === 0) {
|
|
||||||
try { await stat(join(project, ".sandlot")) } catch { stale.push(project) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const repo = basename(project)
|
|
||||||
for (const s of Object.values(st.sessions)) {
|
|
||||||
all.push({ ...s, repo, repoRoot: project })
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Prune projects whose state files no longer exist
|
for (const entry of repoDirs) {
|
||||||
if (stale.length > 0) {
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue
|
||||||
const staleSet = new Set(stale)
|
const repoDir = join(sandlotDir, entry.name)
|
||||||
await withGlobalLock(async () => {
|
|
||||||
const fresh = await loadGlobal()
|
// Find the main repo root from a worktree's .git pointer
|
||||||
fresh.projects = fresh.projects.filter(p => !staleSet.has(p))
|
let repoRoot: string | null = null
|
||||||
await saveGlobal(fresh)
|
const branchEntries = await readdir(repoDir, { withFileTypes: true }).catch(() => [])
|
||||||
}).catch(() => {})
|
|
||||||
|
for (const be of branchEntries) {
|
||||||
|
if (!be.isDirectory() || be.name.startsWith(".")) continue
|
||||||
|
const dotGit = await Bun.file(join(repoDir, be.name, ".git")).text().catch(() => "")
|
||||||
|
const m = dotGit.match(/^gitdir:\s*(.+)/m)
|
||||||
|
if (m) {
|
||||||
|
// gitdir: /path/to/repo/.git/worktrees/<name>
|
||||||
|
const mainGit = m[1].trim().replace(/\/worktrees\/[^/]+$/, "")
|
||||||
|
repoRoot = dirname(mainGit)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repoRoot) {
|
||||||
|
try {
|
||||||
|
const st = await load(repoRoot)
|
||||||
|
for (const session of Object.values(st.sessions)) {
|
||||||
|
all.push({ ...session, repo: entry.name, repoRoot })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return all
|
return all
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user