/** * @file Tests Uninstall AgentHub (scripts/uninstall.js, `agenthub uninstall`, * POST /api/settings/uninstall): on a throwaway HOME with every footprint in * place, everything of AgentHub's goes and everything else stays — foreign * hooks, other plugins and marketplaces, conversations, worktrees. Service * manager, tmux, servers and the `claude` CLI are fakes; nothing real is touched. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-uninstall-")); const CH = path.join(HOME, ".claude"); const saved = { ...process.env }; Object.assign(process.env, { HOME, CLAUDE_HOME: CH, CODEX_HOME: path.join(HOME, ".codex"), AGENTHUB_GEMINI_DIR: path.join(HOME, ".gemini"), LANES_ROOT: path.join(CH, "agenthub-lanes"), AGENTHUB_SETTINGS_FILE: path.join(HOME, ".config", "agenthub", "settings.env"), TMPDIR: path.join(HOME, "tmp"), }); delete process.env.DASHBOARD_DATA_DIR; delete process.env.DASHBOARD_DB_PATH; delete process.env.AGENTHUB_RUN_ID; const uninstall = require("../../scripts/uninstall"); const codexHooks = require("../lib/codex-hooks"); const agyHooks = require("../lib/antigravity-hooks"); const p = (...parts) => path.join(HOME, ...parts); const write = (file, body) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof body === "string" ? body : JSON.stringify(body, null, 2)); }; const json = (file) => JSON.parse(fs.readFileSync(file, "utf8")); const cmd = (c) => ({ hooks: [{ type: "command", command: c }] }); function fakeIo() { const calls = []; let sessions = ["agenthub-lane-1", "agenthub-lane-1-2", "work"]; let pids = [111]; const installed = path.join(CH, "plugins", "installed_plugins.json"); const known = path.join(CH, "plugins", "known_marketplaces.json"); return { calls, svc: { label: "systemd --user service", systemctlAvailable: () => true, unitIsOurs: () => true, unitPath: () => "/fake/agenthub.service", uninstallUserService: () => (calls.push("service"), "removed"), }, tmux: { listSessions: (prefix) => sessions.filter((s) => s.startsWith(prefix)), killSession: (n) => { calls.push(`kill ${n}`); sessions = sessions.filter((s) => s !== n); }, }, livePids: () => pids, stopDashboard: () => (calls.push("stop"), (pids = [])), liveTray: () => null, stopTray: () => {}, claude: () => "claude", // Plays Claude Code: drops the record it was asked to drop. run: (_c, args) => { calls.push(args.join(" ")); if (args[1] === "uninstall") { const d = json(installed); delete d.plugins[args[2]]; write(installed, d); } else { const d = json(known); delete d[args[3]]; write(known, d); } return { status: 0 }; }, kill: (pid) => { if (pid !== 4242) throw new Error("ESRCH"); }, sleep: () => {}, }; } before(() => { fs.mkdirSync(p("tmp"), { recursive: true }); write(p(".claude", "settings.json"), { hooks: { PreToolUse: [cmd('node "/x/agenthub/scripts/hook-handler.js" PreToolUse'), cmd("their-hook")], SessionStart: [cmd('node "/c/agenthub/v1/scripts/plugin-bootstrap.js"')], }, statusLine: { type: "command", command: 'node "/x/agenthub/statusline/statusline.js"' }, theme: "dark", }); write(p(".claude", "settings.json.agenthub-bak"), "{}"); write(p(".claude", "plugins", "installed_plugins.json"), { plugins: { "agenthub@sg": [{ scope: "user", installPath: p(".claude/plugins/cache/sg/agenthub/v1") }], "agenthub-analytics@agenthub": [{ scope: "user" }], "sg-all@sg": [{ scope: "user" }], }, }); write(p(".claude", "plugins", "known_marketplaces.json"), { sg: {}, agenthub: {} }); write(p(".claude", "plugins", "cache", "sg", "agenthub", "v1", "package.json"), "{}"); write(p(".claude", "plugins", "cache", "sg", "sg-all", "v1", "package.json"), "{}"); write(p(".claude", "projects", "repo", "session.jsonl"), "{}\n"); write(p(".claude", ".agenthub.json"), "{}"); write(p(".claude", "agenthub-proxies", "a.json"), "{}"); write(p(".claude", "agenthub-git-credentials", "credentials.json"), "{}"); write(p(".claude", "agenthub", "dashboard.db"), "db"); write(p(".claude", "agenthub", "runtime", "state.json"), "{}"); write(p(".claude", "agenthub-lanes", ".state", "lane1", "run", "web.pid"), "4242"); write(p(".claude", "agenthub-lanes", "repo__feature", "README.md"), "user code"); write(p(".codex", "hooks.json"), { hooks: { Stop: [cmd("their-codex-hook")] } }); codexHooks.install(); write(p(".codex", "work.config.toml"), "# Written by AgentHub — AI Proxy profile work.\n"); write(p(".codex", "mine.config.toml"), "model = 'x'\n"); write(p(".codex", "sessions", "rollout-1.jsonl"), "{}\n"); agyHooks.install(); write(p(".local", "bin", "agenthub"), "#!/bin/sh\n# agenthub-plugin-launcher\n"); write(p(".config", "agenthub", "settings.env"), "CODEX_HOME=x\n"); }); after(() => { process.env = saved; fs.rmSync(HOME, { recursive: true, force: true }); }); describe("uninstall", () => { it("plans every footprint and leaves the machine untouched", () => { const io = fakeIo(); const keys = uninstall.plan(io).map((s) => s.key); assert.deepEqual(keys, [ "panes", "service", "servers", "stacks", "claude-hooks", "statusline", "codex-hooks", "antigravity-hooks", "codex-proxies", "plugin", "files", "data", ]); assert.deepEqual(io.calls, []); assert.ok(fs.existsSync(p(".claude", "agenthub", "dashboard.db"))); }); it("removes AgentHub's and keeps everything else", () => { const io = fakeIo(); const r = uninstall.run({ io }); assert.equal(r.ok, true, JSON.stringify(r.steps)); // Order: panes before the unit before the server; plugin via the CLI. assert.deepEqual(io.calls, [ "kill agenthub-lane-1", "kill agenthub-lane-1-2", "service", "stop", "plugin uninstall agenthub@sg -s user -y", "plugin uninstall agenthub-analytics@agenthub -s user -y", "plugin marketplace remove agenthub", ]); const settings = json(p(".claude", "settings.json")); assert.deepEqual(settings.hooks, { PreToolUse: [cmd("their-hook")] }); assert.equal(settings.statusLine, undefined); assert.equal(settings.theme, "dark"); const codex = json(p(".codex", "hooks.json")); assert.deepEqual(Object.keys(codex.hooks), ["Stop"]); assert.ok(!fs.existsSync(p(".codex", "agenthub-hook.sh"))); assert.ok(!fs.existsSync(p(".codex", "work.config.toml"))); assert.ok(!fs.existsSync(p(".gemini", "config", "agenthub-hook.sh"))); for (const gone of [ ".claude/agenthub", ".claude/agenthub-proxies", ".claude/agenthub-git-credentials", ".claude/.agenthub.json", ".claude/settings.json.agenthub-bak", ".claude/agenthub-lanes/.state", ".claude/plugins/cache/sg/agenthub", ".local/bin/agenthub", ".config/agenthub", ]) { assert.ok(!fs.existsSync(p(gone)), `${gone} should be gone`); } for (const kept of [ ".claude/projects/repo/session.jsonl", ".claude/agenthub-lanes/repo__feature/README.md", ".claude/plugins/cache/sg/sg-all", ".codex/mine.config.toml", ".codex/sessions/rollout-1.jsonl", ]) { assert.ok(fs.existsSync(p(kept)), `${kept} should be kept`); } assert.deepEqual(Object.keys(json(p(".claude/plugins/known_marketplaces.json"))), ["sg"]); assert.ok(fs.readFileSync(r.log, "utf8").includes("AgentHub uninstalled.")); }); it("finds nothing left on disk on a second run", () => { const live = ["panes", "service", "servers"]; // the fakes' own processes assert.deepEqual( uninstall.plan(fakeIo()).filter((s) => !live.includes(s.key)), [] ); }); it("keeps the cache when Claude Code still records the plugin", () => { write(p(".claude", "plugins", "installed_plugins.json"), { plugins: { "agenthub@sg": [{ scope: "user" }] }, }); write(p(".claude", "plugins", "cache", "sg", "agenthub", "v2", "package.json"), "{}"); const io = { ...fakeIo(), run: () => ({ status: 1, stderr: "boom" }) }; const r = uninstall.run({ io }); assert.equal(r.ok, false); assert.equal(r.steps.find((s) => s.key === "plugin").status, "failed"); assert.ok(fs.existsSync(p(".claude", "plugins", "cache", "sg", "agenthub", "v2"))); }); }); describe("isSafeDataDir", () => { it("never deletes the home, CLAUDE_HOME, their parents or a stranger's folder", () => { assert.equal(uninstall.isSafeDataDir(HOME), false); assert.equal(uninstall.isSafeDataDir(CH), false); assert.equal(uninstall.isSafeDataDir(path.dirname(HOME)), false); assert.equal(uninstall.isSafeDataDir("/"), false); assert.equal(uninstall.isSafeDataDir(p("Documents")), false); assert.equal(uninstall.isSafeDataDir(p(".claude", "agenthub")), true); }); }); describe("Claude hook matching", () => { it("is AgentHub's handler or bootstrap only", () => { assert.equal(uninstall.isOurHook(cmd('node "/a/scripts/hook-handler.js" Stop')), true); assert.equal(uninstall.isOurHook(cmd('node "/a/scripts/plugin-bootstrap.js"')), true); assert.equal(uninstall.isOurHook(cmd("their-hook")), false); }); });