#!/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 ` * 2. `claude plugin update @ -y` → new cache dir appears * 3. `node /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ĩ */ const fs = require("fs"); const { rotateIfLarge } = require("./log-rotate"); 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 { rotateIfLarge(LOG_FILE); 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) { try { return runOnce(cmd, args, timeout); } catch (err) { // An older Claude Code rejects `-y` ("unknown option") and never prompts, // so the same command without the flag is the right retry. if (!args.includes("-y") || !/unknown option '-y'/.test(String(err.stderr || ""))) throw err; return runOnce( cmd, args.filter((a) => a !== "-y"), timeout ); } } function runOnce(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; } // Claude Code's own install record first: newest-mtime is only a guess, and a // build installed from a terminal while this server kept running is exactly // the one to hand over to, whether or not this chain pulled anything. const newRoot = require("./plugin-bootstrap").supersededBy(oldRoot) || 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) }); }