058c574138
A `claude plugin update` run from a terminal (or with only the Claude desktop
app open) unpacks the new build and starts nothing: no SessionStart fires, so
the dashboard kept running the old cache dir across reboots, and with the
forge unreachable the sidebar read "Up to date" (seen on a machine stuck at
1.8.10).
- pluginStatus asks supersededBy for its own root: a recorded sibling install
sets `installed_not_running: {root, version}` and `update_available`, with
no network needed
- the sidebar reads "<version> installed, not running"; the update modal keys
on it, so Update now is offered even with no release tag or sha
- plugin-update-helper hands over to the recorded install first, the newest
sibling dir only as a fallback
- a plugin install whose release lookup failed reads "Could not check for
updates" instead of "Up to date"
- docs/PLUGINS.md: the case and the manual hand-over command
219 lines
7.6 KiB
JavaScript
219 lines
7.6 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) {
|
|
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) });
|
|
}
|