updates
This commit is contained in:
parent
08f223add7
commit
033331db98
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sandlot",
|
"name": "sandlot",
|
||||||
"version": "0.1.0",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"sandlot": "./src/cli.ts"
|
"sandlot": "./src/cli.ts"
|
||||||
|
|
|
||||||
36
src/ai.ts
36
src/ai.ts
|
|
@ -1,17 +1,17 @@
|
||||||
import Anthropic from "@anthropic-ai/sdk";
|
import Anthropic from "@anthropic-ai/sdk"
|
||||||
|
|
||||||
let client: Anthropic | null = null;
|
let client: Anthropic | null = null
|
||||||
|
|
||||||
function getClient(): Anthropic {
|
function getClient(): Anthropic {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
client = new Anthropic();
|
client = new Anthropic()
|
||||||
}
|
}
|
||||||
return client;
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate a commit message from a diff. */
|
/** Generate a commit message from a diff. */
|
||||||
export async function generateCommitMessage(diff: string, model: string): Promise<string> {
|
export async function generateCommitMessage(diff: string, model: string): Promise<string> {
|
||||||
const anthropic = getClient();
|
const anthropic = getClient()
|
||||||
|
|
||||||
const response = await anthropic.messages.create({
|
const response = await anthropic.messages.create({
|
||||||
model,
|
model,
|
||||||
|
|
@ -22,11 +22,11 @@ export async function generateCommitMessage(diff: string, model: string): Promis
|
||||||
content: `Generate a git commit message for this diff. The first line must be a single-line summary of 72 characters or less. If the diff is substantial, add a blank line followed by a body with more detail. Return ONLY the commit message, nothing else.\n\n${diff}`,
|
content: `Generate a git commit message for this diff. The first line must be a single-line summary of 72 characters or less. If the diff is substantial, add a blank line followed by a body with more detail. Return ONLY the commit message, nothing else.\n\n${diff}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
|
|
||||||
const block = response.content[0];
|
const block = response.content[0]
|
||||||
if (block.type === "text") return block.text.trim();
|
if (block.type === "text") return block.text.trim()
|
||||||
throw new Error("Unexpected response from Claude");
|
throw new Error("Unexpected response from Claude")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a merge conflict in a file. Returns the resolved content and an explanation. */
|
/** Resolve a merge conflict in a file. Returns the resolved content and an explanation. */
|
||||||
|
|
@ -35,7 +35,7 @@ export async function resolveConflict(
|
||||||
conflictContent: string,
|
conflictContent: string,
|
||||||
model: string
|
model: string
|
||||||
): Promise<{ resolved: string; explanation: string }> {
|
): Promise<{ resolved: string; explanation: string }> {
|
||||||
const anthropic = getClient();
|
const anthropic = getClient()
|
||||||
|
|
||||||
const response = await anthropic.messages.create({
|
const response = await anthropic.messages.create({
|
||||||
model,
|
model,
|
||||||
|
|
@ -54,21 +54,21 @@ File with conflicts:
|
||||||
${conflictContent}`,
|
${conflictContent}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
|
|
||||||
const block = response.content[0];
|
const block = response.content[0]
|
||||||
if (block.type !== "text") throw new Error("Unexpected response from Claude");
|
if (block.type !== "text") throw new Error("Unexpected response from Claude")
|
||||||
|
|
||||||
const text = block.text;
|
const text = block.text
|
||||||
const explMatch = text.match(/EXPLANATION:\s*(.+)/);
|
const explMatch = text.match(/EXPLANATION:\s*(.+)/)
|
||||||
const resolvedMatch = text.match(/RESOLVED:\n([\s\S]+)/);
|
const resolvedMatch = text.match(/RESOLVED:\n([\s\S]+)/)
|
||||||
|
|
||||||
if (!explMatch || !resolvedMatch) {
|
if (!explMatch || !resolvedMatch) {
|
||||||
throw new Error("Could not parse Claude's conflict resolution response");
|
throw new Error("Could not parse Claude's conflict resolution response")
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
explanation: explMatch[1].trim(),
|
explanation: explMatch[1].trim(),
|
||||||
resolved: resolvedMatch[1].trimEnd() + "\n",
|
resolved: resolvedMatch[1].trimEnd() + "\n",
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
314
src/cli.ts
314
src/cli.ts
|
|
@ -1,16 +1,34 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { Command } from "commander";
|
import { homedir } from "os"
|
||||||
import { join } from "path";
|
import { Command } from "commander"
|
||||||
import * as git from "./git.ts";
|
import { join } from "path"
|
||||||
import * as vm from "./vm.ts";
|
|
||||||
import * as state from "./state.ts";
|
|
||||||
import { loadConfig } from "./config.ts";
|
|
||||||
import { generateCommitMessage, resolveConflict } from "./ai.ts";
|
|
||||||
|
|
||||||
const program = new Command();
|
// Load ~/.env into process.env
|
||||||
|
const envFile = Bun.file(join(homedir(), ".env"))
|
||||||
|
if (await envFile.exists()) {
|
||||||
|
const text = await envFile.text()
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue
|
||||||
|
const eq = trimmed.indexOf("=")
|
||||||
|
if (eq === -1) continue
|
||||||
|
const key = trimmed.slice(0, eq)
|
||||||
|
const val = trimmed.slice(eq + 1).replace(/^["']|["']$/g, "")
|
||||||
|
process.env[key] ??= val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
import * as git from "./git.ts"
|
||||||
|
import * as vm from "./vm.ts"
|
||||||
|
import * as state from "./state.ts"
|
||||||
|
import { loadConfig } from "./config.ts"
|
||||||
|
import { generateCommitMessage, resolveConflict } from "./ai.ts"
|
||||||
|
|
||||||
program.name("sandlot").description("Branch-based development with git worktrees and Apple containers").version("0.1.0");
|
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 Apple containers").version(pkg.version)
|
||||||
|
|
||||||
// ── sandlot new <branch> ──────────────────────────────────────────────
|
// ── sandlot new <branch> ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -19,23 +37,23 @@ program
|
||||||
.argument("<branch>", "branch name")
|
.argument("<branch>", "branch name")
|
||||||
.description("Create a new session with a worktree and VM")
|
.description("Create a new session with a worktree and VM")
|
||||||
.action(async (branch: string) => {
|
.action(async (branch: string) => {
|
||||||
const root = await git.repoRoot();
|
const root = await git.repoRoot()
|
||||||
const config = await loadConfig(root);
|
const config = await loadConfig(root)
|
||||||
const worktreeRel = `.sandlot/${branch}`;
|
const worktreeRel = `.sandlot/${branch}`
|
||||||
const worktreeAbs = join(root, worktreeRel);
|
const worktreeAbs = join(root, worktreeRel)
|
||||||
|
|
||||||
// Check for stale directory
|
// Check for stale directory
|
||||||
const existing = await state.getSession(root, branch);
|
const existing = await state.getSession(root, branch)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.error(`Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`);
|
console.error(`Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`)
|
||||||
process.exit(1);
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Creating worktree at ${worktreeRel}/`);
|
console.log(`Creating worktree at ${worktreeRel}/`)
|
||||||
await git.createWorktree(branch, worktreeAbs, root);
|
await git.createWorktree(branch, worktreeAbs, root)
|
||||||
|
|
||||||
console.log("Booting VM...");
|
console.log("Booting VM...")
|
||||||
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm);
|
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm)
|
||||||
|
|
||||||
await state.setSession(root, {
|
await state.setSession(root, {
|
||||||
branch,
|
branch,
|
||||||
|
|
@ -43,10 +61,10 @@ program
|
||||||
vm_id: vmId,
|
vm_id: vmId,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
status: "running",
|
status: "running",
|
||||||
});
|
})
|
||||||
|
|
||||||
await vm.shell(vmId);
|
await vm.shell(vmId)
|
||||||
});
|
})
|
||||||
|
|
||||||
// ── sandlot save [message] ────────────────────────────────────────────
|
// ── sandlot save [message] ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -55,35 +73,35 @@ program
|
||||||
.argument("[message]", "commit message (auto-generated if omitted)")
|
.argument("[message]", "commit message (auto-generated if omitted)")
|
||||||
.description("Stage and commit all changes, push to origin")
|
.description("Stage and commit all changes, push to origin")
|
||||||
.action(async (message?: string) => {
|
.action(async (message?: string) => {
|
||||||
const root = await git.repoRoot();
|
const root = await git.repoRoot()
|
||||||
const config = await loadConfig(root);
|
const config = await loadConfig(root)
|
||||||
const branch = await detectBranch(root);
|
const branch = await detectBranch(root)
|
||||||
const session = await state.getSession(root, branch);
|
const session = await state.getSession(root, branch)
|
||||||
const cwd = session ? join(root, session.worktree) : root;
|
const cwd = session ? join(root, session.worktree) : root
|
||||||
|
|
||||||
if (!(await git.hasChanges(cwd))) {
|
if (!(await git.hasChanges(cwd))) {
|
||||||
console.log("No changes to commit.");
|
console.log("No changes to commit.")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const staged = await git.stageAll(cwd);
|
const staged = await git.stageAll(cwd)
|
||||||
console.log(`Staged ${staged} files`);
|
console.log(`Staged ${staged} files`)
|
||||||
|
|
||||||
let commitMsg: string;
|
let commitMsg: string
|
||||||
if (message) {
|
if (message) {
|
||||||
commitMsg = message;
|
commitMsg = message
|
||||||
} else {
|
} else {
|
||||||
const diff = await git.stagedDiff(cwd);
|
const diff = await git.stagedDiff(cwd)
|
||||||
commitMsg = await generateCommitMessage(diff, config.ai?.model ?? "claude-sonnet-4-20250514");
|
commitMsg = await generateCommitMessage(diff, config.ai?.model ?? "claude-sonnet-4-20250514")
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.commit(commitMsg, cwd);
|
await git.commit(commitMsg, cwd)
|
||||||
const firstLine = commitMsg.split("\n")[0];
|
const firstLine = commitMsg.split("\n")[0]
|
||||||
console.log(`Commit: ${firstLine}`);
|
console.log(`Commit: ${firstLine}`)
|
||||||
|
|
||||||
await git.push(branch, cwd);
|
await git.push(branch, cwd)
|
||||||
console.log(`Pushed ${branch} → origin/${branch}`);
|
console.log(`Pushed ${branch} → origin/${branch}`)
|
||||||
});
|
})
|
||||||
|
|
||||||
// ── sandlot push <target> ─────────────────────────────────────────────
|
// ── sandlot push <target> ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -92,84 +110,84 @@ program
|
||||||
.argument("<target>", "target branch to merge into")
|
.argument("<target>", "target branch to merge into")
|
||||||
.description("Merge session branch into target, then tear down")
|
.description("Merge session branch into target, then tear down")
|
||||||
.action(async (target: string) => {
|
.action(async (target: string) => {
|
||||||
const root = await git.repoRoot();
|
const root = await git.repoRoot()
|
||||||
const config = await loadConfig(root);
|
const config = await loadConfig(root)
|
||||||
const branch = await detectBranch(root);
|
const branch = await detectBranch(root)
|
||||||
const session = await state.getSession(root, branch);
|
const session = await state.getSession(root, branch)
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.error(`No session found for branch "${branch}".`);
|
console.error(`No session found for branch "${branch}".`)
|
||||||
process.exit(1);
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreeCwd = join(root, session.worktree);
|
const worktreeCwd = join(root, session.worktree)
|
||||||
|
|
||||||
// Check for uncommitted changes
|
// Check for uncommitted changes
|
||||||
if (await git.hasChanges(worktreeCwd)) {
|
if (await git.hasChanges(worktreeCwd)) {
|
||||||
console.error(`Uncommitted changes in worktree. Run "sandlot save" first.`);
|
console.error(`Uncommitted changes in worktree. Run "sandlot save" first.`)
|
||||||
process.exit(1);
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Pushing ${branch} → ${target}...`);
|
console.log(`Pushing ${branch} → ${target}...`)
|
||||||
|
|
||||||
// Checkout target in main working tree
|
// Checkout target in main working tree
|
||||||
await git.checkout(target, root);
|
await git.checkout(target, root)
|
||||||
|
|
||||||
// Merge
|
// Merge
|
||||||
const merged = await git.merge(branch, root);
|
const merged = await git.merge(branch, root)
|
||||||
|
|
||||||
if (!merged) {
|
if (!merged) {
|
||||||
// Handle conflicts
|
// Handle conflicts
|
||||||
const conflicts = await git.conflictedFiles(root);
|
const conflicts = await git.conflictedFiles(root)
|
||||||
console.log(`${conflicts.length} conflicts found. Resolving with Claude...\n`);
|
console.log(`${conflicts.length} conflicts found. Resolving with Claude...\n`)
|
||||||
|
|
||||||
const model = config.ai?.model ?? "claude-sonnet-4-20250514";
|
const model = config.ai?.model ?? "claude-sonnet-4-20250514"
|
||||||
const resolutions: Array<{ file: string; explanation: string }> = [];
|
const resolutions: Array<{ file: string; explanation: string }> = []
|
||||||
|
|
||||||
for (const file of conflicts) {
|
for (const file of conflicts) {
|
||||||
const content = await git.conflictContent(file, root);
|
const content = await git.conflictContent(file, root)
|
||||||
const { resolved, explanation } = await resolveConflict(file, content, model);
|
const { resolved, explanation } = await resolveConflict(file, content, model)
|
||||||
await Bun.write(join(root, file), resolved);
|
await Bun.write(join(root, file), resolved)
|
||||||
await git.stageFile(file, root);
|
await git.stageFile(file, root)
|
||||||
resolutions.push({ file, explanation });
|
resolutions.push({ file, explanation })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { file, explanation } of resolutions) {
|
for (const { file, explanation } of resolutions) {
|
||||||
console.log(` ${file}`);
|
console.log(` ${file}`)
|
||||||
console.log(` ✓ ${explanation}\n`);
|
console.log(` ✓ ${explanation}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompt for confirmation
|
// Prompt for confirmation
|
||||||
process.stdout.write("Accept Claude's resolutions? [Y/n] ");
|
process.stdout.write("Accept Claude's resolutions? [Y/n] ")
|
||||||
const answer = await readLine();
|
const answer = await readLine()
|
||||||
|
|
||||||
if (answer.toLowerCase() === "n") {
|
if (answer.toLowerCase() === "n") {
|
||||||
await git.abortMerge(root);
|
await git.abortMerge(root)
|
||||||
console.log("Merge aborted. Resolve conflicts manually, then run sandlot push again.");
|
console.log("Merge aborted. Resolve conflicts manually, then run sandlot push again.")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await git.commitMerge(root);
|
await git.commitMerge(root)
|
||||||
console.log("Committed merge");
|
console.log("Committed merge")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push target
|
// Push target
|
||||||
await git.push(target, root);
|
await git.push(target, root)
|
||||||
console.log(`Pushed ${target} → origin/${target}`);
|
console.log(`Pushed ${target} → origin/${target}`)
|
||||||
|
|
||||||
// Tear down
|
// Tear down
|
||||||
await vm.destroy(session.vm_id);
|
await vm.destroy(session.vm_id)
|
||||||
console.log(`Stopped VM ${branch}`);
|
console.log(`Stopped VM ${branch}`)
|
||||||
|
|
||||||
await git.removeWorktree(join(root, session.worktree), root);
|
await git.removeWorktree(join(root, session.worktree), root)
|
||||||
console.log(`Removed worktree ${session.worktree}/`);
|
console.log(`Removed worktree ${session.worktree}/`)
|
||||||
|
|
||||||
await git.deleteLocalBranch(branch, root);
|
await git.deleteLocalBranch(branch, root)
|
||||||
await git.deleteRemoteBranch(branch, root);
|
await git.deleteRemoteBranch(branch, root)
|
||||||
console.log(`Deleted branch ${branch} (local + remote)`);
|
console.log(`Deleted branch ${branch} (local + remote)`)
|
||||||
|
|
||||||
await state.removeSession(root, branch);
|
await state.removeSession(root, branch)
|
||||||
});
|
})
|
||||||
|
|
||||||
// ── sandlot list ──────────────────────────────────────────────────────
|
// ── sandlot list ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -177,35 +195,35 @@ program
|
||||||
.command("list")
|
.command("list")
|
||||||
.description("Show all active sessions")
|
.description("Show all active sessions")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const root = await git.repoRoot();
|
const root = await git.repoRoot()
|
||||||
const st = await state.load(root);
|
const st = await state.load(root)
|
||||||
const sessions = Object.values(st.sessions);
|
const sessions = Object.values(st.sessions)
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
console.log("No active sessions.");
|
console.log("No active sessions.")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check actual VM statuses
|
// Check actual VM statuses
|
||||||
const rows: Array<{ branch: string; vmStatus: string; worktree: string }> = [];
|
const rows: Array<{ branch: string; vmStatus: string; worktree: string }> = []
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
const vmStatus = await vm.status(s.vm_id);
|
const vmStatus = await vm.status(s.vm_id)
|
||||||
rows.push({ branch: s.branch, vmStatus, worktree: s.worktree });
|
rows.push({ branch: s.branch, vmStatus, worktree: s.worktree })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print table
|
// Print table
|
||||||
const branchWidth = Math.max(6, ...rows.map((r) => r.branch.length));
|
const branchWidth = Math.max(6, ...rows.map((r) => r.branch.length))
|
||||||
const statusWidth = Math.max(9, ...rows.map((r) => r.vmStatus.length));
|
const statusWidth = Math.max(9, ...rows.map((r) => r.vmStatus.length))
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${"BRANCH".padEnd(branchWidth)} ${"VM STATUS".padEnd(statusWidth)} WORKTREE`
|
`${"BRANCH".padEnd(branchWidth)} ${"VM STATUS".padEnd(statusWidth)} WORKTREE`
|
||||||
);
|
)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
console.log(
|
console.log(
|
||||||
`${row.branch.padEnd(branchWidth)} ${row.vmStatus.padEnd(statusWidth)} ${row.worktree}/`
|
`${row.branch.padEnd(branchWidth)} ${row.vmStatus.padEnd(statusWidth)} ${row.worktree}/`
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// ── sandlot open <branch> ─────────────────────────────────────────────
|
// ── sandlot open <branch> ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -214,38 +232,38 @@ program
|
||||||
.argument("<branch>", "branch name")
|
.argument("<branch>", "branch name")
|
||||||
.description("Re-enter an existing session's VM")
|
.description("Re-enter an existing session's VM")
|
||||||
.action(async (branch: string) => {
|
.action(async (branch: string) => {
|
||||||
const root = await git.repoRoot();
|
const root = await git.repoRoot()
|
||||||
const config = await loadConfig(root);
|
const config = await loadConfig(root)
|
||||||
const session = await state.getSession(root, branch);
|
const session = await state.getSession(root, branch)
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.error(`No session found for branch "${branch}".`);
|
console.error(`No session found for branch "${branch}".`)
|
||||||
process.exit(1);
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const vmStatus = await vm.status(session.vm_id);
|
const vmStatus = await vm.status(session.vm_id)
|
||||||
|
|
||||||
if (vmStatus === "missing") {
|
if (vmStatus === "missing") {
|
||||||
// Stale VM, reboot
|
// Stale VM, reboot
|
||||||
console.log("VM is gone. Rebooting...");
|
console.log("VM is gone. Rebooting...")
|
||||||
const worktreeAbs = join(root, session.worktree);
|
const worktreeAbs = join(root, session.worktree)
|
||||||
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm);
|
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm)
|
||||||
await state.setSession(root, { ...session, vm_id: vmId, status: "running" });
|
await state.setSession(root, { ...session, vm_id: vmId, status: "running" })
|
||||||
await vm.shell(vmId);
|
await vm.shell(vmId)
|
||||||
} else if (vmStatus === "stopped") {
|
} else if (vmStatus === "stopped") {
|
||||||
console.log("Booting VM...");
|
console.log("Booting VM...")
|
||||||
// Need to start the existing container
|
// Need to start the existing container
|
||||||
const proc = Bun.spawn(["container", "start", session.vm_id], {
|
const proc = Bun.spawn(["container", "start", session.vm_id], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
});
|
})
|
||||||
await proc.exited;
|
await proc.exited
|
||||||
await state.setSession(root, { ...session, status: "running" });
|
await state.setSession(root, { ...session, status: "running" })
|
||||||
await vm.shell(session.vm_id);
|
await vm.shell(session.vm_id)
|
||||||
} else {
|
} else {
|
||||||
await vm.shell(session.vm_id);
|
await vm.shell(session.vm_id)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// ── sandlot stop <branch> ─────────────────────────────────────────────
|
// ── sandlot stop <branch> ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -254,18 +272,18 @@ program
|
||||||
.argument("<branch>", "branch name")
|
.argument("<branch>", "branch name")
|
||||||
.description("Stop a session's VM without destroying it")
|
.description("Stop a session's VM without destroying it")
|
||||||
.action(async (branch: string) => {
|
.action(async (branch: string) => {
|
||||||
const root = await git.repoRoot();
|
const root = await git.repoRoot()
|
||||||
const session = await state.getSession(root, branch);
|
const session = await state.getSession(root, branch)
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.error(`No session found for branch "${branch}".`);
|
console.error(`No session found for branch "${branch}".`)
|
||||||
process.exit(1);
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
await vm.stop(session.vm_id);
|
await vm.stop(session.vm_id)
|
||||||
await state.setSession(root, { ...session, status: "stopped" });
|
await state.setSession(root, { ...session, status: "stopped" })
|
||||||
console.log(`Stopped VM for ${branch}`);
|
console.log(`Stopped VM for ${branch}`)
|
||||||
});
|
})
|
||||||
|
|
||||||
// ── sandlot rm <branch> ───────────────────────────────────────────────
|
// ── sandlot rm <branch> ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -274,25 +292,25 @@ program
|
||||||
.argument("<branch>", "branch name")
|
.argument("<branch>", "branch name")
|
||||||
.description("Tear down a session without merging")
|
.description("Tear down a session without merging")
|
||||||
.action(async (branch: string) => {
|
.action(async (branch: string) => {
|
||||||
const root = await git.repoRoot();
|
const root = await git.repoRoot()
|
||||||
const session = await state.getSession(root, branch);
|
const session = await state.getSession(root, branch)
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
console.error(`No session found for branch "${branch}".`);
|
console.error(`No session found for branch "${branch}".`)
|
||||||
process.exit(1);
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
await vm.destroy(session.vm_id);
|
await vm.destroy(session.vm_id)
|
||||||
console.log(`Stopped VM ${branch}`);
|
console.log(`Stopped VM ${branch}`)
|
||||||
|
|
||||||
await git.removeWorktree(join(root, session.worktree), root);
|
await git.removeWorktree(join(root, session.worktree), root)
|
||||||
console.log(`Removed worktree ${session.worktree}/`);
|
console.log(`Removed worktree ${session.worktree}/`)
|
||||||
|
|
||||||
await git.deleteLocalBranch(branch, root);
|
await git.deleteLocalBranch(branch, root)
|
||||||
console.log(`Deleted local branch ${branch}`);
|
console.log(`Deleted local branch ${branch}`)
|
||||||
|
|
||||||
await state.removeSession(root, branch);
|
await state.removeSession(root, branch)
|
||||||
});
|
})
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -300,32 +318,32 @@ program
|
||||||
async function detectBranch(root: string): Promise<string> {
|
async function detectBranch(root: string): Promise<string> {
|
||||||
// Check env var first (set when inside a VM)
|
// Check env var first (set when inside a VM)
|
||||||
if (process.env.SANDLOT_BRANCH) {
|
if (process.env.SANDLOT_BRANCH) {
|
||||||
return process.env.SANDLOT_BRANCH;
|
return process.env.SANDLOT_BRANCH
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if cwd is inside a worktree
|
// Check if cwd is inside a worktree
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd()
|
||||||
const sandlotDir = join(root, ".sandlot");
|
const sandlotDir = join(root, ".sandlot")
|
||||||
if (cwd.startsWith(sandlotDir)) {
|
if (cwd.startsWith(sandlotDir)) {
|
||||||
const rel = cwd.slice(sandlotDir.length + 1);
|
const rel = cwd.slice(sandlotDir.length + 1)
|
||||||
const branch = rel.split("/")[0];
|
const branch = rel.split("/")[0]
|
||||||
if (branch) return branch;
|
if (branch) return branch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to current git branch
|
// Fall back to current git branch
|
||||||
return await git.currentBranch();
|
return await git.currentBranch()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a line from stdin. */
|
/** Read a line from stdin. */
|
||||||
function readLine(): Promise<string> {
|
function readLine(): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
process.stdin.setRawMode?.(false);
|
process.stdin.setRawMode?.(false)
|
||||||
process.stdin.resume();
|
process.stdin.resume()
|
||||||
process.stdin.once("data", (chunk) => {
|
process.stdin.once("data", (chunk) => {
|
||||||
process.stdin.pause();
|
process.stdin.pause()
|
||||||
resolve(chunk.toString().trim());
|
resolve(chunk.toString().trim())
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
program.parse();
|
program.parse()
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,35 @@
|
||||||
import { join } from "path";
|
import { join } from "path"
|
||||||
|
|
||||||
export interface VmConfig {
|
export interface VmConfig {
|
||||||
cpus?: number;
|
cpus?: number
|
||||||
memory?: string;
|
memory?: string
|
||||||
image?: string;
|
image?: string
|
||||||
mounts?: Record<string, string>;
|
mounts?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiConfig {
|
export interface AiConfig {
|
||||||
model?: string;
|
model?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SandlotConfig {
|
export interface SandlotConfig {
|
||||||
vm?: VmConfig;
|
vm?: VmConfig
|
||||||
ai?: AiConfig;
|
ai?: AiConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: SandlotConfig = {
|
const DEFAULT_CONFIG: SandlotConfig = {
|
||||||
vm: { image: "ubuntu:24.04" },
|
vm: { image: "ubuntu:24.04" },
|
||||||
ai: { model: "claude-sonnet-4-20250514" },
|
ai: { model: "claude-sonnet-4-20250514" },
|
||||||
};
|
}
|
||||||
|
|
||||||
export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
|
export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
|
||||||
const path = join(repoRoot, "sandlot.json");
|
const path = join(repoRoot, "sandlot.json")
|
||||||
const file = Bun.file(path);
|
const file = Bun.file(path)
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
const userConfig = await file.json();
|
const userConfig = await file.json()
|
||||||
return {
|
return {
|
||||||
vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm },
|
vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm },
|
||||||
ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai },
|
ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai },
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
return DEFAULT_CONFIG;
|
return DEFAULT_CONFIG
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/git.ts
74
src/git.ts
|
|
@ -1,121 +1,121 @@
|
||||||
import { $ } from "bun";
|
import { $ } from "bun"
|
||||||
|
|
||||||
/** Get the repo root from a working directory. */
|
/** Get the repo root from a working directory. */
|
||||||
export async function repoRoot(cwd?: string): Promise<string> {
|
export async function repoRoot(cwd?: string): Promise<string> {
|
||||||
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").text();
|
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").text()
|
||||||
return result.trim();
|
return result.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the current branch name. */
|
/** Get the current branch name. */
|
||||||
export async function currentBranch(cwd?: string): Promise<string> {
|
export async function currentBranch(cwd?: string): Promise<string> {
|
||||||
const result = await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd ?? ".").text();
|
const result = await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd ?? ".").text()
|
||||||
return result.trim();
|
return result.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a branch exists locally or remotely. Returns "local", "remote", or null. */
|
/** Check if a branch exists locally or remotely. Returns "local", "remote", or null. */
|
||||||
export async function branchExists(branch: string, cwd?: string): Promise<"local" | "remote" | null> {
|
export async function branchExists(branch: string, cwd?: string): Promise<"local" | "remote" | null> {
|
||||||
const dir = cwd ?? ".";
|
const dir = cwd ?? "."
|
||||||
const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet();
|
const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet()
|
||||||
if (local.exitCode === 0) return "local";
|
if (local.exitCode === 0) return "local"
|
||||||
|
|
||||||
await $`git fetch origin`.cwd(dir).nothrow().quiet();
|
await $`git fetch origin`.cwd(dir).nothrow().quiet()
|
||||||
const remote = await $`git show-ref --verify --quiet refs/remotes/origin/${branch}`.cwd(dir).nothrow().quiet();
|
const remote = await $`git show-ref --verify --quiet refs/remotes/origin/${branch}`.cwd(dir).nothrow().quiet()
|
||||||
if (remote.exitCode === 0) return "remote";
|
if (remote.exitCode === 0) return "remote"
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a worktree for the given branch. */
|
/** Create a worktree for the given branch. */
|
||||||
export async function createWorktree(branch: string, worktreePath: string, cwd: string): Promise<void> {
|
export async function createWorktree(branch: string, worktreePath: string, cwd: string): Promise<void> {
|
||||||
const exists = await branchExists(branch, cwd);
|
const exists = await branchExists(branch, cwd)
|
||||||
|
|
||||||
if (exists === "local") {
|
if (exists === "local") {
|
||||||
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd);
|
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd)
|
||||||
} else if (exists === "remote") {
|
} else if (exists === "remote") {
|
||||||
await $`git worktree add ${worktreePath} -b ${branch} origin/${branch}`.cwd(cwd);
|
await $`git worktree add ${worktreePath} -b ${branch} origin/${branch}`.cwd(cwd)
|
||||||
} else {
|
} else {
|
||||||
// New branch from current HEAD
|
// New branch from current HEAD
|
||||||
await $`git worktree add -b ${branch} ${worktreePath}`.cwd(cwd);
|
await $`git worktree add -b ${branch} ${worktreePath}`.cwd(cwd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a worktree. */
|
/** Remove a worktree. */
|
||||||
export async function removeWorktree(worktreePath: string, cwd: string): Promise<void> {
|
export async function removeWorktree(worktreePath: string, cwd: string): Promise<void> {
|
||||||
await $`git worktree remove ${worktreePath} --force`.cwd(cwd);
|
await $`git worktree remove ${worktreePath} --force`.cwd(cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a local branch. */
|
/** Delete a local branch. */
|
||||||
export async function deleteLocalBranch(branch: string, cwd: string): Promise<void> {
|
export async function deleteLocalBranch(branch: string, cwd: string): Promise<void> {
|
||||||
await $`git branch -D ${branch}`.cwd(cwd).nothrow();
|
await $`git branch -D ${branch}`.cwd(cwd).nothrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a remote branch. */
|
/** Delete a remote branch. */
|
||||||
export async function deleteRemoteBranch(branch: string, cwd: string): Promise<void> {
|
export async function deleteRemoteBranch(branch: string, cwd: string): Promise<void> {
|
||||||
await $`git push origin --delete ${branch}`.cwd(cwd).nothrow().quiet();
|
await $`git push origin --delete ${branch}`.cwd(cwd).nothrow().quiet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stage all changes and return the number of staged files. */
|
/** Stage all changes and return the number of staged files. */
|
||||||
export async function stageAll(cwd: string): Promise<number> {
|
export async function stageAll(cwd: string): Promise<number> {
|
||||||
await $`git add .`.cwd(cwd);
|
await $`git add .`.cwd(cwd)
|
||||||
const status = await $`git diff --cached --name-only`.cwd(cwd).text();
|
const status = await $`git diff --cached --name-only`.cwd(cwd).text()
|
||||||
const files = status.trim().split("\n").filter(Boolean);
|
const files = status.trim().split("\n").filter(Boolean)
|
||||||
return files.length;
|
return files.length
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if there are any uncommitted changes (staged or unstaged). */
|
/** Check if there are any uncommitted changes (staged or unstaged). */
|
||||||
export async function hasChanges(cwd: string): Promise<boolean> {
|
export async function hasChanges(cwd: string): Promise<boolean> {
|
||||||
const result = await $`git status --porcelain`.cwd(cwd).text();
|
const result = await $`git status --porcelain`.cwd(cwd).text()
|
||||||
return result.trim().length > 0;
|
return result.trim().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the diff of staged changes. */
|
/** Get the diff of staged changes. */
|
||||||
export async function stagedDiff(cwd: string): Promise<string> {
|
export async function stagedDiff(cwd: string): Promise<string> {
|
||||||
return await $`git diff --cached`.cwd(cwd).text();
|
return await $`git diff --cached`.cwd(cwd).text()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Commit with a message. */
|
/** Commit with a message. */
|
||||||
export async function commit(message: string, cwd: string): Promise<void> {
|
export async function commit(message: string, cwd: string): Promise<void> {
|
||||||
await $`git commit -m ${message}`.cwd(cwd);
|
await $`git commit -m ${message}`.cwd(cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Push a branch to origin. */
|
/** Push a branch to origin. */
|
||||||
export async function push(branch: string, cwd: string): Promise<void> {
|
export async function push(branch: string, cwd: string): Promise<void> {
|
||||||
await $`git push -u origin ${branch}`.cwd(cwd);
|
await $`git push -u origin ${branch}`.cwd(cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Checkout a branch in a working tree. */
|
/** Checkout a branch in a working tree. */
|
||||||
export async function checkout(branch: string, cwd: string): Promise<void> {
|
export async function checkout(branch: string, cwd: string): Promise<void> {
|
||||||
await $`git checkout ${branch}`.cwd(cwd);
|
await $`git checkout ${branch}`.cwd(cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge a branch into the current branch. Returns true if successful, false if conflicts. */
|
/** Merge a branch into the current branch. Returns true if successful, false if conflicts. */
|
||||||
export async function merge(branch: string, cwd: string): Promise<boolean> {
|
export async function merge(branch: string, cwd: string): Promise<boolean> {
|
||||||
const result = await $`git merge ${branch}`.cwd(cwd).nothrow();
|
const result = await $`git merge ${branch}`.cwd(cwd).nothrow()
|
||||||
return result.exitCode === 0;
|
return result.exitCode === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get list of conflicted files. */
|
/** Get list of conflicted files. */
|
||||||
export async function conflictedFiles(cwd: string): Promise<string[]> {
|
export async function conflictedFiles(cwd: string): Promise<string[]> {
|
||||||
const result = await $`git diff --name-only --diff-filter=U`.cwd(cwd).text();
|
const result = await $`git diff --name-only --diff-filter=U`.cwd(cwd).text()
|
||||||
return result.trim().split("\n").filter(Boolean);
|
return result.trim().split("\n").filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the conflict content of a file (with markers). */
|
/** Get the conflict content of a file (with markers). */
|
||||||
export async function conflictContent(filePath: string, cwd: string): Promise<string> {
|
export async function conflictContent(filePath: string, cwd: string): Promise<string> {
|
||||||
return await Bun.file(`${cwd}/${filePath}`).text();
|
return await Bun.file(`${cwd}/${filePath}`).text()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stage a resolved file. */
|
/** Stage a resolved file. */
|
||||||
export async function stageFile(filePath: string, cwd: string): Promise<void> {
|
export async function stageFile(filePath: string, cwd: string): Promise<void> {
|
||||||
await $`git add ${filePath}`.cwd(cwd);
|
await $`git add ${filePath}`.cwd(cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Commit a merge (no message needed, uses default merge message). */
|
/** Commit a merge (no message needed, uses default merge message). */
|
||||||
export async function commitMerge(cwd: string): Promise<void> {
|
export async function commitMerge(cwd: string): Promise<void> {
|
||||||
await $`git commit --no-edit`.cwd(cwd);
|
await $`git commit --no-edit`.cwd(cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Abort a merge. */
|
/** Abort a merge. */
|
||||||
export async function abortMerge(cwd: string): Promise<void> {
|
export async function abortMerge(cwd: string): Promise<void> {
|
||||||
await $`git merge --abort`.cwd(cwd);
|
await $`git merge --abort`.cwd(cwd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
src/state.ts
48
src/state.ts
|
|
@ -1,50 +1,50 @@
|
||||||
import { join } from "path";
|
import { join } from "path"
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
branch: string;
|
branch: string
|
||||||
worktree: string;
|
worktree: string
|
||||||
vm_id: string;
|
vm_id: string
|
||||||
created_at: string;
|
created_at: string
|
||||||
status: "running" | "stopped";
|
status: "running" | "stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
sessions: Record<string, Session>;
|
sessions: Record<string, Session>
|
||||||
}
|
}
|
||||||
|
|
||||||
function statePath(repoRoot: string): string {
|
function statePath(repoRoot: string): string {
|
||||||
return join(repoRoot, ".sandlot", "state.json");
|
return join(repoRoot, ".sandlot", "state.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function load(repoRoot: string): Promise<State> {
|
export async function load(repoRoot: string): Promise<State> {
|
||||||
const path = statePath(repoRoot);
|
const path = statePath(repoRoot)
|
||||||
const file = Bun.file(path);
|
const file = Bun.file(path)
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
return await file.json();
|
return await file.json()
|
||||||
}
|
}
|
||||||
return { sessions: {} };
|
return { sessions: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function save(repoRoot: string, state: State): Promise<void> {
|
export async function save(repoRoot: string, state: State): Promise<void> {
|
||||||
const path = statePath(repoRoot);
|
const path = statePath(repoRoot)
|
||||||
const dir = join(repoRoot, ".sandlot");
|
const dir = join(repoRoot, ".sandlot")
|
||||||
await Bun.write(join(dir, ".gitkeep"), ""); // ensure dir exists
|
await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists
|
||||||
await Bun.write(path, JSON.stringify(state, null, 2) + "\n");
|
await Bun.write(path, JSON.stringify(state, null, 2) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
|
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
|
||||||
const state = await load(repoRoot);
|
const state = await load(repoRoot)
|
||||||
return state.sessions[branch];
|
return state.sessions[branch]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSession(repoRoot: string, session: Session): Promise<void> {
|
export async function setSession(repoRoot: string, session: Session): Promise<void> {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
|
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
|
||||||
const state = await load(repoRoot);
|
const state = await load(repoRoot)
|
||||||
delete state.sessions[branch];
|
delete state.sessions[branch]
|
||||||
await save(repoRoot, state);
|
await save(repoRoot, state)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
src/vm.ts
52
src/vm.ts
|
|
@ -1,5 +1,5 @@
|
||||||
import { $ } from "bun";
|
import { $ } from "bun"
|
||||||
import type { VmConfig } from "./config.ts";
|
import type { VmConfig } from "./config.ts"
|
||||||
|
|
||||||
/** Boot a container VM mapped to a worktree directory. Returns the container ID. */
|
/** Boot a container VM mapped to a worktree directory. Returns the container ID. */
|
||||||
export async function boot(
|
export async function boot(
|
||||||
|
|
@ -7,42 +7,42 @@ export async function boot(
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
config?: VmConfig
|
config?: VmConfig
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const args: string[] = ["container", "run", "--name", name];
|
const args: string[] = ["container", "run", "--name", name]
|
||||||
|
|
||||||
if (config?.cpus) args.push("--cpus", String(config.cpus));
|
if (config?.cpus) args.push("--cpus", String(config.cpus))
|
||||||
if (config?.memory) args.push("--memory", config.memory);
|
if (config?.memory) args.push("--memory", config.memory)
|
||||||
|
|
||||||
// Mount worktree as /root/work
|
// Mount worktree as /root/work
|
||||||
args.push("--mount", `type=virtiofs,source=${worktreePath},target=/root/work`);
|
args.push("--mount", `type=virtiofs,source=${worktreePath},target=/root/work`)
|
||||||
|
|
||||||
// Additional mounts from config
|
// Additional mounts from config
|
||||||
if (config?.mounts) {
|
if (config?.mounts) {
|
||||||
for (const [source, target] of Object.entries(config.mounts)) {
|
for (const [source, target] of Object.entries(config.mounts)) {
|
||||||
args.push("--mount", `type=virtiofs,source=${source},target=${target}`);
|
args.push("--mount", `type=virtiofs,source=${source},target=${target}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = config?.image ?? "ubuntu:24.04";
|
const image = config?.image ?? "ubuntu:24.04"
|
||||||
args.push("-d", image);
|
args.push("-d", image)
|
||||||
|
|
||||||
const result = await $`${args}`.text();
|
const result = await $`${args}`.text()
|
||||||
return result.trim();
|
return result.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop a running container. */
|
/** Stop a running container. */
|
||||||
export async function stop(vmId: string): Promise<void> {
|
export async function stop(vmId: string): Promise<void> {
|
||||||
await $`container stop ${vmId}`.nothrow().quiet();
|
await $`container stop ${vmId}`.nothrow().quiet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a container. */
|
/** Remove a container. */
|
||||||
export async function rm(vmId: string): Promise<void> {
|
export async function rm(vmId: string): Promise<void> {
|
||||||
await $`container rm ${vmId}`.nothrow().quiet();
|
await $`container rm ${vmId}`.nothrow().quiet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop and remove a container. */
|
/** Stop and remove a container. */
|
||||||
export async function destroy(vmId: string): Promise<void> {
|
export async function destroy(vmId: string): Promise<void> {
|
||||||
await stop(vmId);
|
await stop(vmId)
|
||||||
await rm(vmId);
|
await rm(vmId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a container is running. Returns "running", "stopped", or "missing". */
|
/** Check if a container is running. Returns "running", "stopped", or "missing". */
|
||||||
|
|
@ -50,12 +50,12 @@ export async function status(vmId: string): Promise<"running" | "stopped" | "mis
|
||||||
const result = await $`container inspect ${vmId} --format '{{.State.Status}}'`
|
const result = await $`container inspect ${vmId} --format '{{.State.Status}}'`
|
||||||
.nothrow()
|
.nothrow()
|
||||||
.quiet()
|
.quiet()
|
||||||
.text();
|
.text()
|
||||||
|
|
||||||
const state = result.trim().replace(/'/g, "");
|
const state = result.trim().replace(/'/g, "")
|
||||||
if (state.includes("running")) return "running";
|
if (state.includes("running")) return "running"
|
||||||
if (state.includes("exited") || state.includes("stopped") || state.includes("created")) return "stopped";
|
if (state.includes("exited") || state.includes("stopped") || state.includes("created")) return "stopped"
|
||||||
return "missing";
|
return "missing"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Exec into a container shell interactively. */
|
/** Exec into a container shell interactively. */
|
||||||
|
|
@ -64,8 +64,8 @@ export async function shell(vmId: string): Promise<void> {
|
||||||
stdin: "inherit",
|
stdin: "inherit",
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
});
|
})
|
||||||
await proc.exited;
|
await proc.exited
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List all containers with their names and statuses. */
|
/** List all containers with their names and statuses. */
|
||||||
|
|
@ -73,14 +73,14 @@ export async function list(): Promise<Array<{ id: string; name: string; status:
|
||||||
const result = await $`container ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'`
|
const result = await $`container ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'`
|
||||||
.nothrow()
|
.nothrow()
|
||||||
.quiet()
|
.quiet()
|
||||||
.text();
|
.text()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
const [id, name, status] = line.replace(/'/g, "").split("\t");
|
const [id, name, status] = line.replace(/'/g, "").split("\t")
|
||||||
return { id, name, status };
|
return { id, name, status }
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user