af014d9829
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
312 lines
11 KiB
JavaScript
312 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");
|
|
|
|
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"],
|
|
});
|
|
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 {
|
|
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.
|
|
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
|
|
first = false;
|
|
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: 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();
|
|
}
|