updates
This commit is contained in:
parent
45bb19a8d9
commit
994c26647d
71
bun.lock
71
bun.lock
|
|
@ -5,7 +5,6 @@
|
|||
"": {
|
||||
"name": "sandlot",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"commander": "^13.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -14,84 +13,14 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "https://npm.nose.space/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||
|
||||
"@types/node": ["@types/node@18.19.130", "https://npm.nose.space/@types/node/-/node-18.19.130.tgz", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "https://npm.nose.space/@types/node-fetch/-/node-fetch-2.6.13.tgz", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "https://npm.nose.space/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "https://npm.nose.space/agentkeepalive/-/agentkeepalive-4.6.0.tgz", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "https://npm.nose.space/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://npm.nose.space/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "https://npm.nose.space/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@13.1.0", "https://npm.nose.space/commander/-/commander-13.1.0.tgz", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "https://npm.nose.space/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://npm.nose.space/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://npm.nose.space/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "https://npm.nose.space/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "https://npm.nose.space/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://npm.nose.space/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "https://npm.nose.space/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "https://npm.nose.space/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"form-data-encoder": ["form-data-encoder@1.7.2", "https://npm.nose.space/form-data-encoder/-/form-data-encoder-1.7.2.tgz", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
|
||||
|
||||
"formdata-node": ["formdata-node@4.4.1", "https://npm.nose.space/formdata-node/-/formdata-node-4.4.1.tgz", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "https://npm.nose.space/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://npm.nose.space/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "https://npm.nose.space/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "https://npm.nose.space/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "https://npm.nose.space/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "https://npm.nose.space/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "https://npm.nose.space/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "https://npm.nose.space/humanize-ms/-/humanize-ms-1.2.1.tgz", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://npm.nose.space/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "https://npm.nose.space/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "https://npm.nose.space/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "https://npm.nose.space/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "https://npm.nose.space/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "https://npm.nose.space/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "https://npm.nose.space/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"undici-types": ["undici-types@5.26.5", "https://npm.nose.space/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "https://npm.nose.space/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "https://npm.nose.space/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "https://npm.nose.space/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
src/cli.ts
133
src/cli.ts
|
|
@ -2,11 +2,10 @@
|
|||
|
||||
import { Command } from "commander"
|
||||
import { join } from "path"
|
||||
|
||||
import * as git from "./git.ts"
|
||||
import * as vm from "./vm.ts"
|
||||
import * as state from "./state.ts"
|
||||
import { loadConfig } from "./config.ts"
|
||||
import { spinner } from "./spinner.ts"
|
||||
|
||||
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
|
||||
|
||||
|
|
@ -19,36 +18,32 @@ program.name("sandlot").description("Branch-based development with git worktrees
|
|||
program
|
||||
.command("new")
|
||||
.argument("<branch>", "branch name")
|
||||
.description("Create a new session with a worktree and VM")
|
||||
.description("Create a new session and launch Claude")
|
||||
.action(async (branch: string) => {
|
||||
const root = await git.repoRoot()
|
||||
const config = await loadConfig(root)
|
||||
const worktreeRel = `.sandlot/${branch}`
|
||||
const worktreeAbs = join(root, worktreeRel)
|
||||
|
||||
// Check for stale directory
|
||||
const existing = await state.getSession(root, branch)
|
||||
if (existing) {
|
||||
console.error(`Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Creating worktree at ${worktreeRel}/`)
|
||||
const spin = spinner("Creating worktree")
|
||||
await git.createWorktree(branch, worktreeAbs, root)
|
||||
|
||||
console.log("Booting VM...")
|
||||
const name = vm.limaName(branch)
|
||||
const vmId = await vm.boot(name, worktreeAbs, config.vm)
|
||||
spin.text = "Starting VM"
|
||||
await vm.ensure()
|
||||
spin.succeed("Session ready")
|
||||
|
||||
await state.setSession(root, {
|
||||
branch,
|
||||
worktree: worktreeRel,
|
||||
vm_id: vmId,
|
||||
created_at: new Date().toISOString(),
|
||||
status: "running",
|
||||
})
|
||||
|
||||
await vm.shell(vmId)
|
||||
await vm.claude(worktreeAbs)
|
||||
})
|
||||
|
||||
// ── sandlot list ──────────────────────────────────────────────────────
|
||||
|
|
@ -66,24 +61,10 @@ program
|
|||
return
|
||||
}
|
||||
|
||||
// Check actual VM statuses
|
||||
const rows: Array<{ branch: string; vmStatus: string; worktree: string }> = []
|
||||
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
|
||||
console.log(`${"BRANCH".padEnd(branchWidth)} WORKTREE`)
|
||||
for (const s of sessions) {
|
||||
const vmStatus = await vm.status(s.vm_id)
|
||||
rows.push({ branch: s.branch, vmStatus, worktree: s.worktree })
|
||||
}
|
||||
|
||||
// Print table
|
||||
const branchWidth = Math.max(6, ...rows.map((r) => r.branch.length))
|
||||
const statusWidth = Math.max(9, ...rows.map((r) => r.vmStatus.length))
|
||||
|
||||
console.log(
|
||||
`${"BRANCH".padEnd(branchWidth)} ${"VM STATUS".padEnd(statusWidth)} WORKTREE`
|
||||
)
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
`${row.branch.padEnd(branchWidth)} ${row.vmStatus.padEnd(statusWidth)} ${row.worktree}/`
|
||||
)
|
||||
console.log(`${s.branch.padEnd(branchWidth)} ${s.worktree}/`)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -92,10 +73,9 @@ program
|
|||
program
|
||||
.command("open")
|
||||
.argument("<branch>", "branch name")
|
||||
.description("Re-enter an existing session's VM")
|
||||
.description("Re-enter an existing session")
|
||||
.action(async (branch: string) => {
|
||||
const root = await git.repoRoot()
|
||||
const config = await loadConfig(root)
|
||||
const session = await state.getSession(root, branch)
|
||||
|
||||
if (!session) {
|
||||
|
|
@ -103,73 +83,62 @@ program
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
const vmStatus = await vm.status(session.vm_id)
|
||||
const spin = spinner("Starting VM")
|
||||
await vm.ensure()
|
||||
spin.succeed("Session ready")
|
||||
|
||||
if (vmStatus === "missing") {
|
||||
// Stale VM, reboot
|
||||
console.log("VM is gone. Rebooting...")
|
||||
const worktreeAbs = join(root, session.worktree)
|
||||
const vmId = await vm.boot(vm.limaName(branch), worktreeAbs, config.vm)
|
||||
await state.setSession(root, { ...session, vm_id: vmId, status: "running" })
|
||||
await vm.shell(vmId)
|
||||
} else if (vmStatus === "stopped") {
|
||||
console.log("Booting VM...")
|
||||
await vm.start(session.vm_id)
|
||||
await state.setSession(root, { ...session, status: "running" })
|
||||
await vm.shell(session.vm_id)
|
||||
} else {
|
||||
await vm.shell(session.vm_id)
|
||||
}
|
||||
await vm.claude(join(root, session.worktree))
|
||||
})
|
||||
|
||||
// ── sandlot stop <branch> ─────────────────────────────────────────────
|
||||
// ── sandlot rm <branch> ──────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("stop")
|
||||
.command("rm")
|
||||
.argument("<branch>", "branch name")
|
||||
.description("Stop a session's VM without destroying it")
|
||||
.description("Remove a worktree and clean up the session")
|
||||
.action(async (branch: string) => {
|
||||
const root = await git.repoRoot()
|
||||
const session = await state.getSession(root, branch)
|
||||
const worktreeRel = session?.worktree ?? `.sandlot/${branch}`
|
||||
const worktreeAbs = join(root, worktreeRel)
|
||||
|
||||
if (!session) {
|
||||
console.error(`No session found for branch "${branch}".`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await vm.stop(session.vm_id)
|
||||
await state.setSession(root, { ...session, status: "stopped" })
|
||||
console.log(`Stopped VM for ${branch}`)
|
||||
})
|
||||
|
||||
// ── sandlot close <branch> ────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("close")
|
||||
.argument("<branch>", "branch name")
|
||||
.description("Tear down a session and switch back to main")
|
||||
.action(async (branch: string) => {
|
||||
const root = await git.repoRoot()
|
||||
const session = await state.getSession(root, branch)
|
||||
|
||||
if (!session) {
|
||||
console.error(`No session found for branch "${branch}".`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await vm.destroy(session.vm_id)
|
||||
console.log(`Stopped VM ${branch}`)
|
||||
|
||||
await git.removeWorktree(join(root, session.worktree), root)
|
||||
console.log(`Removed worktree ${session.worktree}/`)
|
||||
await git.removeWorktree(worktreeAbs, root)
|
||||
console.log(`Removed worktree ${worktreeRel}/`)
|
||||
|
||||
await git.deleteLocalBranch(branch, root)
|
||||
console.log(`Deleted local branch ${branch}`)
|
||||
|
||||
if (session) {
|
||||
await state.removeSession(root, branch)
|
||||
}
|
||||
})
|
||||
|
||||
await git.checkout("main", root)
|
||||
console.log(`Switched to main`)
|
||||
// ── sandlot vm ───────────────────────────────────────────────────────
|
||||
|
||||
const vmCmd = program.command("vm").description("Manage the sandlot VM")
|
||||
|
||||
vmCmd
|
||||
.command("status")
|
||||
.description("Show VM status")
|
||||
.action(async () => {
|
||||
const s = await vm.status()
|
||||
console.log(s)
|
||||
})
|
||||
|
||||
vmCmd
|
||||
.command("stop")
|
||||
.description("Stop the VM")
|
||||
.action(async () => {
|
||||
await vm.stop()
|
||||
console.log("VM stopped")
|
||||
})
|
||||
|
||||
vmCmd
|
||||
.command("destroy")
|
||||
.description("Stop and delete the VM")
|
||||
.action(async () => {
|
||||
await vm.destroy()
|
||||
console.log("VM destroyed")
|
||||
})
|
||||
|
||||
program.parse()
|
||||
|
|
|
|||
17
src/git.ts
17
src/git.ts
|
|
@ -1,3 +1,5 @@
|
|||
import { existsSync } from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import { $ } from "bun"
|
||||
|
||||
/** Get the repo root from a working directory. */
|
||||
|
|
@ -27,15 +29,24 @@ export async function branchExists(branch: string, cwd?: string): Promise<"local
|
|||
|
||||
/** Create a worktree for the given branch. */
|
||||
export async function createWorktree(branch: string, worktreePath: string, cwd: string): Promise<void> {
|
||||
// Clean up stale worktree path if it exists
|
||||
if (existsSync(worktreePath)) {
|
||||
await $`git worktree remove ${worktreePath} --force`.cwd(cwd).nothrow().quiet()
|
||||
if (existsSync(worktreePath)) {
|
||||
await rm(worktreePath, { recursive: true })
|
||||
}
|
||||
}
|
||||
await $`git worktree prune`.cwd(cwd).nothrow().quiet()
|
||||
|
||||
const exists = await branchExists(branch, cwd)
|
||||
|
||||
if (exists === "local") {
|
||||
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd)
|
||||
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd).quiet()
|
||||
} 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).quiet()
|
||||
} else {
|
||||
// New branch from current HEAD
|
||||
await $`git worktree add -b ${branch} ${worktreePath}`.cwd(cwd)
|
||||
await $`git worktree add -b ${branch} ${worktreePath}`.cwd(cwd).quiet()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
22
src/spinner.ts
Normal file
22
src/spinner.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
export function spinner(text: string) {
|
||||
let i = 0
|
||||
const id = setInterval(() => {
|
||||
process.stderr.write(`\r\x1b[2K${frames[i++ % frames.length]} ${text}`)
|
||||
}, 80)
|
||||
|
||||
return {
|
||||
set text(t: string) {
|
||||
text = t
|
||||
},
|
||||
succeed(msg: string) {
|
||||
clearInterval(id)
|
||||
process.stderr.write(`\r\x1b[2K✔ ${msg}\n`)
|
||||
},
|
||||
fail(msg: string) {
|
||||
clearInterval(id)
|
||||
process.stderr.write(`\r\x1b[2K✖ ${msg}\n`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -3,9 +3,7 @@ import { join } from "path"
|
|||
export interface Session {
|
||||
branch: string
|
||||
worktree: string
|
||||
vm_id: string
|
||||
created_at: string
|
||||
status: "running" | "stopped"
|
||||
}
|
||||
|
||||
export interface State {
|
||||
|
|
|
|||
129
src/vm.ts
129
src/vm.ts
|
|
@ -1,85 +1,37 @@
|
|||
import { $ } from "bun"
|
||||
import type { VmConfig } from "./config.ts"
|
||||
import { homedir } from "os"
|
||||
|
||||
/** Sanitize a branch name into a valid Lima instance name. */
|
||||
export function limaName(branch: string): string {
|
||||
return "sandlot-" + branch.replace(/[^a-zA-Z0-9-]/g, "-")
|
||||
const VM_NAME = "sandlot"
|
||||
|
||||
/** Ensure the sandlot VM exists and is running. Creates and provisions on first use. */
|
||||
export async function ensure(): Promise<void> {
|
||||
const s = await status()
|
||||
if (s === "running") return
|
||||
|
||||
if (s === "stopped") {
|
||||
await $`limactl start ${VM_NAME}`.quiet()
|
||||
return
|
||||
}
|
||||
|
||||
/** Map a container image string (e.g. "ubuntu:24.04") to a Lima template. */
|
||||
function mapImageToTemplate(image?: string): string {
|
||||
const img = image ?? "ubuntu:24.04"
|
||||
// "ubuntu:24.04" → "template://ubuntu-24.04"
|
||||
const normalized = img.replace(":", "-")
|
||||
return `template://${normalized}`
|
||||
// Create from scratch
|
||||
const home = homedir()
|
||||
await $`limactl create --name=${VM_NAME} --mount=${home}:w --mount-writable template:ubuntu-24.04`.quiet()
|
||||
await $`limactl start ${VM_NAME}`.quiet()
|
||||
|
||||
// Provision
|
||||
await $`limactl shell ${VM_NAME} -- sudo bash -c "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs && npm install -g @anthropic-ai/claude-code"`.quiet()
|
||||
}
|
||||
|
||||
/** Create and start a Lima VM mapped to a worktree directory. Returns the instance name. */
|
||||
export async function boot(
|
||||
name: string,
|
||||
worktreePath: string,
|
||||
config?: VmConfig
|
||||
): Promise<string> {
|
||||
const args: string[] = ["limactl", "create", `--name=${name}`]
|
||||
|
||||
if (config?.cpus) args.push(`--cpus=${config.cpus}`)
|
||||
if (config?.memory) args.push(`--memory=${config.memory}`)
|
||||
|
||||
// Mount worktree as /root/work
|
||||
args.push(`--mount-writable=${worktreePath}:/root/work`)
|
||||
|
||||
// Additional mounts from config
|
||||
if (config?.mounts) {
|
||||
for (const [source, target] of Object.entries(config.mounts)) {
|
||||
args.push(`--mount-writable=${source}:${target}`)
|
||||
}
|
||||
}
|
||||
|
||||
const template = mapImageToTemplate(config?.image)
|
||||
args.push(template)
|
||||
|
||||
// Don't quiet create so user sees download progress
|
||||
await $`${args}`
|
||||
|
||||
await $`limactl start ${name}`.quiet()
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
/** Start a stopped Lima instance. */
|
||||
export async function start(name: string): Promise<void> {
|
||||
await $`limactl start ${name}`
|
||||
}
|
||||
|
||||
/** Stop a running Lima instance. */
|
||||
export async function stop(name: string): Promise<void> {
|
||||
await $`limactl stop ${name}`.nothrow().quiet()
|
||||
}
|
||||
|
||||
/** Delete a Lima instance. */
|
||||
export async function rm(name: string): Promise<void> {
|
||||
await $`limactl delete ${name}`.nothrow().quiet()
|
||||
}
|
||||
|
||||
/** Stop and delete a Lima instance. */
|
||||
export async function destroy(name: string): Promise<void> {
|
||||
await stop(name)
|
||||
await rm(name)
|
||||
}
|
||||
|
||||
/** Check if a Lima instance is running. Returns "running", "stopped", or "missing". */
|
||||
export async function status(name: string): Promise<"running" | "stopped" | "missing"> {
|
||||
/** Check VM status. */
|
||||
export async function status(): Promise<"running" | "stopped" | "missing"> {
|
||||
const result = await $`limactl list --json`.nothrow().quiet().text()
|
||||
|
||||
// limactl list --json outputs JSONL (one JSON object per line)
|
||||
for (const line of result.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
try {
|
||||
const instance = JSON.parse(line)
|
||||
if (instance.name === name) {
|
||||
const s = instance.status?.toLowerCase()
|
||||
if (s === "running") return "running"
|
||||
return "stopped"
|
||||
if (instance.name === VM_NAME) {
|
||||
return instance.status?.toLowerCase() === "running" ? "running" : "stopped"
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
|
|
@ -89,33 +41,22 @@ export async function status(name: string): Promise<"running" | "stopped" | "mis
|
|||
return "missing"
|
||||
}
|
||||
|
||||
/** Exec into a Lima instance shell interactively. */
|
||||
export async function shell(name: string): Promise<void> {
|
||||
const proc = Bun.spawn(["limactl", "shell", name], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
/** Launch claude in the VM at the given workdir. */
|
||||
export async function claude(workdir: string): Promise<void> {
|
||||
const proc = Bun.spawn(
|
||||
["limactl", "shell", `--workdir=${workdir}`, VM_NAME, "claude", "--dangerously-skip-permissions"],
|
||||
{ stdin: "inherit", stdout: "inherit", stderr: "inherit" },
|
||||
)
|
||||
await proc.exited
|
||||
}
|
||||
|
||||
/** List all sandlot Lima instances with their names and statuses. */
|
||||
export async function list(): Promise<Array<{ name: string; status: string }>> {
|
||||
const result = await $`limactl list --json`.nothrow().quiet().text()
|
||||
|
||||
const instances: Array<{ name: string; status: string }> = []
|
||||
|
||||
for (const line of result.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
try {
|
||||
const instance = JSON.parse(line)
|
||||
if (instance.name?.startsWith("sandlot-")) {
|
||||
instances.push({ name: instance.name, status: instance.status ?? "Unknown" })
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
/** Stop the VM. */
|
||||
export async function stop(): Promise<void> {
|
||||
await $`limactl stop ${VM_NAME}`.nothrow().quiet()
|
||||
}
|
||||
|
||||
return instances
|
||||
/** Stop and delete the VM. */
|
||||
export async function destroy(): Promise<void> {
|
||||
await stop()
|
||||
await $`limactl delete ${VM_NAME}`.nothrow().quiet()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user