Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8acd5fc29 | |||
| be75572977 |
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.5.0 (rebuild client to match)
|
||||
UI build v2.5.2 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ waits on an install:
|
||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
6. **Legacy hook cleanup** — see above.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.5.0
|
||||
version: 2.5.2
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.2",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+67
-38
@@ -383,6 +383,29 @@ function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath
|
||||
|
||||
/* ------------------------------------------------------------- dependencies */
|
||||
|
||||
/**
|
||||
* npm, run by the node executing this file: `{ file, args, env, shell }` for
|
||||
* `spawnSync`. Its bundled `npm-cli.js` is found from `process.execPath`, so
|
||||
* nothing depends on PATH — the self-update chain runs with the dashboard
|
||||
* server's environment, and on Windows that PATH need not hold the directory
|
||||
* of `npm.cmd` ("'npm.cmd' is not recognized"), even though node itself runs.
|
||||
* PATH is still prefixed with that node's directory so npm's scripts (the UI
|
||||
* build's `tsc`/`vite` shims) find the same node. A node with no bundled npm
|
||||
* (a distro's split nodejs/npm packages) falls back to `npm` on PATH.
|
||||
*/
|
||||
function npmCommand(args, { execPath = process.execPath, env = process.env } = {}) {
|
||||
const dir = path.dirname(execPath);
|
||||
const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
|
||||
const childEnv = { ...env, [pathKey]: [dir, env[pathKey]].filter(Boolean).join(path.delimiter) };
|
||||
const cli = [
|
||||
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), // Windows layout
|
||||
path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // Unix layout
|
||||
].find((p) => fs.existsSync(p));
|
||||
if (cli) return { file: execPath, args: [cli, ...args], env: childEnv, shell: false };
|
||||
const win = process.platform === "win32";
|
||||
return { file: win ? "npm.cmd" : "npm", args, env: childEnv, shell: win };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
|
||||
* the root postinstall from pulling the whole Vite client toolchain; the
|
||||
@@ -395,12 +418,13 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||
const src = path.join(root, f);
|
||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
|
||||
}
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const res = spawnSync(
|
||||
npm,
|
||||
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
|
||||
);
|
||||
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
|
||||
const res = spawnSync(npm.file, npm.args, {
|
||||
cwd: rt,
|
||||
stdio: "inherit",
|
||||
env: npm.env,
|
||||
shell: npm.shell,
|
||||
});
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
@@ -829,6 +853,43 @@ function main() {
|
||||
child.unref();
|
||||
}
|
||||
|
||||
// Exported before the entry block below, not after it: `bootstrap()` requires
|
||||
// plugin-open.js, which requires this module back (`npmCommand`, `runtimeDir`),
|
||||
// and when this file is the entry that require resolves to whatever is on
|
||||
// module.exports at that moment — an empty object if it is assigned last.
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
writeState,
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
isCheckoutHookEntry,
|
||||
stripLegacyHooks,
|
||||
hasLegacyHooks,
|
||||
linkCli,
|
||||
npmCommand,
|
||||
installDeps,
|
||||
serverIsLive,
|
||||
liveServers,
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
bootstrap,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const force = process.argv.includes("--force");
|
||||
if (force || process.argv.includes("--worker")) {
|
||||
@@ -854,35 +915,3 @@ if (require.main === module) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MIN_NODE,
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
runtimeDir,
|
||||
statePath,
|
||||
readState,
|
||||
writeState,
|
||||
depsHash,
|
||||
fastPathOk,
|
||||
nodeVersionOk,
|
||||
lockPath,
|
||||
lockIsStale,
|
||||
acquireLock,
|
||||
releaseLock,
|
||||
isCheckoutHookEntry,
|
||||
stripLegacyHooks,
|
||||
hasLegacyHooks,
|
||||
linkCli,
|
||||
installDeps,
|
||||
serverIsLive,
|
||||
liveServers,
|
||||
livePids,
|
||||
stopDashboard,
|
||||
startDashboard,
|
||||
bootstrap,
|
||||
};
|
||||
|
||||
@@ -125,10 +125,9 @@ function runtimeDepsStatus(rt) {
|
||||
|
||||
/** No npm, no dashboard bundle and no runtime install — both go through it. */
|
||||
function npmStatus() {
|
||||
const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["-v"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
// The same npm the bootstrap and the UI build run (`npmCommand`), not PATH's.
|
||||
const npm = boot.npmCommand(["-v"]);
|
||||
const res = spawnSync(npm.file, npm.args, { encoding: "utf8", env: npm.env, shell: npm.shell });
|
||||
if (res.status !== 0) {
|
||||
return ["fail", "npm", "not runnable — the runtime install and the UI build both need it"];
|
||||
}
|
||||
|
||||
@@ -29,10 +29,12 @@ const boot = require("./plugin-bootstrap");
|
||||
const { resolveDashboardPort } = require("../server/lib/server-info");
|
||||
|
||||
const PLUGIN_ROOT = path.resolve(__dirname, "..");
|
||||
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const NPM = "npm";
|
||||
|
||||
function run(cmd, args, cwd, stdio) {
|
||||
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
|
||||
/** `cmd` is always NPM: run through `boot.npmCommand`, never a PATH lookup. */
|
||||
function run(_cmd, args, cwd, stdio) {
|
||||
const npm = boot.npmCommand(args);
|
||||
const res = spawnSync(npm.file, npm.args, { cwd, stdio, env: npm.env, shell: npm.shell });
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -651,3 +651,62 @@ describe("pruneRetiredPluginRoots", () => {
|
||||
assert.ok(fs.existsSync(sibling));
|
||||
});
|
||||
});
|
||||
|
||||
describe("npmCommand", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-npmcmd-"));
|
||||
after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const cliAt = (...parts) => {
|
||||
const p = path.join(tmp, ...parts);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, "");
|
||||
return p;
|
||||
};
|
||||
|
||||
it("runs the node's own bundled npm (Windows layout), no shell, no PATH lookup", () => {
|
||||
// The "'npm.cmd' is not recognized" failure: the update chain's PATH had no npm.
|
||||
const cli = cliAt("win", "node_modules", "npm", "bin", "npm-cli.js");
|
||||
const execPath = path.join(tmp, "win", "node.exe");
|
||||
const npm = boot.npmCommand(["install"], { execPath, env: { Path: "C:\\other" } });
|
||||
assert.equal(npm.file, execPath);
|
||||
assert.deepEqual(npm.args, [cli, "install"]);
|
||||
assert.equal(npm.shell, false);
|
||||
// Windows env keys keep their own case — a second "PATH" key would be ambiguous.
|
||||
assert.equal(npm.env.Path, [path.dirname(execPath), "C:\\other"].join(path.delimiter));
|
||||
assert.equal(npm.env.PATH, undefined);
|
||||
});
|
||||
|
||||
it("finds the Unix layout (bin/node + lib/node_modules/npm)", () => {
|
||||
const cli = cliAt("unix", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
||||
const execPath = path.join(tmp, "unix", "bin", "node");
|
||||
const npm = boot.npmCommand(["-v"], { execPath, env: { PATH: "/usr/bin" } });
|
||||
assert.deepEqual(npm.args, [
|
||||
path.join(tmp, "unix", "bin", "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
||||
"-v",
|
||||
]);
|
||||
assert.ok(fs.existsSync(npm.args[0]) && path.resolve(npm.args[0]) === cli);
|
||||
assert.equal(npm.env.PATH.split(path.delimiter)[0], path.dirname(execPath));
|
||||
});
|
||||
|
||||
it("falls back to npm on PATH when the node has no bundled npm", () => {
|
||||
const npm = boot.npmCommand(["-v"], { execPath: path.join(tmp, "bare", "node"), env: {} });
|
||||
assert.equal(npm.file, process.platform === "win32" ? "npm.cmd" : "npm");
|
||||
assert.deepEqual(npm.args, ["-v"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry scripts export before they run", () => {
|
||||
// plugin-bootstrap.js run as the entry calls bootstrap() → plugin-open.js,
|
||||
// which requires plugin-bootstrap back and gets whatever module.exports holds
|
||||
// at that moment. Assigned after the entry block it was still {}, and v2.5.1's
|
||||
// update died with "boot.npmCommand is not a function". Requiring the module
|
||||
// (as every other test here does) never takes that path, hence this check.
|
||||
for (const file of ["plugin-bootstrap.js", "plugin-open.js"]) {
|
||||
it(file, () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, "..", "..", "scripts", file), "utf8");
|
||||
const exportsAt = src.indexOf("\nmodule.exports = {");
|
||||
const entryAt = src.indexOf("\nif (require.main === module)");
|
||||
assert.ok(exportsAt > 0 && entryAt > 0, "both markers present");
|
||||
assert.ok(exportsAt < entryAt, "module.exports must be assigned before the entry block");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user