b7f3735b29
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
261 lines
9.1 KiB
JavaScript
261 lines
9.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @file Detached helper that performs a full plugin-cache update end to end, so
|
|
* the dashboard's "Update now" button works for an install running out of
|
|
* `~/.claude/plugins/cache/`. Spawned by `server/lib/plugin-update.js` after the
|
|
* HTTP response is written, because step 3 SIGTERMs the very server that asked
|
|
* for the update — an in-process version would kill itself mid-flight.
|
|
*
|
|
* The three steps:
|
|
* 1. `claude plugin marketplace update <marketplace>`
|
|
* 2. `claude plugin update <plugin>@<marketplace> -y` → new cache dir appears
|
|
* 3. `node <newRoot>/scripts/plugin-bootstrap.js --force` → stops the old
|
|
* server, waits for the ports, installs deps and starts the new one
|
|
*
|
|
* Step 3 is what makes `/reload-plugins` unnecessary here: `plugin-bootstrap.js`
|
|
* derives its own `PLUGIN_ROOT` from `__dirname`, not from `${CLAUDE_PLUGIN_ROOT}`,
|
|
* so running it out of the NEW cache directory is enough to move the dashboard
|
|
* onto the new code. `/reload-plugins` remains needed only for an interactive
|
|
* Claude Code session to pick up new skills/commands/hooks — a thing no
|
|
* background process can do on that session's behalf.
|
|
*
|
|
* Every outcome, including failure, is recorded in `plugin-update.json` under the
|
|
* runtime directory (outside the plugin cache, so it survives the swap) and the
|
|
* new server reports it back as `last_plugin_update`. Without that, a failed
|
|
* chain would look identical to a slow one: the tab would poll a server that
|
|
* never comes back and say nothing about why. A failed step's verdict carries
|
|
* what the command printed (`failureText`): `plugin-bootstrap.js` says why it
|
|
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
|
|
* only "Command failed: <argv>".
|
|
*
|
|
* @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");
|
|
|
|
const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
|
|
|
|
// `|| ""`: required by a test (for `failureText`) there is no argv, and only
|
|
// main() — which a require never runs — writes these.
|
|
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
|
|
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
|
|
|
|
// Names come from the local cache PATH, never from an HTTP body, but they still
|
|
// reach an argv — so they are re-validated here rather than trusted across the
|
|
// process boundary. Anything else is a bug or tampering, and either way this
|
|
// helper must not run.
|
|
const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
|
|
|
|
function log(line) {
|
|
try {
|
|
rotateIfLarge(LOG_FILE);
|
|
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
function record(result) {
|
|
try {
|
|
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
|
fs.writeFileSync(
|
|
RESULT_FILE,
|
|
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
|
|
);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
/** Resolve the `claude` CLI. The server is started detached from a hook, so its
|
|
* PATH is not the user's login shell PATH — check the documented install
|
|
* location before giving up. */
|
|
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;
|
|
}
|
|
|
|
/** Newest cache directory for this plugin, excluding `exclude`. */
|
|
function findNewRoot(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)
|
|
// A cache directory without the bootstrap script is not a usable root.
|
|
.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;
|
|
}
|
|
|
|
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(" ")}`);
|
|
try {
|
|
const out = execFileSync(cmd, args, {
|
|
encoding: "utf8",
|
|
timeout,
|
|
maxBuffer: 10_000_000,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
if (out) log(out.trim().slice(0, 4000));
|
|
return out;
|
|
} catch (err) {
|
|
log(`failed (${howItEnded(err)})`);
|
|
for (const stream of [err.stdout, err.stderr]) {
|
|
if (stream) log(String(stream).trim().slice(-4000));
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function howItEnded(err) {
|
|
if (err.signal) return `killed by ${err.signal}`;
|
|
return err.status != null ? `exit ${err.status}` : err.code || "did not run";
|
|
}
|
|
|
|
/**
|
|
* A failed command's verdict text: the last lines it printed on stdout and
|
|
* stderr, then how it ended. Falls back to execFileSync's message ("Command
|
|
* failed: …", or ENOENT) only when the command printed nothing.
|
|
*/
|
|
function failureText(err) {
|
|
const tail = (s) =>
|
|
String(s || "")
|
|
.trim()
|
|
.split("\n")
|
|
.slice(-5)
|
|
.join("\n");
|
|
const said = [tail(err.stdout), tail(err.stderr)].filter(Boolean);
|
|
if (!said.length) return `${String(err.message || err).split("\n")[0]} (${howItEnded(err)})`;
|
|
return [...said, `(${howItEnded(err)})`].join("\n");
|
|
}
|
|
|
|
function main() {
|
|
if (!SAFE_NAME.test(marketplace || "") || !SAFE_NAME.test(plugin || "")) {
|
|
record({ ok: false, step: "validate", error: "unsafe marketplace or plugin name" });
|
|
return;
|
|
}
|
|
|
|
const claude = resolveClaude();
|
|
if (!claude) {
|
|
record({
|
|
ok: false,
|
|
step: "resolve_claude",
|
|
error: "the `claude` CLI is not on this server's PATH; run /agenthub-update instead",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
|
} catch (err) {
|
|
record({ ok: false, step: "marketplace_update", error: failureText(err) });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
|
} catch (err) {
|
|
record({ ok: false, step: "plugin_update", error: failureText(err) });
|
|
return;
|
|
}
|
|
|
|
// Claude Code's own install record first: newest-mtime is only a guess, and a
|
|
// build installed from a terminal while this server kept running is exactly
|
|
// the one to hand over to, whether or not this chain pulled anything.
|
|
const newRoot = require("./plugin-bootstrap").supersededBy(oldRoot) || findNewRoot(oldRoot);
|
|
if (!newRoot) {
|
|
// Already newest: the marketplace had nothing to pull. Not a failure — the
|
|
// old server is still running and untouched, so say so plainly.
|
|
record({
|
|
ok: true,
|
|
step: "already_current",
|
|
note: "no new plugin version appeared; the running server was left alone",
|
|
plugin_root: oldRoot,
|
|
});
|
|
return;
|
|
}
|
|
|
|
log(`new plugin root: ${newRoot}`);
|
|
// Write the verdict BEFORE the handover, not only after it. The step below
|
|
// retires the process tree this helper hangs off, and an observed run reached
|
|
// the new server with no verdict file at all — so the dashboard reported
|
|
// `last_plugin_update: null` for an update that had in fact succeeded. A
|
|
// pre-written record is overwritten by the real outcome when this survives,
|
|
// and is the outcome when it does not.
|
|
record({
|
|
ok: true,
|
|
step: "bootstrap",
|
|
plugin_root: newRoot,
|
|
note: "the new plugin version is installed; the server is being handed over",
|
|
});
|
|
try {
|
|
// Foreground and blocking: this stops the old server (us), waits for the
|
|
// ports, then starts the new one. It must finish before we record success.
|
|
run(
|
|
process.execPath,
|
|
[path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"],
|
|
900_000
|
|
);
|
|
} catch (err) {
|
|
record({
|
|
ok: false,
|
|
step: "bootstrap",
|
|
error: failureText(err),
|
|
plugin_root: newRoot,
|
|
note: "the old server was stopped; run /agenthub-update to recover",
|
|
});
|
|
return;
|
|
}
|
|
|
|
record({ ok: true, step: "done", plugin_root: newRoot });
|
|
}
|
|
|
|
module.exports = { failureText, resolveClaude };
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
main();
|
|
} catch (err) {
|
|
// A helper that dies without a verdict is the failure mode this file exists
|
|
// to prevent, so even an unexpected throw gets recorded.
|
|
record({ ok: false, step: "unexpected", error: err.message || String(err) });
|
|
}
|
|
}
|