diff --git a/bun.lock b/bun.lock index 5596a7a..64dc8db 100644 --- a/bun.lock +++ b/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=="], } } diff --git a/src/cli.ts b/src/cli.ts index 14802a8..7a03bdd 100755 --- a/src/cli.ts +++ b/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 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 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 ───────────────────────────────────────────── +// ── sandlot rm ────────────────────────────────────────────── program - .command("stop") + .command("rm") .argument("", "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 ──────────────────────────────────────────── - -program - .command("close") - .argument("", "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}`) - await state.removeSession(root, 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() diff --git a/src/git.ts b/src/git.ts index 9d724cd..102bd73 100644 --- a/src/git.ts +++ b/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 { + // 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() } } diff --git a/src/spinner.ts b/src/spinner.ts new file mode 100644 index 0000000..7ef14d0 --- /dev/null +++ b/src/spinner.ts @@ -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`) + }, + } +} diff --git a/src/state.ts b/src/state.ts index 2115aba..09c34f6 100644 --- a/src/state.ts +++ b/src/state.ts @@ -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 { diff --git a/src/vm.ts b/src/vm.ts index 65ea5ea..32abcd2 100644 --- a/src/vm.ts +++ b/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" -/** 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}` -} +/** Ensure the sandlot VM exists and is running. Creates and provisions on first use. */ +export async function ensure(): Promise { + const s = await status() + if (s === "running") return -/** 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 { - 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}`) - } + if (s === "stopped") { + await $`limactl start ${VM_NAME}`.quiet() + return } - const template = mapImageToTemplate(config?.image) - args.push(template) + // 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() - // Don't quiet create so user sees download progress - await $`${args}` - - await $`limactl start ${name}`.quiet() - - return name + // 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() } -/** Start a stopped Lima instance. */ -export async function start(name: string): Promise { - await $`limactl start ${name}` -} - -/** Stop a running Lima instance. */ -export async function stop(name: string): Promise { - await $`limactl stop ${name}`.nothrow().quiet() -} - -/** Delete a Lima instance. */ -export async function rm(name: string): Promise { - await $`limactl delete ${name}`.nothrow().quiet() -} - -/** Stop and delete a Lima instance. */ -export async function destroy(name: string): Promise { - 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 { - 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 { + 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> { - 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 - } - } - - return instances +/** Stop the VM. */ +export async function stop(): Promise { + await $`limactl stop ${VM_NAME}`.nothrow().quiet() +} + +/** Stop and delete the VM. */ +export async function destroy(): Promise { + await stop() + await $`limactl delete ${VM_NAME}`.nothrow().quiet() }