Files
AgentHub/scripts/plugin-update-helper.js
T
nntrivi2001 af014d9829 fix: v2.5.3 — Windows workspace folders, picker on top, no console flashes
A workspace could not be created on Windows: "cwd must be an absolute path"
for D:\…, because createLane/canonicalCwd tested for a leading "/".

- lanes.js: path.isAbsolute/path.resolve; canonicalCwd stops at any root (a
  missing drive looped forever); resolveLaneByCwd and /ensure accept "\" too
- native-dialog.js: the Windows picker gets an invisible TopMost owner form,
  shown first — an ownerless dialog from the background server opened behind
  the browser, or hidden
- windows-hide.js: every child process on Windows starts with windowsHide,
  required first by the server and the detached scripts (a console window
  flashed per git/npm call)
- Confirm/update/clear-data modals and the quota panel scroll instead of
  overflowing a short window
2026-10-06 20:22:34 +07:00

261 lines
9.1 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. A failed step's verdict carries
* what the command printed (`failureText`): `plugin-bootstrap.js` says why it
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
* only "Command failed: <argv>".
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
require("../server/lib/windows-hide").install(); // before child_process is read
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;
// `|| ""`: required by a test (for `failureText`) there is no argv, and only
// main() — which a require never runs — writes these.
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(" ")}`);
try {
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;
} catch (err) {
log(`failed (${howItEnded(err)})`);
for (const stream of [err.stdout, err.stderr]) {
if (stream) log(String(stream).trim().slice(-4000));
}
throw err;
}
}
function howItEnded(err) {
if (err.signal) return `killed by ${err.signal}`;
return err.status != null ? `exit ${err.status}` : err.code || "did not run";
}
/**
* A failed command's verdict text: the last lines it printed on stdout and
* stderr, then how it ended. Falls back to execFileSync's message ("Command
* failed: …", or ENOENT) only when the command printed nothing.
*/
function failureText(err) {
const tail = (s) =>
String(s || "")
.trim()
.split("\n")
.slice(-5)
.join("\n");
const said = [tail(err.stdout), tail(err.stderr)].filter(Boolean);
if (!said.length) return `${String(err.message || err).split("\n")[0]} (${howItEnded(err)})`;
return [...said, `(${howItEnded(err)})`].join("\n");
}
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: failureText(err) });
return;
}
try {
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
} catch (err) {
record({ ok: false, step: "plugin_update", error: failureText(err) });
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: failureText(err),
plugin_root: newRoot,
note: "the old server was stopped; run /agenthub-update to recover",
});
return;
}
record({ ok: true, step: "done", plugin_root: newRoot });
}
module.exports = { failureText };
if (require.main === module) {
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) });
}
}