b7f3735b29
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
250 lines
9.4 KiB
JavaScript
250 lines
9.4 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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);
|
|
});
|
|
});
|