From 6f9b9c36b8ba55c01e4b5b515be4e88d880b4098 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 8 Sep 2026 15:44:41 +0700 Subject: [PATCH] feat(doctor): catch half-written installs, not just absent ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/agenthub/commands/agenthub-doctor.md | 12 +- scripts/plugin-doctor.js | 126 +++++++++++++++--- .../__tests__/plugin-doctor-runtime.test.js | 125 +++++++++++++++++ 3 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 server/__tests__/plugin-doctor-runtime.test.js diff --git a/plugins/agenthub/commands/agenthub-doctor.md b/plugins/agenthub/commands/agenthub-doctor.md index 9f08008b..f44b1976 100644 --- a/plugins/agenthub/commands/agenthub-doctor.md +++ b/plugins/agenthub/commands/agenthub-doctor.md @@ -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 @@ -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. Starting a new Claude Code session removes them automatically (the bootstrap 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`. +- `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 Skill plugins` → the pipelines name slash commands no installed plugin 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 it, and print the `claude plugin install …` line from the output as the manual 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. diff --git a/scripts/plugin-doctor.js b/scripts/plugin-doctor.js index dc644fe3..2330a78b 100644 --- a/scripts/plugin-doctor.js +++ b/scripts/plugin-doctor.js @@ -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ĩ */ @@ -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 diff --git a/server/__tests__/plugin-doctor-runtime.test.js b/server/__tests__/plugin-doctor-runtime.test.js new file mode 100644 index 00000000..2530b29e --- /dev/null +++ b/server/__tests__/plugin-doctor-runtime.test.js @@ -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ĩ + */ + +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"), ""); + } + + 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); + }); +});