chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
This commit is contained in:
+123
-62
@@ -21,11 +21,23 @@
|
||||
* 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.
|
||||
* - S3, a member's own config (`acct` on any operation): the shared `claude`
|
||||
* (`shared-tools.sharedPath`, ENOTSHARED when it is not published) runs
|
||||
* AS the member — `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
|
||||
* <claude> …` in their home, with their own default config dir. Only
|
||||
* `--scope user`, and never `-y`: a plugin whose marketplace declares an
|
||||
* install command fails instead of running unseen as them. Each account
|
||||
* has its own queue, so one member's slow clone never holds up another's.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFile } = require("node:child_process");
|
||||
const { execFile: realExecFile } = require("node:child_process");
|
||||
// Test seam: how `claude` (or `sudo … claude`) is spawned.
|
||||
let execFile = realExecFile;
|
||||
function __setExecFileImpl(fn) {
|
||||
execFile = fn || realExecFile;
|
||||
}
|
||||
const path = require("node:path");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
@@ -98,57 +110,48 @@ function assertMarketplaceName(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertCliScope(value) {
|
||||
function assertCliScope(value, acct) {
|
||||
if (value === undefined || value === null || value === "") return "user";
|
||||
if (!CLI_SCOPES.includes(value)) {
|
||||
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
|
||||
}
|
||||
// A member's CLI runs in their home, which is no project: only their user
|
||||
// layer is theirs to change from here.
|
||||
if (acct && value !== "user") {
|
||||
throw makeError("EBADARG", "only scope user can be changed for your own config");
|
||||
}
|
||||
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();
|
||||
// One in-process queue per account (the host's is "host") 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.
|
||||
const mutationQueues = new Map();
|
||||
|
||||
function serialize(fn) {
|
||||
const run = mutationQueue.then(fn, fn);
|
||||
mutationQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
function serialize(fn, acct) {
|
||||
const key = acct ? acct.login : "host";
|
||||
const queue = mutationQueues.get(key) || Promise.resolve();
|
||||
const run = queue.then(fn, fn);
|
||||
mutationQueues.set(
|
||||
key,
|
||||
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[]}>}
|
||||
* `[command, argv, options]` for `claude <args>`: the host's own `claude` on
|
||||
* PATH, or — for `acct` (S3) — the shared one AS the member through sudo,
|
||||
* with only HOME and DISABLE_AUTOUPDATER set (sudo resets the rest; their
|
||||
* default config dir is their own `~/.claude`), in their home.
|
||||
*/
|
||||
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
|
||||
// It also predates the confirmation prompt the flag answers, so the same
|
||||
// command without it is the correct retry, not a weaker one.
|
||||
const NO_YES_FLAG = /unknown option '-y'/;
|
||||
|
||||
async function runClaude(args, opts = {}) {
|
||||
const result = await runClaudeOnce(args, opts);
|
||||
if (!result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
|
||||
return runClaudeOnce(
|
||||
args.filter((a) => a !== "-y"),
|
||||
opts
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runClaudeOnce(args, opts = {}) {
|
||||
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||
return new Promise((resolve, reject) => {
|
||||
function claudeCommand(args, acct) {
|
||||
if (!acct) {
|
||||
// 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`)
|
||||
@@ -161,13 +164,62 @@ function runClaudeOnce(args, opts = {}) {
|
||||
// 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();
|
||||
return ["claude", args, { env }];
|
||||
}
|
||||
const claude = require("./shared-tools").sharedPath("claude");
|
||||
if (!claude) throw makeError("ENOTSHARED", "claude is not in the shared tools yet");
|
||||
// Never -y for a member, and never a --dangerously-* flag (none is built
|
||||
// here; the validators refuse any value starting with "-").
|
||||
const safe = args.filter((a) => a !== "-y");
|
||||
const [cmd, argv] = require("./run-as").sudoArgv(acct, claude, safe, {
|
||||
HOME: acct.home,
|
||||
DISABLE_AUTOUPDATER: "1",
|
||||
});
|
||||
return [cmd, argv, { env: process.env, cwd: acct.home }];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, acct?:object}} [opts] `acct`: run AS that member (S3).
|
||||
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
|
||||
*/
|
||||
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
|
||||
// It also predates the confirmation prompt the flag answers, so the same
|
||||
// command without it is the correct retry, not a weaker one.
|
||||
const NO_YES_FLAG = /unknown option '-y'/;
|
||||
|
||||
async function runClaude(args, opts = {}) {
|
||||
const result = await runClaudeOnce(args, opts);
|
||||
if (!opts.acct && !result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
|
||||
return runClaudeOnce(
|
||||
args.filter((a) => a !== "-y"),
|
||||
opts
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runClaudeOnce(args, opts = {}) {
|
||||
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd, argv, spawnOpts;
|
||||
try {
|
||||
[cmd, argv, spawnOpts] = claudeCommand(args, opts.acct);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
const child = execFile(
|
||||
"claude",
|
||||
args,
|
||||
cmd,
|
||||
argv,
|
||||
{
|
||||
timeout,
|
||||
maxBuffer: MAX_OUTPUT_BYTES,
|
||||
env,
|
||||
...spawnOpts,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
const out = String(stdout || "");
|
||||
@@ -201,51 +253,55 @@ function runClaudeOnce(args, opts = {}) {
|
||||
// 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 }) {
|
||||
// `acct` (S3): run AS that member, on their own config — see the file header.
|
||||
const queued = (args, acct) => serialize(() => runClaude(args, { acct }), acct);
|
||||
|
||||
async function marketplaceAdd({ source, scope, acct }) {
|
||||
const src = assertSource(source);
|
||||
const sc = assertCliScope(scope);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc]));
|
||||
const sc = assertCliScope(scope, acct);
|
||||
return queued(["plugin", "marketplace", "add", src, "--scope", sc], acct);
|
||||
}
|
||||
|
||||
async function marketplaceRemove({ name }) {
|
||||
async function marketplaceRemove({ name, acct }) {
|
||||
const n = assertMarketplaceName(name);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "remove", n]));
|
||||
return queued(["plugin", "marketplace", "remove", n], acct);
|
||||
}
|
||||
|
||||
async function marketplaceUpdate({ name }) {
|
||||
async function marketplaceUpdate({ name, acct }) {
|
||||
const n = assertMarketplaceName(name);
|
||||
return serialize(() => runClaude(["plugin", "marketplace", "update", n]));
|
||||
return queued(["plugin", "marketplace", "update", n], acct);
|
||||
}
|
||||
|
||||
async function pluginInstall({ spec, scope }) {
|
||||
async function pluginInstall({ spec, scope, acct }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
const sc = assertCliScope(scope);
|
||||
const sc = assertCliScope(scope, acct);
|
||||
// `-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"]));
|
||||
// required because stdin is not a TTY here. Never for a member (dropped by
|
||||
// claudeCommand): there such a plugin fails instead.
|
||||
return queued(["plugin", "install", s, "--scope", sc, "-y"], acct);
|
||||
}
|
||||
|
||||
async function pluginUninstall({ spec }) {
|
||||
async function pluginUninstall({ spec, acct }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "uninstall", s]));
|
||||
return queued(["plugin", "uninstall", s], acct);
|
||||
}
|
||||
|
||||
async function pluginEnable({ spec }) {
|
||||
async function pluginEnable({ spec, acct }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "enable", s]));
|
||||
return queued(["plugin", "enable", s], acct);
|
||||
}
|
||||
|
||||
async function pluginDisable({ spec }) {
|
||||
async function pluginDisable({ spec, acct }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "disable", s]));
|
||||
return queued(["plugin", "disable", s], acct);
|
||||
}
|
||||
|
||||
async function pluginUpdate({ spec }) {
|
||||
async function pluginUpdate({ spec, acct }) {
|
||||
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"]));
|
||||
return queued(["plugin", "update", s, "-y"], acct);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,10 +309,14 @@ async function pluginUpdate({ spec }) {
|
||||
* registered marketplace, plus what is already installed. Read-only, so it
|
||||
* skips the mutation queue.
|
||||
*
|
||||
* @param {{acct?: object}} [opts] `acct`: the member's own list (S3).
|
||||
* @returns {Promise<{installed:object[], available:object[]}>}
|
||||
*/
|
||||
async function listAvailable() {
|
||||
const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 });
|
||||
async function listAvailable({ acct } = {}) {
|
||||
const res = await runClaude(["plugin", "list", "--available", "--json"], {
|
||||
timeoutMs: 30_000,
|
||||
acct,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
|
||||
}
|
||||
@@ -309,4 +369,5 @@ module.exports = {
|
||||
assertMarketplaceName,
|
||||
assertCliScope,
|
||||
PLUGIN_SPEC_RE,
|
||||
__setExecFileImpl,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user