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.
297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
/**
|
|
* @file cc-plugin-cli.js
|
|
* @description Drives the official `claude plugin …` CLI so the dashboard can
|
|
* add/remove marketplaces and install/uninstall/enable/disable/update plugins
|
|
* without the user opening a terminal.
|
|
*
|
|
* Why the CLI and not direct JSON edits: `installed_plugins.json`,
|
|
* `known_marketplaces.json` and the plugin cache tree are written together by
|
|
* the CLI (it clones the marketplace repo, resolves versions, prunes the
|
|
* cache). Reproducing that here would drift the moment the CLI's schema moves.
|
|
*
|
|
* Hard constraints (do not relax without a follow-up review):
|
|
* - `execFile` with an explicit argv array. NEVER a shell string — the
|
|
* `source`/`spec` values come straight from a user-typed field.
|
|
* - Every argument is validated BEFORE the spawn: no leading `-` (a value
|
|
* can never become a flag), no control characters, and a shape allowlist
|
|
* per argument kind.
|
|
* - Mutating calls are serialized in-process; two dashboard clicks must not
|
|
* interleave writes to the same manifest.
|
|
* - `CLAUDE_CONFIG_DIR` is pinned ONLY when the operator set `CLAUDE_HOME`.
|
|
* Left unset it stays unset, because the CLI's own defaults already write
|
|
* where `cc-discovery` looks — pinning it unconditionally is what once
|
|
* made every MCP add/remove write a file nothing reads.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { execFile } = require("node:child_process");
|
|
const path = require("node:path");
|
|
const { getClaudeHome } = require("./claude-home");
|
|
|
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
|
|
/** `owner/repo` — the GitHub shorthand `claude plugin marketplace add` accepts. */
|
|
// Both halves must START with an alphanumeric, so `./relative` is not mistaken
|
|
// for `owner/repo`.
|
|
const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
/** `name` or `name@marketplace`. No slashes: a plugin spec is never a path. */
|
|
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
|
/** A marketplace's registered name (what `known_marketplaces.json` is keyed by). */
|
|
const MARKETPLACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
/** Install scopes the CLI understands — note `local`, not `project-local`. */
|
|
const CLI_SCOPES = ["user", "project", "local"];
|
|
|
|
function makeError(code, message) {
|
|
const err = new Error(message);
|
|
err.code = code;
|
|
return err;
|
|
}
|
|
|
|
/**
|
|
* Baseline check applied to every argv element we interpolate. A value that
|
|
* starts with `-` would be parsed as a flag by the CLI even inside an argv
|
|
* array, so it is rejected rather than escaped.
|
|
*/
|
|
function assertArg(value, label) {
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
throw makeError("EBADARG", `${label} is required`);
|
|
}
|
|
if (value.length > 512) throw makeError("EBADARG", `${label} is too long`);
|
|
// eslint-disable-next-line no-control-regex
|
|
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
|
throw makeError("EBADARG", `${label} contains control characters`);
|
|
}
|
|
if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`);
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* A marketplace source is one of: `owner/repo`, an http(s) URL, or an absolute
|
|
* path on this machine. Anything else (an ssh remote, a `file://` URL, a
|
|
* relative path) is refused — the set the UI advertises is the set we accept.
|
|
*/
|
|
function assertSource(value) {
|
|
assertArg(value, "source");
|
|
const v = value.trim();
|
|
if (v.includes("..")) throw makeError("EBADARG", "source may not contain '..'");
|
|
if (/^https?:\/\/\S+$/i.test(v)) return v;
|
|
if (path.isAbsolute(v)) return v;
|
|
if (REPO_RE.test(v)) return v;
|
|
throw makeError("EBADARG", "source must be owner/repo, an http(s) URL, or an absolute path");
|
|
}
|
|
|
|
function assertPluginSpec(value) {
|
|
assertArg(value, "plugin");
|
|
if (!PLUGIN_SPEC_RE.test(value)) {
|
|
throw makeError("EBADARG", "plugin must be <name> or <name>@<marketplace>");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertMarketplaceName(value) {
|
|
assertArg(value, "marketplace");
|
|
if (!MARKETPLACE_NAME_RE.test(value)) {
|
|
throw makeError("EBADARG", "marketplace name is not a valid identifier");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertCliScope(value) {
|
|
if (value === undefined || value === null || value === "") return "user";
|
|
if (!CLI_SCOPES.includes(value)) {
|
|
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// One in-process queue for every mutating CLI call. The dashboard is a single
|
|
// Node process, so a promise chain is the whole locking story — no file lock,
|
|
// no cross-process coordination. It does NOT protect against a human running
|
|
// `claude plugin …` in a terminal at the same moment; the CLI owns that race.
|
|
let mutationQueue = Promise.resolve();
|
|
|
|
function serialize(fn) {
|
|
const run = mutationQueue.then(fn, fn);
|
|
mutationQueue = run.then(
|
|
() => undefined,
|
|
() => undefined
|
|
);
|
|
return run;
|
|
}
|
|
|
|
/**
|
|
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
|
|
* normal result (`ok:false`), not a rejection — callers map it to a response
|
|
* body. Only a spawn failure (binary missing) rejects.
|
|
*
|
|
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
|
|
* @param {{timeoutMs?:number}} [opts]
|
|
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
|
|
*/
|
|
function runClaude(args, opts = {}) {
|
|
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
return new Promise((resolve, reject) => {
|
|
// Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME
|
|
// (a non-default install). Left unset, the CLI's own default config dir
|
|
// is `~/.claude` and it writes `.claude.json` NEXT TO it (`~/.claude.json`)
|
|
// — the exact file cc-discovery.getClaudeJsonPath() reads (see that
|
|
// function's own comment). Pinning CLAUDE_CONFIG_DIR unconditionally to
|
|
// getClaudeHome() made the CLI write `~/.claude/.claude.json` instead —
|
|
// a file nothing reads — so every default-install add/remove was inert.
|
|
// When CLAUDE_HOME IS set, this pins it and the CLI writes
|
|
// <CLAUDE_HOME>/.claude.json — which is what getClaudeJsonPath() reads in
|
|
// that branch, so both cases line up. Keep the two in step.
|
|
const env = { ...process.env };
|
|
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
|
const child = execFile(
|
|
"claude",
|
|
args,
|
|
{
|
|
timeout,
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
env,
|
|
},
|
|
(err, stdout, stderr) => {
|
|
const out = String(stdout || "");
|
|
const errOut = String(stderr || "");
|
|
if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
|
|
return reject(
|
|
makeError(
|
|
"ECLINOTFOUND",
|
|
"the `claude` CLI was not found on PATH — install Claude Code or add it to the dashboard's PATH"
|
|
)
|
|
);
|
|
}
|
|
if (err && err.killed) {
|
|
return reject(makeError("ETIMEOUT", `claude ${args[0]} timed out after ${timeout}ms`));
|
|
}
|
|
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
|
|
resolve({ ok: !err, code, stdout: out, stderr: errOut, args });
|
|
}
|
|
);
|
|
// Every command here is non-interactive. Closing stdin turns a would-be
|
|
// prompt into an immediate CLI error instead of a hang until `timeout`.
|
|
try {
|
|
child.stdin?.end();
|
|
} catch {
|
|
/* the child may already have exited */
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Operations ─────────────────────────────────────────────────────────
|
|
// Each validates first, then queues. Validation errors throw synchronously
|
|
// from the returned promise, which is what the routes turn into a 400.
|
|
|
|
async function marketplaceAdd({ source, scope }) {
|
|
const src = assertSource(source);
|
|
const sc = assertCliScope(scope);
|
|
return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc]));
|
|
}
|
|
|
|
async function marketplaceRemove({ name }) {
|
|
const n = assertMarketplaceName(name);
|
|
return serialize(() => runClaude(["plugin", "marketplace", "remove", n]));
|
|
}
|
|
|
|
async function marketplaceUpdate({ name }) {
|
|
const n = assertMarketplaceName(name);
|
|
return serialize(() => runClaude(["plugin", "marketplace", "update", n]));
|
|
}
|
|
|
|
async function pluginInstall({ spec, scope }) {
|
|
const s = assertPluginSpec(spec);
|
|
const sc = assertCliScope(scope);
|
|
// `-y` accepts the marketplace-declared install command without a prompt;
|
|
// required because stdin is not a TTY here.
|
|
return serialize(() => runClaude(["plugin", "install", s, "--scope", sc, "-y"]));
|
|
}
|
|
|
|
async function pluginUninstall({ spec }) {
|
|
const s = assertPluginSpec(spec);
|
|
return serialize(() => runClaude(["plugin", "uninstall", s]));
|
|
}
|
|
|
|
async function pluginEnable({ spec }) {
|
|
const s = assertPluginSpec(spec);
|
|
return serialize(() => runClaude(["plugin", "enable", s]));
|
|
}
|
|
|
|
async function pluginDisable({ spec }) {
|
|
const s = assertPluginSpec(spec);
|
|
return serialize(() => runClaude(["plugin", "disable", s]));
|
|
}
|
|
|
|
async function pluginUpdate({ spec }) {
|
|
const s = assertPluginSpec(spec);
|
|
// `-y` for the same reason as pluginInstall: the CLI requires it whenever
|
|
// stdin is not a TTY (runClaude closes stdin), and without it an update whose
|
|
// marketplace declares an install command fails on a prompt nobody can answer.
|
|
return serialize(() => runClaude(["plugin", "update", s, "-y"]));
|
|
}
|
|
|
|
/**
|
|
* `claude plugin list --available --json` — every plugin offered by a
|
|
* registered marketplace, plus what is already installed. Read-only, so it
|
|
* skips the mutation queue.
|
|
*
|
|
* @returns {Promise<{installed:object[], available:object[]}>}
|
|
*/
|
|
async function listAvailable() {
|
|
const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 });
|
|
if (!res.ok) {
|
|
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(res.stdout);
|
|
} catch {
|
|
throw makeError("ECLIFAILED", "could not parse `claude plugin list --json` output");
|
|
}
|
|
return {
|
|
installed: Array.isArray(parsed.installed) ? parsed.installed : [],
|
|
available: Array.isArray(parsed.available) ? parsed.available : [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Best-effort human message from a failed run. The CLI prints its errors with
|
|
* a `✘` marker on either stream; fall back to the last non-empty line.
|
|
*/
|
|
function cliMessage(res) {
|
|
const text = `${res.stderr || ""}\n${res.stdout || ""}`;
|
|
const lines = text
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
const marked = lines.find((l) => l.includes("✘"));
|
|
const picked = marked || lines[lines.length - 1] || "";
|
|
return picked.replace(/^✘\s*/, "").slice(0, 1000);
|
|
}
|
|
|
|
module.exports = {
|
|
runClaude,
|
|
cliMessage,
|
|
// Exported so cc-mcp-cli.js queues onto this SAME chokepoint — one CLI
|
|
// process against CLAUDE_CONFIG_DIR at a time, whether the click was a
|
|
// plugin install or an MCP add.
|
|
serialize,
|
|
listAvailable,
|
|
marketplaceAdd,
|
|
marketplaceRemove,
|
|
marketplaceUpdate,
|
|
pluginInstall,
|
|
pluginUninstall,
|
|
pluginEnable,
|
|
pluginDisable,
|
|
pluginUpdate,
|
|
// exported for tests
|
|
assertSource,
|
|
assertPluginSpec,
|
|
assertMarketplaceName,
|
|
assertCliScope,
|
|
PLUGIN_SPEC_RE,
|
|
};
|