diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 86b608f..2d8f8f7 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -5,16 +5,18 @@ import path from "node:path"; import { runScript } from "../src/runtime.mjs"; import { moshVocabulary } from "../src/commands.mjs"; import { - ENGINES, agentLaunchArgs, engineList, + ENGINES, engineStatus, - resolveEngine, openSession, + resolveEngine, + resolveExecutable, runCmd, } from "../src/engines.mjs"; import { TOOLS, toolList, toolStatus, resolveTool, openTool } from "../src/tools.mjs"; import { runUpgrade } from "../src/upgrade.mjs"; +import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs"; import { mcpCommand, skillCommand } from "../src/integrations.mjs"; import { locate, tilde } from "../src/pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs"; @@ -150,6 +152,7 @@ usage: moshcode skill install install a skill across every engine that supports it (claude/gemini) moshcode install install a coding engine or workflow tool + moshcode uninstall take one back off this machine moshcode upgrade [target…] update moshcode + installed engines/tools (no args = everything; name targets to narrow, e.g. \`upgrade ugig\`) @@ -288,6 +291,59 @@ async function main() { if (result.code === 0) console.log(`\nβœ“ ${target} installed. run it with \`${bin}\`. 🀘`); return backToPit(`install ${target}`, result.code); } + if (cmd === "uninstall" || cmd === "remove") { + const target = rest.find((a) => !a.startsWith("-"))?.toLowerCase(); + const entry = target + && ((Object.hasOwn(ENGINES, target) && ENGINES[target]) || (Object.hasOwn(TOOLS, target) && TOOLS[target])); + if (!target || !entry) { + console.error(`usage: moshcode uninstall \nengines:\n${engineList()}\ntools:\n${toolList()}`); + process.exit(target ? 1 : 0); + } + + const binPath = resolveExecutable(entry.bin, entry.binDirs); + const plan = uninstallPlan(entry, { binPath }); + + if (plan.kind === "absent" || plan.kind === "refused") { + for (const w of plan.warnings) console.error(w); + process.exitCode = plan.kind === "refused" ? 1 : 0; + return; + } + + console.log(`🎸 uninstalling ${target} β€” ${entry.desc}`); + console.log(describeUninstall(plan)); + if (rest.includes("--dry-run")) return; + + // Removing a binary is not something to do because a flag was left off. + // An npm uninstall is reversible with one command and does not ask. + if (plan.kind === "binary" && !rest.includes("--yes") && !rest.includes("-y")) { + console.error(`\nthis deletes ${binPath}. re-run with --yes to do it.`); + process.exitCode = 1; + return; + } + + for (const step of plan.steps) { + if (step.kind === "remove") { + try { + fs.rmSync(step.path, { force: true }); + console.log(`\nβœ“ removed ${step.path}`); + } catch (err) { + console.error(`could not remove ${step.path}: ${err.message}`); + process.exitCode = 1; + return; + } + } else { + const result = await runCmd(step.command, step.args); + if (!result.ok || result.code !== 0) { + console.error(`uninstall failed: ${result.error?.message || `exit ${result.code}`}`); + process.exitCode = 1; + return; + } + console.log(`\nβœ“ ${target} uninstalled. 🀘`); + } + } + return backToPit(`uninstall ${target}`, 0); + } + if (cmd === "upgrade" || cmd === "update") { console.log("🎸 moshcode upgrade β€” updating moshcode + installed engines/tools 🀘"); const results = await runUpgrade(rest); diff --git a/src/engines.mjs b/src/engines.mjs index 0679fb2..f8de7d3 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -130,7 +130,7 @@ function executableCandidates(bin, extraDirs = []) { return candidates; } -function resolveExecutable(bin, extraDirs = []) { +export function resolveExecutable(bin, extraDirs = []) { for (const candidate of executableCandidates(bin, extraDirs)) { try { if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; diff --git a/src/uninstall.mjs b/src/uninstall.mjs new file mode 100644 index 0000000..91a90a1 --- /dev/null +++ b/src/uninstall.mjs @@ -0,0 +1,112 @@ +// Taking a tool back off the machine. +// +// `moshcode install` runs whatever each tool ships as its installer, and those +// come in two shapes: `npm install -g `, and `curl … | sh`. Only the first +// has an inverse anyone wrote down. The second drops a binary somewhere and +// leaves no record, so removing it means finding that binary and deleting it β€” +// which is a different kind of operation and is treated like one here. +// +// The rules that follow from that: +// +// - an npm install is undone by npm, which knows what it put where +// - a script install is undone by removing the binary the tool reports, and +// only when it sits somewhere a per-user installer would plausibly have put +// it. `/usr/bin/git` is not something this should ever offer to delete +// - anything else says so rather than guessing +// +// Plans are data so the whole decision is testable without deleting anything, +// and so `--dry-run` can show exactly what would go. + +import { dirname } from "node:path"; +import { homedir } from "node:os"; + +/** + * Where a per-user installer legitimately puts a binary. + * + * A deny-list would be the wrong shape here: the question is not "is this + * dangerous" but "did something we ran plausibly create this", and only an + * allow-list answers that. A binary in /usr/bin arrived from the system package + * manager and is not ours to remove. + */ +export function safePrefixes(home = homedir()) { + return [ + `${home}/.local/bin`, + `${home}/.bun/bin`, + `${home}/.cargo/bin`, + `${home}/.deno/bin`, + `${home}/bin`, + `${home}/.npm-global/bin`, + `${home}/.volta/bin`, + "/usr/local/bin", + "/opt/homebrew/bin", + ]; +} + +/** The npm package an install spec would install, or null if it is not npm. */ +export function npmPackageOf(install) { + if (!install) return null; + const npmish = ["npm", "pnpm", "yarn", "bun"].includes(install.cmd); + if (!npmish) return null; + const args = install.args || []; + if (!args.includes("-g") && !args.includes("--global")) return null; + // The package is the last argument that is not a flag or a subcommand. + const skip = new Set(["install", "add", "i", "-g", "--global"]); + const pkg = [...args].reverse().find((a) => !a.startsWith("-") && !skip.has(a)); + return pkg || null; +} + +/** + * How to remove `entry`, given where its binary currently is. + * + * `binPath` is what `which ` reported, or null when it is not on PATH. + */ +export function uninstallPlan(entry, { binPath = null, home = homedir() } = {}) { + const pkg = npmPackageOf(entry?.install); + if (pkg) { + return { + kind: "npm", + steps: [{ kind: "run", command: entry.install.cmd, args: ["uninstall", "-g", pkg] }], + warnings: [], + }; + } + + if (!binPath) { + return { + kind: "absent", + steps: [], + warnings: [`${entry?.bin || "it"} is not on your PATH β€” nothing to remove`], + }; + } + + const dir = dirname(binPath); + if (!safePrefixes(home).includes(dir)) { + // Refused rather than confirmed-with-a-scary-prompt: a binary here came + // from somewhere else, and the person who put it there knows how to remove + // it. Deleting it because a prompt was clicked through is worse than not + // offering. + return { + kind: "refused", + steps: [], + warnings: [ + `${binPath} is not in a directory moshcode installs into.`, + "It was put there by something else β€” a system package manager, or by hand β€” so removing it is that thing's job.", + ], + }; + } + + return { + kind: "binary", + steps: [{ kind: "remove", path: binPath }], + warnings: [ + "This removes the binary only. Anything it wrote to your home directory β€” config, caches, credentials β€” stays.", + ], + }; +} + +/** The plan as a line someone can read before agreeing to it. */ +export function describeUninstall(plan) { + return plan.steps + .map((s) => (s.kind === "run" ? `run ${s.command} ${s.args.join(" ")}` : `remove ${s.path}`)) + .concat((plan.warnings || []).map((w) => `note ${w}`)) + .join("\n"); +} diff --git a/test/uninstall.test.mjs b/test/uninstall.test.mjs new file mode 100644 index 0000000..e7eb4da --- /dev/null +++ b/test/uninstall.test.mjs @@ -0,0 +1,75 @@ +// Taking a tool back off the machine. +// +// The dangerous branch is the one that deletes a file, so most of this is about +// when it refuses to. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { describeUninstall, npmPackageOf, safePrefixes, uninstallPlan } from "../src/uninstall.mjs"; + +const HOME = "/home/someone"; +const npmEntry = { bin: "claude", desc: "…", install: { cmd: "npm", args: ["install", "-g", "@anthropic-ai/claude-code"] } }; +const scriptEntry = { bin: "opencode", desc: "…", install: { cmd: "bash", args: ["-c", "curl -fsSL https://opencode.ai/install | bash"] } }; + +test("an npm install is undone by npm", () => { + const plan = uninstallPlan(npmEntry, { binPath: "/usr/bin/claude", home: HOME }); + + // npm knows what it put where, so the binary's location does not matter β€” + // note this passes a path that the binary branch would refuse. + assert.equal(plan.kind, "npm"); + assert.deepEqual(plan.steps, [{ kind: "run", command: "npm", args: ["uninstall", "-g", "@anthropic-ai/claude-code"] }]); +}); + +test("the package name is read out of the install spec", () => { + assert.equal(npmPackageOf({ cmd: "npm", args: ["install", "-g", "@openai/codex"] }), "@openai/codex"); + assert.equal(npmPackageOf({ cmd: "pnpm", args: ["add", "-g", "thing"] }), "thing"); + assert.equal(npmPackageOf({ cmd: "npm", args: ["install", "--global", "thing"] }), "thing"); + + // Not npm, or not global β€” neither has an npm inverse. + assert.equal(npmPackageOf({ cmd: "bash", args: ["-c", "curl … | bash"] }), null); + assert.equal(npmPackageOf({ cmd: "npm", args: ["install", "thing"] }), null, "a local install is not ours"); + assert.equal(npmPackageOf(null), null); +}); + +test("a script install removes the binary it dropped", () => { + const plan = uninstallPlan(scriptEntry, { binPath: `${HOME}/.local/bin/opencode`, home: HOME }); + + assert.equal(plan.kind, "binary"); + assert.deepEqual(plan.steps, [{ kind: "remove", path: `${HOME}/.local/bin/opencode` }]); + // Said out loud, because "uninstalled" reads as "gone" and it is not. + assert.match(plan.warnings.join(" "), /config, caches, credentials β€” stays/); +}); + +test("it refuses to delete a binary it could not have installed", () => { + // The whole point of the allow-list. A binary here came from a package + // manager or from someone's own hands, and deleting it because a prompt was + // clicked through is worse than not offering. + for (const path of ["/usr/bin/opencode", "/bin/opencode", "/snap/bin/opencode", "/etc/opencode"]) { + const plan = uninstallPlan(scriptEntry, { binPath: path, home: HOME }); + assert.equal(plan.kind, "refused", path); + assert.equal(plan.steps.length, 0, `${path} must produce no steps`); + assert.match(plan.warnings.join(" "), /not in a directory moshcode installs into/); + } +}); + +test("the places a per-user installer legitimately writes are allowed", () => { + for (const dir of safePrefixes(HOME)) { + const plan = uninstallPlan(scriptEntry, { binPath: `${dir}/opencode`, home: HOME }); + assert.equal(plan.kind, "binary", dir); + } +}); + +test("a tool that is not there says so rather than failing", () => { + const plan = uninstallPlan(scriptEntry, { binPath: null, home: HOME }); + assert.equal(plan.kind, "absent"); + assert.equal(plan.steps.length, 0); + assert.match(plan.warnings.join(" "), /not on your PATH/); +}); + +test("a plan reads as something you can agree to first", () => { + assert.match(describeUninstall(uninstallPlan(npmEntry, { home: HOME })), /run\s+npm uninstall -g/); + assert.match( + describeUninstall(uninstallPlan(scriptEntry, { binPath: `${HOME}/bin/opencode`, home: HOME })), + /remove\s+\/home\/someone\/bin\/opencode/, + ); +});