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.
464 lines
16 KiB
JavaScript
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);
|
|
}
|