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.
This commit is contained in:
+102
-4
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const path = require("path");
|
||||
const cc = require("../lib/cc-discovery");
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
const ccCli = require("../lib/cc-plugin-cli");
|
||||
@@ -32,6 +33,13 @@ const ccProfiles = require("../lib/cc-profiles");
|
||||
const projectMemory = require("../lib/project-memory");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { broadcast } = require("../websocket");
|
||||
const { reloadOpenSessions } = require("../lib/cc-reload");
|
||||
const { startUpdateAll, readUpdateAll, isRunning } = require("../lib/cc-update-all");
|
||||
const { detectPluginInstall } = require("../lib/update-check");
|
||||
|
||||
/** Mirrors cc-plugin-cli's own spec rule — ids from the CLI's JSON still reach
|
||||
* an argv in the detached batch worker, so they are filtered before they go. */
|
||||
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -265,7 +273,7 @@ function cliError(res, err) {
|
||||
}
|
||||
|
||||
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
|
||||
function cliResult(res, result, changed) {
|
||||
function cliResult(res, result, changed, extra) {
|
||||
if (!result.ok) {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
@@ -278,7 +286,7 @@ function cliResult(res, result, changed) {
|
||||
});
|
||||
}
|
||||
emitChanged(changed);
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, ...extra });
|
||||
}
|
||||
|
||||
router.get("/plugins/available", async (_req, res) => {
|
||||
@@ -307,7 +315,7 @@ const PLUGIN_ACTIONS = {
|
||||
};
|
||||
|
||||
router.post("/plugins/action", async (req, res) => {
|
||||
const { key, action } = req.body || {};
|
||||
const { key, action, reload } = req.body || {};
|
||||
const run = PLUGIN_ACTIONS[action];
|
||||
if (!run) {
|
||||
return res.status(400).json({
|
||||
@@ -319,12 +327,102 @@ router.post("/plugins/action", async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const result = await run({ spec: key });
|
||||
return cliResult(res, result, { action, type: "plugin", name: key });
|
||||
// "Update one, reload that one" — the counterpart to the batch, which
|
||||
// reloads once at the end. Only on success, and only when asked: an
|
||||
// enable/disable already needs a reload but the caller decides, and a
|
||||
// reload after a FAILED update would tell the session to activate nothing.
|
||||
let reloaded;
|
||||
if (reload && result.ok) {
|
||||
try {
|
||||
reloaded = reloadOpenSessions();
|
||||
} catch {
|
||||
/* a reload that cannot be delivered must not fail the update itself */
|
||||
}
|
||||
}
|
||||
return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Type `/reload-plugins` + `/reload-skills` into the live Claude Code lane
|
||||
* panes. Not a config mutation — it changes nothing on disk, it just tells
|
||||
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||
* mutation chain nor emits `cc_config_changed`.
|
||||
*/
|
||||
router.post("/reload", (req, res) => {
|
||||
const { kinds } = req.body || {};
|
||||
if (kinds !== undefined && !Array.isArray(kinds)) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADARG", message: "kinds must be an array of 'plugins' | 'skills'" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
res.json({ ok: true, ...reloadOpenSessions({ kinds }) });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "ERELOAD", message: err.message || String(err) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Update all ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Runs detached (see server/lib/cc-update-all.js): the batch ends with AgentHub
|
||||
// itself when this server runs from the plugin cache, and that step stops the
|
||||
// process. The response is therefore only an acknowledgement — progress is
|
||||
// polled from GET below, which reads the worker's on-disk journal.
|
||||
|
||||
router.get("/plugins/update-all", (_req, res) => {
|
||||
res.json({ batch: readUpdateAll() });
|
||||
});
|
||||
|
||||
router.post("/plugins/update-all", async (req, res) => {
|
||||
if (isRunning()) {
|
||||
return res.status(409).json({
|
||||
error: { code: "EBUSY", message: "an update-all batch is already running" },
|
||||
batch: readUpdateAll(),
|
||||
});
|
||||
}
|
||||
|
||||
let installed;
|
||||
try {
|
||||
installed = (await ccCli.listAvailable()).installed || [];
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
|
||||
// Ids come from the CLI's own JSON, never from the request body, but they
|
||||
// reach an argv in a detached process — so they are filtered here too.
|
||||
const plugins = installed.map((p) => String(p.id || "")).filter((id) => PLUGIN_SPEC_RE.test(id));
|
||||
|
||||
// Which plugin, if any, is the one this dashboard is running from. Derived
|
||||
// from our own path — never from the request — so no caller can nominate a
|
||||
// different plugin as "self" and get a bootstrap run against it.
|
||||
const selfRoot = path.resolve(__dirname, "..", "..");
|
||||
const detected = detectPluginInstall(selfRoot);
|
||||
const selfPlugin = detected
|
||||
? { marketplace: detected.marketplace, plugin: detected.plugin }
|
||||
: null;
|
||||
|
||||
const plan = {
|
||||
plugins,
|
||||
selfPlugin,
|
||||
selfRoot: selfPlugin ? selfRoot : null,
|
||||
reload: req.body?.reload !== false,
|
||||
};
|
||||
|
||||
try {
|
||||
const started = startUpdateAll(plan);
|
||||
res.json({ ok: true, ...started, planned: plugins.length, self_plugin: selfPlugin });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "EUPDATEALL", message: err.message || String(err) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/marketplaces/add", async (req, res) => {
|
||||
const { source, scope } = req.body || {};
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user