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:
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Diagnose a plugin-installed AgentHub — Node, bootstrap, server, hooks, CLI
|
description: Diagnose a plugin-installed AgentHub — Node, npm, disk, bootstrap, server, hooks, CLI, UI build
|
||||||
---
|
---
|
||||||
|
|
||||||
Run the diagnostic and report what it found. Do not fix anything unless the user
|
Run the diagnostic and report what it found. Do not fix anything unless the user
|
||||||
@@ -17,8 +17,13 @@ Print the output verbatim, then add at most three lines of guidance based on the
|
|||||||
- `FAIL Hooks` → duplicate hook entries double every token and cost figure.
|
- `FAIL Hooks` → duplicate hook entries double every token and cost figure.
|
||||||
Starting a new Claude Code session removes them automatically (the bootstrap
|
Starting a new Claude Code session removes them automatically (the bootstrap
|
||||||
backs the file up as `settings.json.agenthub-bak` first).
|
backs the file up as `settings.json.agenthub-bak` first).
|
||||||
- `FAIL Server` or `FAIL Runtime deps` → run `/agenthub-update`, then check
|
- `FAIL Server` → run `/agenthub-update`, then check
|
||||||
`~/.claude/agenthub/runtime/bootstrap.log` and `server.log`.
|
`~/.claude/agenthub/runtime/bootstrap.log` and `server.log`.
|
||||||
|
- `FAIL Runtime deps` → the install is incomplete, not merely absent. Delete
|
||||||
|
`~/.claude/agenthub/runtime/node_modules` before running `/agenthub-update`;
|
||||||
|
reinstalling over the broken tree is what let it survive.
|
||||||
|
- `FAIL npm` or `FAIL Disk` → nothing else in the report can be trusted until
|
||||||
|
these are fixed; both silently truncate installs.
|
||||||
- `WARN agenthub CLI` → print the exact `export PATH=...` line from the output.
|
- `WARN agenthub CLI` → print the exact `export PATH=...` line from the output.
|
||||||
- `WARN Skill plugins` → the pipelines name slash commands no installed plugin
|
- `WARN Skill plugins` → the pipelines name slash commands no installed plugin
|
||||||
provides. The dashboard is unaffected; `/agenthub:ship-feature(-lane)` is what
|
provides. The dashboard is unaffected; `/agenthub:ship-feature(-lane)` is what
|
||||||
@@ -26,6 +31,7 @@ Print the output verbatim, then add at most three lines of guidance based on the
|
|||||||
means that attempt has not run or did not succeed — say `/agenthub-update` retries
|
means that attempt has not run or did not succeed — say `/agenthub-update` retries
|
||||||
it, and print the `claude plugin install …` line from the output as the manual
|
it, and print the `claude plugin install …` line from the output as the manual
|
||||||
route.
|
route.
|
||||||
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle.
|
- `WARN Dashboard UI` → run `/agenthub-open` to build the bundle. When the row
|
||||||
|
quotes a build error, report that error — it is the actual problem.
|
||||||
|
|
||||||
No preamble, no summary of things that passed.
|
No preamble, no summary of things that passed.
|
||||||
|
|||||||
+110
-16
@@ -2,10 +2,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file plugin-doctor.js
|
* @file plugin-doctor.js
|
||||||
* @description Reports the health of a plugin-installed AgentHub: Node version,
|
* @description Reports the health of a plugin-installed AgentHub: Node version,
|
||||||
* bootstrap state, runtime dependencies, server liveness, duplicate hook
|
* npm, free disk on the data dir, bootstrap state, runtime dependencies (every
|
||||||
* entries (the one failure that silently doubles every token and cost figure),
|
* declared package, not just that node_modules exists), server liveness,
|
||||||
* the `agenthub` CLI launcher and its PATH, and whether the slash commands the pipeline templates name
|
* duplicate hook entries (the one failure that silently doubles every token and
|
||||||
* as node drivers are actually resolvable (the `superpowers` plugin, today).
|
* 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.
|
* Read-only — it diagnoses, it never repairs.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -13,6 +16,7 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
|
||||||
const boot = require("./plugin-bootstrap");
|
const boot = require("./plugin-bootstrap");
|
||||||
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
|
const { getSettingsPath, getDataDir } = require("../server/lib/claude-home");
|
||||||
@@ -54,12 +58,9 @@ function diagnose() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deps = fs.existsSync(path.join(rt, "node_modules"));
|
add(...runtimeDepsStatus(rt));
|
||||||
add(
|
add(...npmStatus());
|
||||||
deps ? "ok" : "fail",
|
add(...diskStatus());
|
||||||
"Runtime deps",
|
|
||||||
deps ? path.join(rt, "node_modules") : "missing — run /agenthub-update"
|
|
||||||
);
|
|
||||||
|
|
||||||
const live = boot.liveServers();
|
const live = boot.liveServers();
|
||||||
add(
|
add(
|
||||||
@@ -83,16 +84,109 @@ function diagnose() {
|
|||||||
add(...cliStatus());
|
add(...cliStatus());
|
||||||
add(...skillPluginStatus());
|
add(...skillPluginStatus());
|
||||||
|
|
||||||
const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist");
|
add(...dashboardUiStatus(rt));
|
||||||
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"
|
|
||||||
);
|
|
||||||
|
|
||||||
return out;
|
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:
|
* 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
|
* the dashboard, hooks and CLI all work without it — only the pipeline
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests the `Runtime deps` and `Dashboard UI` rows of
|
||||||
|
* scripts/plugin-doctor.js — the two checks that have to survive a half-written
|
||||||
|
* install. A `node_modules` directory that exists but is missing packages is
|
||||||
|
* exactly what took a client build down with TS2318 (typescript's lib/*.d.ts
|
||||||
|
* never landed, so every global type was undefined), and when the bundle is
|
||||||
|
* missing the reader needs the build error, not another "run /agenthub-open".
|
||||||
|
* Every case runs against an injected temp CLAUDE_HOME.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, 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 TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-doctor-runtime-"));
|
||||||
|
process.env.CLAUDE_HOME = TMP_HOME;
|
||||||
|
delete process.env.DASHBOARD_CLIENT_DIST;
|
||||||
|
|
||||||
|
const { diagnose } = require("../../scripts/plugin-doctor");
|
||||||
|
|
||||||
|
const RT = path.join(TMP_HOME, "agenthub", "runtime");
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
fs.rmSync(TMP_HOME, { recursive: true, force: true });
|
||||||
|
delete process.env.CLAUDE_HOME;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the runtime dir from scratch, then read back one row.
|
||||||
|
* `installed` are the packages that actually made it into node_modules.
|
||||||
|
*/
|
||||||
|
function rowFor(label, { manifest, installed = [], buildLog, dist = false } = {}) {
|
||||||
|
fs.rmSync(RT, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
||||||
|
|
||||||
|
if (manifest) fs.writeFileSync(path.join(RT, "package.json"), JSON.stringify(manifest));
|
||||||
|
for (const dep of installed) {
|
||||||
|
fs.mkdirSync(path.join(RT, "node_modules", dep), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(RT, "node_modules", dep, "package.json"), "{}");
|
||||||
|
}
|
||||||
|
if (buildLog) fs.writeFileSync(path.join(RT, "client-build.log"), buildLog);
|
||||||
|
if (dist) {
|
||||||
|
fs.mkdirSync(path.join(RT, "client-dist"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(RT, "client-dist", "index.html"), "<html></html>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = diagnose().find((r) => r.label === label);
|
||||||
|
assert.ok(row, `the doctor must always emit a ${label} row`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("doctor: Runtime deps", () => {
|
||||||
|
it("fails on a tree that npm reported as installed but that is missing packages", () => {
|
||||||
|
const row = rowFor("Runtime deps", {
|
||||||
|
manifest: { dependencies: { express: "^4", ws: "^8", uuid: "^11" } },
|
||||||
|
installed: ["express"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(row.level, "fail", row.detail);
|
||||||
|
assert.match(row.detail, /ws/);
|
||||||
|
assert.match(row.detail, /uuid/);
|
||||||
|
assert.ok(!/express/.test(row.detail), `express is installed and must not be listed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes when every declared dependency landed", () => {
|
||||||
|
const row = rowFor("Runtime deps", {
|
||||||
|
manifest: { dependencies: { express: "^4", ws: "^8" } },
|
||||||
|
installed: ["express", "ws"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(row.level, "ok", row.detail);
|
||||||
|
assert.match(row.detail, /2 packages/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail over an absent optional dependency", () => {
|
||||||
|
// better-sqlite3 is optional by design: the server falls back to
|
||||||
|
// node:sqlite. Failing here would send readers reinstalling for nothing.
|
||||||
|
const row = rowFor("Runtime deps", {
|
||||||
|
manifest: { dependencies: { ws: "^8" }, optionalDependencies: { "better-sqlite3": "^12" } },
|
||||||
|
installed: ["ws"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(row.level, "ok", row.detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns rather than fails when there is no manifest to verify against", () => {
|
||||||
|
const row = rowFor("Runtime deps", { installed: ["ws"] });
|
||||||
|
|
||||||
|
assert.equal(row.level, "warn", row.detail);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("doctor: Dashboard UI", () => {
|
||||||
|
it("quotes the last build error instead of repeating the build command", () => {
|
||||||
|
const row = rowFor("Dashboard UI", {
|
||||||
|
manifest: { dependencies: {} },
|
||||||
|
buildLog: [
|
||||||
|
"added 321 packages in 7s",
|
||||||
|
"> tsc -b && vite build",
|
||||||
|
"error TS2318: Cannot find global type 'Array'.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(row.level, "warn", row.detail);
|
||||||
|
assert.match(row.detail, /TS2318/);
|
||||||
|
assert.match(row.detail, /client-build\.log/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the bundle was never built when no build has run", () => {
|
||||||
|
const row = rowFor("Dashboard UI", { manifest: { dependencies: {} } });
|
||||||
|
|
||||||
|
assert.equal(row.level, "warn", row.detail);
|
||||||
|
assert.match(row.detail, /not built yet/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is ok once the bundle exists", () => {
|
||||||
|
const row = rowFor("Dashboard UI", { manifest: { dependencies: {} }, dist: true });
|
||||||
|
|
||||||
|
assert.equal(row.level, "ok", row.detail);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user