Files
AgentHub/scripts/cc-update-all-helper.js
nntrivi2001 f0dc1445fc fix: v2.6.2 — Windows panes no longer read as dead (psmux ignores -F), Update now's setup works under cmd.exe
- psmux prints `name: 1 windows (created …)` whatever `list-sessions -F`
  asks for. Read as names, every live pane looked gone and the heal beat
  closed its run (and its tab) within seconds while the session kept
  running. tmux.js (and the Update all helper) now take the text before the
  first `:` — a session name never holds one.
- `npm run setup` used `(cd client && npm install)`; cmd.exe's ( ) is no
  subshell, so `npm run link-cli` ran inside client/ ("Missing script") and
  Update now failed on Windows. Now `npm --prefix client install`.
- Update now: an npm step names itself in its error and shows the END of
  npm's output (the error, not the warnings), says when it hit the time
  limit, and gets 20 min instead of 5.
2026-10-10 12:01:01 +07:00

318 lines
11 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>
*/
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");
// What a failed command printed — plugin-bootstrap.js says why on STDOUT.
const { failureText } = require("./plugin-update-helper");
// psmux (the Windows `tmux`) has no `=name:` targets; tmux.js knows which form.
const { pane: paneTarget } = require("../server/lib/tmux");
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 {
rotateIfLarge(LOG_FILE);
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) {
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, 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"],
});
// psmux (Windows) ignores -F: `name: 1 windows (…)`; a name holds no `:`.
names = out
.split("\n")
.map((s) => s.split(":")[0].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", paneTarget(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 {
let first = true;
for (const command of ["/reload-plugins", "/reload-skills"]) {
// Let the first command actually submit before typing the second, or
// both land on one composer line and Claude Code runs the pair as
// `/reload-plugins /reload-skills` — see SETTLE_MS in
// server/lib/cc-reload.js. Blocking is fine here: this worker is
// detached and serves no requests.
// Atomics.wait, not `sleep`: Windows has no sleep binary.
if (!first) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
first = false;
execFileSync("tmux", ["send-keys", "-t", paneTarget(name), "-l", command], {
timeout: 15_000,
});
execFileSync("tmux", ["send-keys", "-t", paneTarget(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: failureText(err).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: failureText(err).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: failureText(err).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 = failureText(err).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();
}