Files
AgentHub/scripts/cc-update-all-helper.js
T
nntrivi2001 aca35dd4cd feat(cc-config): update every plugin and marketplace at once, then reload the open sessions
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect.

Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement.

readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race.

Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: a one-shot claude -p "/reload-plugins" would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command.

Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action.

Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted.

Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
2026-09-10 16:05:07 +07:00

284 lines
9.7 KiB
JavaScript

#!/usr/bin/env node
/**
* @file Detached worker for the dashboard's "Update all" button. Runs the whole
* batch and writes each step's outcome to `cc-update-all.json` in the runtime
* directory as it goes, so the UI can follow along by polling.
*
* Order, and why:
* 1. `claude plugin marketplace update` (no name = every marketplace) — a
* plugin update can only see a version its marketplace already knows about.
* 2. `claude plugin update <id> -y` for every installed plugin EXCEPT the one
* the dashboard itself runs from.
* 3. That one last, followed by `plugin-bootstrap.js --force` from the NEW
* cache directory — this stops the dashboard server and starts the
* replacement. Anything ordered after it would run while the server is
* down, which is exactly why it is last.
* 4. `/reload-plugins` + `/reload-skills` typed into the live lane panes.
* Deliberately after everything: one reload picks up every pending change.
*
* Spawned detached precisely because step 3 kills its parent. It must never
* throw its way out without a terminal journal entry — a silent death would
* leave the UI polling a batch that says "running" forever.
* @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 [, , runtimeDirArg, planJson] = process.argv;
const JOURNAL = path.join(runtimeDirArg || ".", "cc-update-all.json");
const LOG_FILE = path.join(runtimeDirArg || ".", "cc-update-all.log");
// Ids reach an argv, so they are re-validated here rather than trusted across
// the process boundary — same reasoning as scripts/plugin-update-helper.js.
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
/** @type {{status: string, started_at: string, finished_at?: string, steps: object[]}} */
const state = { status: "running", started_at: new Date().toISOString(), steps: [] };
function log(line) {
try {
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
} catch {
/* best effort */
}
}
/** Persist after every transition — the UI polls this file, and the process can
* be SIGTERMed at step 3 without warning. */
function flush() {
try {
fs.mkdirSync(runtimeDirArg, { recursive: true });
fs.writeFileSync(JOURNAL, JSON.stringify(state, null, 2));
} catch {
/* best effort */
}
}
function addStep(key, label) {
const step = { key, label, status: "running", started_at: new Date().toISOString() };
state.steps.push(step);
flush();
return step;
}
function finishStep(step, status, extra = {}) {
step.status = status;
step.finished_at = new Date().toISOString();
Object.assign(step, extra);
flush();
}
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;
}
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, 2000));
return out;
}
/** Newest cache dir for the self plugin, excluding the one being replaced. */
function findNewRoot(marketplace, plugin, 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)
.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;
}
/**
* Type the reload commands into every live `claude` lane pane. Uses tmux
* directly rather than the server's lib: by this point the server may be a
* different process than the one that started the batch.
*/
function reloadPanes() {
const step = addStep("reload", "Reload open Claude Code sessions");
let names;
try {
const out = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
encoding: "utf8",
timeout: 15_000,
stdio: ["ignore", "pipe", "pipe"],
});
names = out
.split("\n")
.map((s) => s.trim())
.filter((s) => s.startsWith("agenthub-lane-"));
} catch {
// No tmux server at all means no open sessions — nothing to reload, which
// is a normal outcome and not a batch failure.
finishStep(step, "skipped", { note: "no tmux sessions are open" });
return;
}
const reloaded = [];
for (const name of names) {
let paneCmd = null;
try {
paneCmd = execFileSync(
"tmux",
["display-message", "-p", "-t", name, "#{pane_current_command}"],
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
).trim();
} catch {
continue; // pane vanished between listing and probe
}
// A pane at a shell prompt has no REPL to reload; the text would be run as
// a shell command instead.
if (SHELL_COMMANDS.has(paneCmd)) continue;
try {
for (const command of ["/reload-plugins", "/reload-skills"]) {
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
}
reloaded.push(name);
} catch {
/* pane died mid-send; the rest of the batch stands */
}
}
finishStep(step, "ok", { lanes: reloaded, lanes_total: reloaded.length });
}
function main() {
let plan;
try {
plan = JSON.parse(planJson);
} catch {
state.status = "failed";
state.error = "unreadable plan";
state.finished_at = new Date().toISOString();
flush();
return;
}
const claude = resolveClaude();
if (!claude) {
const step = addStep("resolve_claude", "Locate the claude CLI");
finishStep(step, "failed", {
error: "the `claude` CLI is not on this server's PATH; run the update from a terminal",
});
state.status = "failed";
state.finished_at = new Date().toISOString();
flush();
return;
}
// 1. Every marketplace, in one call (no name = all).
const mktStep = addStep("marketplaces", "Update all marketplaces");
try {
run(claude, ["plugin", "marketplace", "update"], 300_000);
finishStep(mktStep, "ok");
} catch (err) {
// A marketplace that can't be refreshed (offline, auth) does not stop the
// batch: the plugins may still have updates from the marketplaces that did.
finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
}
const self = plan.selfPlugin;
const selfId = self ? `${self.plugin}@${self.marketplace}` : null;
const others = (plan.plugins || []).filter((id) => PLUGIN_SPEC_RE.test(id) && id !== selfId);
// 2. Every other plugin.
for (const id of others) {
const step = addStep(`plugin:${id}`, id);
try {
run(claude, ["plugin", "update", id, "-y"], 600_000);
finishStep(step, "ok");
} catch (err) {
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
}
}
// 3. AgentHub itself, last — its bootstrap stops this server.
if (self && SAFE_NAME.test(self.marketplace) && SAFE_NAME.test(self.plugin)) {
const step = addStep(`plugin:${selfId}`, `${selfId} (this dashboard)`);
try {
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
} catch (err) {
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
reloadPanes();
state.status = "failed";
state.finished_at = new Date().toISOString();
flush();
return;
}
const newRoot = findNewRoot(self.marketplace, self.plugin, plan.selfRoot);
if (!newRoot) {
// Nothing newer was published: the running server is correct as-is.
finishStep(step, "ok", { note: "already on the newest version; server left running" });
} else {
// Mark the step done BEFORE the restart: bootstrap SIGTERMs us mid-call,
// so a journal entry written afterwards may never be written at all.
finishStep(step, "ok", { note: `restarting the dashboard from ${newRoot}`, restarted: true });
reloadPanes();
state.status = "done";
state.finished_at = new Date().toISOString();
flush();
try {
run(
process.execPath,
[path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"],
900_000
);
} catch (err) {
log(`bootstrap failed: ${err.message}`);
state.status = "failed";
state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000);
state.note = "the old server was stopped; run /agenthub-update to recover";
flush();
}
return;
}
}
// 4. Reload, once, after every update.
if (plan.reload !== false) reloadPanes();
state.status = state.steps.some((s) => s.status === "failed") ? "partial" : "done";
state.finished_at = new Date().toISOString();
flush();
}
try {
main();
} catch (err) {
// A worker that dies without a terminal entry leaves the UI polling forever.
state.status = "failed";
state.error = err.message || String(err);
state.finished_at = new Date().toISOString();
flush();
}