Files
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- 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.
2026-10-08 23:04:57 +07:00

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);
});
});