Files
AgentHub/scripts/uninstall.js
T
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

464 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* @file `agenthub uninstall` and Settings → System's **Uninstall AgentHub**:
* removes everything AgentHub put on this machine, in an order that keeps a
* running piece from putting anything back — terminals, then the service unit
* (else `Restart=always` revives the server), servers, tray icon, lane stacks,
* the hooks and statusline it wrote into Claude Code / Codex / Antigravity
* config (only its own entries), the plugin itself, then its files and data
* dir LAST (pure fs, so this script's own cache dir may already be gone).
*
* Never touched: the AI tools' own conversations (`~/.claude/projects`,
* `~/.codex/sessions`, …), lane worktrees and branches, anything written into
* a user's repo, config backups, a marketplace other than `agenthub`.
*
* Every module is required up front: the plugin cache and runtime this file
* runs from are deleted mid-run. Each step's outcome goes to
* `$TMPDIR/agenthub-uninstall-<uid>.log`, outside everything removed. Run as a
* script (`--yes`) it is the detached worker `POST /api/settings/uninstall`
* spawns; `bin/agenthub.js` calls `plan()`/`run()` in-process.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const { getClaudeHome, getDataDir, getSettingsPath } = require("../server/lib/claude-home");
const { getCodexHome } = require("../server/lib/codex-home");
const { settingsFilePath, loadEnvFiles } = require("../server/lib/settings-env");
const { getServerInfoPath } = require("../server/lib/server-info");
const { isOurEntry } = require("./install-hooks");
const statusline = require("../statusline/install");
const codexHooks = require("../server/lib/codex-hooks");
const agyHooks = require("../server/lib/antigravity-hooks");
const { resolveClaude } = require("./plugin-update-helper");
const REPO_ROOT = path.resolve(__dirname, "..");
const MARKETPLACE = "agenthub"; // .claude-plugin/marketplace.json — never a shared one
const uid = () => (process.getuid ? process.getuid() : "user");
const logPath = () => path.join(os.tmpdir(), `agenthub-uninstall-${uid()}.log`);
const lanesRoot = () =>
process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
function defaultIo() {
const bootstrap = require("./plugin-bootstrap");
const tray = require("../server/lib/tray");
return {
svc: require("./service-manager"),
tmux: require("../server/lib/tmux"),
livePids: bootstrap.livePids,
stopDashboard: bootstrap.stopDashboard,
liveTray: tray.liveTray,
stopTray: tray.stopTray,
claude: resolveClaude,
run: (cmd, args, opts) => spawnSync(cmd, args, { encoding: "utf8", timeout: 120_000, ...opts }),
kill: (pid, sig) => process.kill(pid, sig),
sleep: (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
};
}
const exists = (p) => {
try {
fs.lstatSync(p);
return true;
} catch {
return false;
}
};
const rm = (p) => fs.rmSync(p, { recursive: true, force: true });
const readJson = (p) => {
try {
return JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return null;
}
};
const alive = (io, pid) => {
try {
io.kill(pid, 0);
return true;
} catch {
return false;
}
};
/** `dir` is ancestor-or-equal of `p`. */
function covers(dir, p) {
const r = path.relative(path.resolve(dir), path.resolve(p));
return r === "" || (!r.startsWith("..") && !path.isAbsolute(r));
}
/**
* A data dir is deleted whole only when it cannot be the user's own folder:
* never `/`, the home, CLAUDE_HOME or this checkout (or any of their parents —
* `DASHBOARD_DATA_DIR` is free text), and it must look like ours.
*/
function isSafeDataDir(dir) {
if ([os.homedir(), getClaudeHome(), REPO_ROOT].some((p) => covers(dir, p))) return false;
return (
["agenthub", "agent-dashboard"].includes(path.basename(path.resolve(dir))) ||
exists(path.join(dir, "dashboard.db"))
);
}
/** Claude Code hook entries of ours: a checkout's hook-handler.js, a plugin's bootstrap. */
const isOurHook = (e) => isOurEntry(e) || JSON.stringify(e).includes("plugin-bootstrap.js");
function ourHookCount() {
const hooks = readJson(getSettingsPath())?.hooks || {};
return Object.values(hooks)
.filter(Array.isArray)
.reduce((n, list) => n + list.filter(isOurHook).length, 0);
}
function removeOurHooks() {
const file = getSettingsPath();
const settings = readJson(file);
for (const [type, list] of Object.entries(settings.hooks || {})) {
if (!Array.isArray(list)) continue;
const kept = list.filter((e) => !isOurHook(e));
if (kept.length) settings.hooks[type] = kept;
else delete settings.hooks[type];
}
if (settings.hooks && !Object.keys(settings.hooks).length) delete settings.hooks;
const backup = `${file}.agenthub-uninstall.bak`;
fs.copyFileSync(file, backup);
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n", "utf8");
return `backup: ${backup}`;
}
/** `agenthub@<any>` plus every plugin of AgentHub's own marketplace. */
function ourPlugins() {
const data = readJson(path.join(getClaudeHome(), "plugins", "installed_plugins.json"));
const out = [];
for (const [id, entries] of Object.entries(data?.plugins || {})) {
if (!id.startsWith("agenthub@") && !id.endsWith(`@${MARKETPLACE}`)) continue;
for (const e of entries || []) out.push({ id, scope: e.scope || "user", cwd: e.projectPath });
}
return out;
}
function marketplaceKnown() {
const known = readJson(path.join(getClaudeHome(), "plugins", "known_marketplaces.json"));
return !!known && MARKETPLACE in known;
}
function cacheDirs() {
const base = path.join(getClaudeHome(), "plugins", "cache");
let mkts = [];
try {
mkts = fs.readdirSync(base);
} catch {
return [];
}
return mkts.map((m) => path.join(base, m, "agenthub")).filter(exists);
}
function launchers() {
const dir = path.join(os.homedir(), ".local", "bin");
return ["agenthub", "agenthub.cmd"]
.map((n) => path.join(dir, n))
.filter((p) => {
try {
return fs.readFileSync(p, "utf8").includes("agenthub-plugin-launcher");
} catch {
return false;
}
});
}
function stackPids(io) {
const state = path.join(lanesRoot(), ".state");
const out = [];
let lanes = [];
try {
lanes = fs.readdirSync(state);
} catch {
return out;
}
for (const lane of lanes) {
const run = path.join(state, lane, "run");
let files = [];
try {
files = fs.readdirSync(run).filter((f) => f.endsWith(".pid"));
} catch {
continue;
}
for (const f of files) {
const pid = Number(fs.readFileSync(path.join(run, f), "utf8").trim());
if (Number.isInteger(pid) && pid > 1 && alive(io, pid)) out.push(pid);
}
}
return out;
}
function codexProxyLayers() {
const home = getCodexHome();
let names = [];
try {
names = fs.readdirSync(home).filter((n) => n.endsWith(".config.toml"));
} catch {
return [];
}
return names
.map((n) => path.join(home, n))
.filter((p) => {
try {
return fs.readFileSync(p, "utf8").startsWith("# Written by AgentHub");
} catch {
return false;
}
});
}
function hookFiles(mod) {
const s = mod.status();
const any = Object.values(s.events || {}).some((e) => e.installed);
return [any && s.path, exists(s.script) && s.script].filter(Boolean);
}
/** Files and directories that are AgentHub's alone, deleted whole. */
function ownFiles() {
const ch = getClaudeHome();
const lanes = lanesRoot();
let backups = [];
try {
const dir = path.dirname(getSettingsPath());
backups = fs
.readdirSync(dir)
.filter((n) => /^settings\.json\.agenthub-(bak\d*|backup-.+)$/.test(n))
.map((n) => path.join(dir, n));
} catch {
/* no CLAUDE_HOME */
}
return [
...launchers(),
settingsFilePath(),
getServerInfoPath(),
path.join(ch, ".agent-dashboard.json"),
...["proxies", "routers", "git-credentials", "accounts", "profiles"].map((n) =>
path.join(ch, `agenthub-${n}`)
),
...backups,
path.join(lanes, ".state"),
path.join(lanes, ".locks"),
path.join(os.homedir(), "Library", "Logs", "agenthub.log"),
path.join(os.tmpdir(), `agenthub-statusline-${uid()}.json`),
path.join(os.tmpdir(), `agenthub-drops-${uid()}`),
].filter(exists);
}
function dataDirs() {
const ch = getClaudeHome();
const dirs = new Set([getDataDir(), path.join(ch, "agenthub"), path.join(ch, "agent-dashboard")]);
const out = [...dirs].filter(exists);
const db = process.env.DASHBOARD_DB_PATH;
if (db && /\.db$/.test(db) && !out.some((d) => covers(d, db))) {
out.push(...[db, `${db}-wal`, `${db}-shm`].filter(exists));
}
return out;
}
/**
* The steps, in order. `find` lists what is there (empty = nothing to do, the
* step is skipped); `remove` takes it away and may return a note for the log.
*/
function steps(io) {
const self = process.env.AGENTHUB_RUN_ID || null; // the pane this runs in, if any
return [
{
key: "panes",
label: "Close every workspace terminal",
find: () => io.tmux.listSessions("agenthub-lane-").filter((n) => n !== self),
remove: (names) => names.forEach((n) => io.tmux.killSession(n)),
},
{
key: "service",
label: `Remove the ${io.svc.label}`,
find: () =>
io.svc.systemctlAvailable() && io.svc.unitIsOurs("agenthub")
? [io.svc.unitPath("agenthub")]
: [],
remove: () => io.svc.uninstallUserService("agenthub"),
},
{
key: "servers",
label: "Stop the dashboard server",
find: () => io.livePids().map((p) => `pid ${p}`),
remove: () => {
io.stopDashboard();
for (let i = 0; i < 50 && io.livePids().length; i++) io.sleep(200);
if (io.livePids().length) throw new Error(`still running: ${io.livePids().join(", ")}`);
},
},
{
key: "tray",
label: "Close the status-area icon",
find: () => (io.liveTray() ? [`pid ${io.liveTray().pid}`] : []),
remove: () => io.stopTray(),
},
{
key: "stacks",
label: "Stop workspace stacks",
find: () => stackPids(io).map((p) => `pid ${p}`),
remove: () => stackPids(io).forEach((p) => io.kill(p, "SIGTERM")),
},
{
key: "claude-hooks",
label: "Remove AgentHub's hooks from Claude Code settings",
find: () => (ourHookCount() ? [`${getSettingsPath()} (${ourHookCount()} entries)`] : []),
remove: removeOurHooks,
},
{
key: "statusline",
label: "Remove AgentHub's statusline",
find: () =>
statusline.isOurs(readJson(getSettingsPath())?.statusLine) ? [getSettingsPath()] : [],
remove: () => statusline.uninstall(getSettingsPath()).status,
},
{
key: "codex-hooks",
label: "Remove AgentHub's Codex hooks",
find: () => hookFiles(codexHooks),
remove: () => void codexHooks.remove(),
},
{
key: "antigravity-hooks",
label: "Remove AgentHub's Antigravity hook",
find: () => hookFiles(agyHooks),
remove: () => void agyHooks.remove(),
},
{
key: "codex-proxies",
label: "Remove AI Proxy profiles from Codex",
find: codexProxyLayers,
remove: (files) => files.forEach(rm),
},
{
key: "plugin",
label: "Uninstall the AgentHub plugin(s) from Claude Code",
find: () => [
...ourPlugins().map((p) => `${p.id} (${p.scope})`),
...(marketplaceKnown() ? [`marketplace ${MARKETPLACE}`] : []),
],
remove: () => {
const claude = io.claude();
if (!claude)
throw new Error("`claude` CLI not found — run `claude plugin uninstall` yourself");
const failed = [];
const call = (args, cwd) => {
const r = io.run(claude, args, cwd && exists(cwd) ? { cwd } : {});
if (r.status !== 0)
failed.push(`${args.join(" ")}: ${(r.stderr || r.stdout || "").trim()}`);
};
for (const p of ourPlugins())
call(["plugin", "uninstall", p.id, "-s", p.scope, "-y"], p.cwd);
if (marketplaceKnown()) call(["plugin", "marketplace", "remove", MARKETPLACE]);
if (failed.length) throw new Error(failed.join("; "));
},
},
{
key: "files",
label: "Delete AgentHub's files, settings and stored logins",
find: ownFiles,
remove: (paths) => {
paths.forEach(rm);
for (const dir of [path.dirname(settingsFilePath()), lanesRoot()]) {
try {
fs.rmdirSync(dir); // only when nothing else is left in it
} catch {
/* not empty, or gone */
}
}
},
},
{
key: "cache",
label: "Delete the plugin's cached copies",
// Claude Code still recording an install means its uninstall failed:
// deleting the files then would leave it pointing at nothing.
find: () => (ourPlugins().some((p) => p.id.startsWith("agenthub@")) ? [] : cacheDirs()),
remove: (dirs) => dirs.forEach(rm),
},
{
key: "data",
label: "Delete AgentHub's database and data directory",
find: dataDirs,
remove: (paths) => {
const refused = paths.filter((p) => fs.statSync(p).isDirectory() && !isSafeDataDir(p));
paths.filter((p) => !refused.includes(p)).forEach(rm);
if (refused.length)
throw new Error(`not deleted (not clearly AgentHub's): ${refused.join(", ")}`);
},
},
{
key: "own-pane",
label: "Close the terminal this runs in",
find: () => (self && io.tmux.listSessions(self).includes(self) ? [self] : []),
remove: () => io.tmux.killSession(self),
},
];
}
/** What would be removed: `[{key, label, items}]`, only steps with something to do. */
function plan(io = defaultIo()) {
return steps(io)
.map((s) => {
try {
return { key: s.key, label: s.label, items: s.find() };
} catch (err) {
return { key: s.key, label: s.label, items: [], error: err.message };
}
})
.filter((s) => s.items.length || s.error);
}
/**
* Run every step. A failing step is logged and the rest still run — half an
* uninstall that stops at the first error is worse than one that reports it.
* @returns {{ok: boolean, log: string, steps: object[]}}
*/
function run({ io = defaultIo(), onLine = () => {} } = {}) {
const file = logPath();
const log = (line) => {
onLine(line);
try {
fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`);
} catch {
/* best effort */
}
};
log(`AgentHub uninstall started (from ${REPO_ROOT})`);
const results = [];
for (const s of steps(io)) {
let items;
try {
items = s.find();
if (!items.length) continue;
const note = s.remove(items);
results.push({ key: s.key, status: "done" });
log(`✔ ${s.label}${typeof note === "string" ? ` — ${note}` : ""}`);
for (const i of items) log(` ${i}`);
} catch (err) {
results.push({ key: s.key, status: "failed", error: err.message });
log(`✖ ${s.label} — ${err.message}`);
}
}
const ok = results.every((r) => r.status === "done");
log(ok ? "AgentHub uninstalled." : "AgentHub uninstalled with errors (see above).");
return { ok, log: file, steps: results };
}
module.exports = { plan, run, logPath, isSafeDataDir, isOurHook, steps };
if (require.main === module) {
if (!process.argv.includes("--yes")) {
console.error("usage: node scripts/uninstall.js --yes");
process.exit(2);
}
loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
process.exit(run({ onLine: (l) => console.log(l) }).ok ? 0 : 1);
}