Files
AgentHub/scripts/plugin-update-helper.js
T
nntrivi2001 99e718ad91 fix(plugin): build the UI with devDependencies so a self-update ships its own bundle
After the 1.5.2 -> 1.5.3 self-update the server ran 1.5.3 while the dashboard
still served the 1.5.2 UI. The client build had failed, and `runtime/client-dist`
still held the previous version's bundle, so nothing looked broken — the only
signal was the Settings page's "UI build v1.5.2 (rebuild client to match)".

Cause: the whole client build toolchain (vite, typescript,
@vitejs/plugin-react, tailwindcss, @types/*) is a devDependency, and npm reads
NODE_ENV=production as --omit=dev (`NODE_ENV=production npm config get omit` ->
`dev`). The dashboard server runs with NODE_ENV=production, and every bootstrap
descended from it inherits that: the plugin-update chain, and any SessionStart
fired by a `claude` running in a tmux pane the server started. So `npm ci`
dropped vite, `tsc -b` died on missing type declarations, and buildClient
returned "failed" with the stale bundle left in place.

`--include=dev` on both install paths — a CLI flag outranks the env, which is
the point. Verified by running buildClient with NODE_ENV=production against the
installed plugin root: previously "failed", now "built", and the served bundle
carries 1.5.3.

Also: plugin-update-helper writes its verdict BEFORE the bootstrap handover as
well as after. That step retires the process tree the helper hangs off, and the
observed run reached the new server with no verdict file at all — the dashboard
reported `last_plugin_update: null` for an update that had actually succeeded.
2026-09-10 23:17:33 +07:00

201 lines
6.8 KiB
JavaScript

#!/usr/bin/env node
/**
* @file Detached helper that performs a full plugin-cache update end to end, so
* the dashboard's "Update now" button works for an install running out of
* `~/.claude/plugins/cache/`. Spawned by `server/lib/plugin-update.js` after the
* HTTP response is written, because step 3 SIGTERMs the very server that asked
* for the update — an in-process version would kill itself mid-flight.
*
* The three steps:
* 1. `claude plugin marketplace update <marketplace>`
* 2. `claude plugin update <plugin>@<marketplace> -y` → new cache dir appears
* 3. `node <newRoot>/scripts/plugin-bootstrap.js --force` → stops the old
* server, waits for the ports, installs deps and starts the new one
*
* Step 3 is what makes `/reload-plugins` unnecessary here: `plugin-bootstrap.js`
* derives its own `PLUGIN_ROOT` from `__dirname`, not from `${CLAUDE_PLUGIN_ROOT}`,
* so running it out of the NEW cache directory is enough to move the dashboard
* onto the new code. `/reload-plugins` remains needed only for an interactive
* Claude Code session to pick up new skills/commands/hooks — a thing no
* background process can do on that session's behalf.
*
* Every outcome, including failure, is recorded in `plugin-update.json` under the
* runtime directory (outside the plugin cache, so it survives the swap) and the
* new server reports it back as `last_plugin_update`. Without that, a failed
* chain would look identical to a slow one: the tab would poll a server that
* never comes back and say nothing about why.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { execFileSync, spawnSync } = require("child_process");
const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
const RESULT_FILE = path.join(runtimeDirArg, "plugin-update.json");
const LOG_FILE = path.join(runtimeDirArg, "plugin-update.log");
// Names come from the local cache PATH, never from an HTTP body, but they still
// reach an argv — so they are re-validated here rather than trusted across the
// process boundary. Anything else is a bug or tampering, and either way this
// helper must not run.
const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
function log(line) {
try {
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
} catch {
/* best effort */
}
}
function record(result) {
try {
fs.mkdirSync(runtimeDirArg, { recursive: true });
fs.writeFileSync(
RESULT_FILE,
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
);
} catch {
/* best effort */
}
}
/** Resolve the `claude` CLI. The server is started detached from a hook, so its
* PATH is not the user's login shell PATH — check the documented install
* location before giving up. */
function resolveClaude() {
const candidates = ["claude", path.join(require("os").homedir(), ".local", "bin", "claude")];
for (const cmd of candidates) {
const probe = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 20_000 });
if (!probe.error && probe.status === 0) return cmd;
}
return null;
}
/** Newest cache directory for this plugin, excluding `exclude`. */
function findNewRoot(exclude) {
const base = path.join(
require("os").homedir(),
".claude",
"plugins",
"cache",
marketplace,
plugin
);
let entries;
try {
entries = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory());
} catch {
return null;
}
const candidates = entries
.map((e) => path.join(base, e.name))
.filter((p) => p !== exclude)
// A cache directory without the bootstrap script is not a usable root.
.filter((p) => fs.existsSync(path.join(p, "scripts", "plugin-bootstrap.js")))
.map((p) => ({ p, mtime: fs.statSync(p).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
return candidates.length ? candidates[0].p : null;
}
function run(cmd, args, timeout) {
log(`$ ${cmd} ${args.join(" ")}`);
const out = execFileSync(cmd, args, {
encoding: "utf8",
timeout,
maxBuffer: 10_000_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (out) log(out.trim().slice(0, 4000));
return out;
}
function main() {
if (!SAFE_NAME.test(marketplace || "") || !SAFE_NAME.test(plugin || "")) {
record({ ok: false, step: "validate", error: "unsafe marketplace or plugin name" });
return;
}
const claude = resolveClaude();
if (!claude) {
record({
ok: false,
step: "resolve_claude",
error: "the `claude` CLI is not on this server's PATH; run /agenthub-update instead",
});
return;
}
try {
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
} catch (err) {
record({ ok: false, step: "marketplace_update", error: String(err.stderr || err.message) });
return;
}
try {
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
} catch (err) {
record({ ok: false, step: "plugin_update", error: String(err.stderr || err.message) });
return;
}
const newRoot = findNewRoot(oldRoot);
if (!newRoot) {
// Already newest: the marketplace had nothing to pull. Not a failure — the
// old server is still running and untouched, so say so plainly.
record({
ok: true,
step: "already_current",
note: "no new plugin version appeared; the running server was left alone",
plugin_root: oldRoot,
});
return;
}
log(`new plugin root: ${newRoot}`);
// Write the verdict BEFORE the handover, not only after it. The step below
// retires the process tree this helper hangs off, and an observed run reached
// the new server with no verdict file at all — so the dashboard reported
// `last_plugin_update: null` for an update that had in fact succeeded. A
// pre-written record is overwritten by the real outcome when this survives,
// and is the outcome when it does not.
record({
ok: true,
step: "bootstrap",
plugin_root: newRoot,
note: "the new plugin version is installed; the server is being handed over",
});
try {
// Foreground and blocking: this stops the old server (us), waits for the
// ports, then starts the new one. It must finish before we record success.
run(
process.execPath,
[path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"],
900_000
);
} catch (err) {
record({
ok: false,
step: "bootstrap",
error: String(err.stderr || err.message),
plugin_root: newRoot,
note: "the old server was stopped; run /agenthub-update to recover",
});
return;
}
record({ ok: true, step: "done", plugin_root: newRoot });
}
try {
main();
} catch (err) {
// A helper that dies without a verdict is the failure mode this file exists
// to prevent, so even an unexpected throw gets recorded.
record({ ok: false, step: "unexpected", error: err.message || String(err) });
}