29858ddccb
The READMEs never said tmux was needed, so a fresh install only found out when Start Run failed with nothing explaining why. Both doctors were silent about it too. - README/README.vi: a prerequisites table (node, npm, git, tmux, claude, codex, gh, systemd) and per-OS tmux install + verify steps; the feature list is regrouped into observe/drive/configure and now covers analytics, alerts, import, remote sources, the config explorer and Update all. - INSTALL: Step 0 for the system packages, plus a troubleshooting entry separating "tmux missing" from "the server has a different PATH". - SETUP/docs/README: the same troubleshooting entry and a pointer to Step 0. - plugin-doctor: a tmux row, WARN and never FAIL - the run console and `lanes shell` break without it, nothing else does. Reuses tmux.js's own isTmuxAvailable(). - agenthub doctor: asks GET /api/run/tmux instead, because the PATH that decides whether a run starts is the server's, not the caller's shell's.
285 lines
9.7 KiB
JavaScript
285 lines
9.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @file plugin-doctor.js
|
|
* @description Reports the health of a plugin-installed AgentHub: Node version,
|
|
* npm, free disk on the data dir, bootstrap state, runtime dependencies (every
|
|
* declared package, not just that node_modules exists), server liveness,
|
|
* duplicate hook entries (the one failure that silently doubles every token and
|
|
* cost figure), the `agenthub` CLI launcher and its PATH, the `tmux` binary
|
|
* every browser-started run depends on, whether the slash
|
|
* commands the pipeline templates name as node drivers are actually resolvable
|
|
* (the `superpowers` plugin, today), and the dashboard bundle — quoting the last
|
|
* build error when it is missing.
|
|
* Read-only — it diagnoses, it never repairs.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
|
|
const boot = require("./plugin-bootstrap");
|
|
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
|
|
|
|
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
|
|
|
/** @returns {{level:"ok"|"warn"|"fail", label:string, detail:string}[]} */
|
|
function diagnose() {
|
|
const rt = boot.runtimeDir();
|
|
const out = [];
|
|
const add = (level, label, detail) => out.push({ level, label, detail });
|
|
|
|
add(
|
|
boot.nodeVersionOk() ? "ok" : "fail",
|
|
"Node",
|
|
boot.nodeVersionOk()
|
|
? `v${process.versions.node}`
|
|
: `v${process.versions.node} — AgentHub needs >= ${boot.MIN_NODE.join(".")} (node:sqlite)`
|
|
);
|
|
|
|
add("ok", "Plugin root", PLUGIN_ROOT);
|
|
add("ok", "Data dir", getDataDir());
|
|
|
|
const state = boot.readState(rt);
|
|
if (!state) {
|
|
add(
|
|
"warn",
|
|
"Bootstrap",
|
|
`no state recorded — start a new session, or check ${rt}/bootstrap.log`
|
|
);
|
|
} else {
|
|
const moved = state.pluginRoot !== PLUGIN_ROOT;
|
|
add(
|
|
moved ? "warn" : "ok",
|
|
"Bootstrap",
|
|
moved
|
|
? `recorded against a previous plugin version (${state.pluginRoot}) — run /agenthub-update`
|
|
: `last run ${state.updatedAt}`
|
|
);
|
|
}
|
|
|
|
add(...runtimeDepsStatus(rt));
|
|
add(...npmStatus());
|
|
add(...diskStatus());
|
|
|
|
const live = boot.liveServers();
|
|
add(
|
|
live.length ? "ok" : "fail",
|
|
"Server",
|
|
live.length
|
|
? `listening on ${live.map((s) => `http://localhost:${s.port}`).join(", ")}`
|
|
: `not running — see ${path.join(rt, "server.log")}`
|
|
);
|
|
|
|
const dup = countLegacyHookEntries();
|
|
add(
|
|
dup ? "fail" : "ok",
|
|
"Hooks",
|
|
dup
|
|
? `${dup} entr${dup === 1 ? "y" : "ies"} in ${getSettingsPath()} duplicate the plugin's hooks — ` +
|
|
`every event is counted twice. Remove them (a new session does it automatically).`
|
|
: "provided by the plugin only"
|
|
);
|
|
|
|
add(...cliStatus());
|
|
add(...tmuxStatus());
|
|
add(...skillPluginStatus());
|
|
|
|
add(...dashboardUiStatus(rt));
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* `node_modules` existing proves nothing: an interrupted install or a truncated
|
|
* cache entry leaves a directory that npm reports as installed and that is
|
|
* missing packages — the failure mode that took the client build down with
|
|
* TS2318. Check every declared runtime dependency actually landed.
|
|
*
|
|
* Only `dependencies` count. `optionalDependencies` (better-sqlite3) are allowed
|
|
* to be absent by design — the server falls back to `node:sqlite`.
|
|
*/
|
|
function runtimeDepsStatus(rt) {
|
|
const nm = path.join(rt, "node_modules");
|
|
if (!fs.existsSync(nm)) return ["fail", "Runtime deps", "missing — run /agenthub-update"];
|
|
|
|
let declared;
|
|
try {
|
|
declared = Object.keys(
|
|
JSON.parse(fs.readFileSync(path.join(rt, "package.json"), "utf8")).dependencies || {}
|
|
);
|
|
} catch {
|
|
return ["warn", "Runtime deps", `${nm} — no manifest beside it, cannot verify the tree`];
|
|
}
|
|
|
|
const missing = declared.filter((d) => !fs.existsSync(path.join(nm, d, "package.json")));
|
|
if (missing.length) {
|
|
return [
|
|
"fail",
|
|
"Runtime deps",
|
|
`${missing.join(", ")} declared but not installed — the tree is incomplete. ` +
|
|
`Delete ${nm} and run /agenthub-update`,
|
|
];
|
|
}
|
|
return ["ok", "Runtime deps", `${declared.length} packages in ${nm}`];
|
|
}
|
|
|
|
/** No npm, no dashboard bundle and no runtime install — both go through it. */
|
|
function npmStatus() {
|
|
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], {
|
|
encoding: "utf8",
|
|
shell: process.platform === "win32",
|
|
});
|
|
if (res.status !== 0) {
|
|
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
|
|
}
|
|
return ["ok", "npm", `v${res.stdout.trim()}`];
|
|
}
|
|
|
|
/**
|
|
* A full disk is the quiet cause of half-written installs and a SQLite file that
|
|
* refuses writes, and it reads as unrelated breakage everywhere else.
|
|
*/
|
|
function diskStatus() {
|
|
const dir = getDataDir();
|
|
let free;
|
|
try {
|
|
const s = fs.statfsSync(dir);
|
|
free = s.bavail * s.bsize;
|
|
} catch {
|
|
return ["ok", "Disk", "not measurable on this platform"];
|
|
}
|
|
const mb = Math.round(free / 1024 / 1024);
|
|
if (free < 200 * 1024 * 1024) {
|
|
return ["fail", "Disk", `${mb} MB free on ${dir} — installs and the database will truncate`];
|
|
}
|
|
if (free < 1024 * 1024 * 1024) return ["warn", "Disk", `${mb} MB free on ${dir}`];
|
|
return ["ok", "Disk", `${Math.round(mb / 1024)} GB free`];
|
|
}
|
|
|
|
/**
|
|
* When the bundle is missing the useful thing is not "run /agenthub-open" — it
|
|
* is why the last build failed, which sits in `client-build.log` and which
|
|
* nobody thinks to open.
|
|
*/
|
|
function dashboardUiStatus(rt) {
|
|
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
|
|
if (fs.existsSync(path.join(dist, "index.html"))) return ["ok", "Dashboard UI", dist];
|
|
|
|
const err = lastBuildError(path.join(rt, "client-build.log"));
|
|
return [
|
|
"warn",
|
|
"Dashboard UI",
|
|
err
|
|
? `not built — the last build failed with: ${err} (full log: ${path.join(rt, "client-build.log")})`
|
|
: "not built yet — run /agenthub-open",
|
|
];
|
|
}
|
|
|
|
/** First compiler/npm error line of the log's last build, or null. */
|
|
function lastBuildError(logPath) {
|
|
let lines;
|
|
try {
|
|
lines = fs.readFileSync(logPath, "utf8").split("\n");
|
|
} catch {
|
|
return null;
|
|
}
|
|
const hit = lines.filter((l) => /\berror\b|npm ERR!/i.test(l)).pop();
|
|
return hit ? hit.trim().slice(0, 200) : null;
|
|
}
|
|
|
|
/**
|
|
* WARN (never FAIL) when a plugin the pipelines depend on cannot be resolved:
|
|
* the dashboard, hooks and CLI all work without it — only the pipeline
|
|
* skills that drive it stop partway, so this must not fail a doctor run.
|
|
*
|
|
* The requirement derivation and the missing/satisfied rules live in
|
|
* `server/lib/skill-plugins.js`, shared with the bootstrap that installs them.
|
|
*/
|
|
function skillPluginStatus() {
|
|
const skillPlugins = require("../server/lib/skill-plugins");
|
|
const required = skillPlugins.requiredSkillPlugins();
|
|
if (!required.size) return ["ok", "Skill plugins", "no pipeline names an external plugin"];
|
|
|
|
let missing;
|
|
try {
|
|
missing = skillPlugins.missingSkillPlugins();
|
|
} catch {
|
|
return ["warn", "Skill plugins", "could not read the installed-plugin manifest"];
|
|
}
|
|
if (!missing.length) {
|
|
return ["ok", "Skill plugins", `${[...required.keys()].sort().join(", ")} available`];
|
|
}
|
|
return [
|
|
"warn",
|
|
"Skill plugins",
|
|
`${missing.map((m) => `${m.name} (needed for ${m.skills.join(", ")})`).join("; ")} — ` +
|
|
`the pipelines name these commands; install with ` +
|
|
`claude plugin install ${missing.map((m) => m.autoInstall?.spec || m.name).join(" ")}`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
|
|
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
|
|
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
|
|
* sessions, analytics, lanes and the CLI keep working. Same reasoning as the
|
|
* skill-plugins row: a partial feature loss must not fail a doctor run.
|
|
*
|
|
* This checks the doctor's own PATH. A server started as a systemd `--user`
|
|
* service can still see a different one — `GET /api/run/tmux` is the server's
|
|
* own answer, and `agenthub doctor` reports that.
|
|
*/
|
|
function tmuxStatus() {
|
|
if (require("../server/lib/tmux").isTmuxAvailable()) {
|
|
return ["ok", "tmux", "on PATH — the run console can start sessions"];
|
|
}
|
|
return [
|
|
"warn",
|
|
"tmux",
|
|
"not found — Start Run and `agenthub lanes shell` will fail (everything else is unaffected). " +
|
|
"Install it: sudo apt install tmux / brew install tmux (Windows: run AgentHub under WSL2)",
|
|
];
|
|
}
|
|
|
|
function countLegacyHookEntries() {
|
|
try {
|
|
const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8"));
|
|
if (!settings.hooks) return 0;
|
|
const isLegacy = boot.isCheckoutHookEntry();
|
|
return Object.values(settings.hooks)
|
|
.filter(Array.isArray)
|
|
.reduce((n, entries) => n + entries.filter(isLegacy).length, 0);
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function cliStatus() {
|
|
const dir = path.join(os.homedir(), ".local", "bin");
|
|
const file = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
|
|
if (!fs.existsSync(file)) return ["warn", "agenthub CLI", `no launcher at ${file}`];
|
|
const onPath = (process.env.PATH || "")
|
|
.split(path.delimiter)
|
|
.some((p) => path.resolve(p) === path.resolve(dir));
|
|
return onPath
|
|
? ["ok", "agenthub CLI", file]
|
|
: [
|
|
"warn",
|
|
"agenthub CLI",
|
|
`${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`,
|
|
];
|
|
}
|
|
|
|
function report() {
|
|
const marks = { ok: "OK ", warn: "WARN", fail: "FAIL" };
|
|
const rows = diagnose();
|
|
for (const r of rows) console.log(`${marks[r.level]} ${r.label.padEnd(14)} ${r.detail}`);
|
|
return rows.some((r) => r.level === "fail") ? 1 : 0;
|
|
}
|
|
|
|
if (require.main === module) process.exitCode = report();
|
|
|
|
module.exports = { diagnose, report, countLegacyHookEntries };
|