aca35dd4cd
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.
284 lines
9.7 KiB
JavaScript
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();
|
|
}
|