feat(doctor): catch half-written installs, not just absent ones
Runtime deps checked that node_modules existed, which is true of the broken tree too — it now verifies every declared dependency landed. Dashboard UI quotes the last client-build.log error instead of repeating the build command, and npm and free disk get their own rows: both truncate installs silently.
This commit is contained in:
+110
-16
@@ -2,10 +2,13 @@
|
||||
/**
|
||||
* @file plugin-doctor.js
|
||||
* @description Reports the health of a plugin-installed AgentHub: Node version,
|
||||
* bootstrap state, runtime dependencies, server liveness, duplicate hook
|
||||
* entries (the one failure that silently doubles every token and cost figure),
|
||||
* the `agenthub` CLI launcher and its PATH, and whether the slash commands the pipeline templates name
|
||||
* as node drivers are actually resolvable (the `superpowers` plugin, today).
|
||||
* 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, 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>
|
||||
*/
|
||||
@@ -13,6 +16,7 @@
|
||||
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");
|
||||
@@ -54,12 +58,9 @@ function diagnose() {
|
||||
);
|
||||
}
|
||||
|
||||
const deps = fs.existsSync(path.join(rt, "node_modules"));
|
||||
add(
|
||||
deps ? "ok" : "fail",
|
||||
"Runtime deps",
|
||||
deps ? path.join(rt, "node_modules") : "missing — run /agenthub-update"
|
||||
);
|
||||
add(...runtimeDepsStatus(rt));
|
||||
add(...npmStatus());
|
||||
add(...diskStatus());
|
||||
|
||||
const live = boot.liveServers();
|
||||
add(
|
||||
@@ -83,16 +84,109 @@ function diagnose() {
|
||||
add(...cliStatus());
|
||||
add(...skillPluginStatus());
|
||||
|
||||
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
|
||||
add(
|
||||
fs.existsSync(path.join(dist, "index.html")) ? "ok" : "warn",
|
||||
"Dashboard UI",
|
||||
fs.existsSync(path.join(dist, "index.html")) ? dist : "not built yet — run /agenthub-open"
|
||||
);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user