Files
AgentHub/scripts/plugin-open.js
nntrivi2001 dab01b7f2b fix(plugin): install the client build's deps with npm ci
npm install reuses whatever the cache and an earlier run left behind and still
reports success, so a truncated cache entry produced a node_modules that was
missing typescript's lib/*.d.ts — the build then died with TS2318, every global
type undefined. npm ci starts from an empty tree and verifies each tarball
against the lockfile's integrity hash. Falls back to install when the copied
client has no usable lockfile.
2026-09-08 15:43:20 +07:00

125 lines
5.0 KiB
JavaScript

#!/usr/bin/env node
/**
* @file plugin-open.js
* @description Builds the dashboard UI bundle for a plugin install and prints
* the dashboard URL. `scripts/plugin-bootstrap.js` calls `buildClient` eagerly
* on session start, so client-only routes (e.g. `/run`) work the moment
* `claude` is started, not just the API. `/agenthub-open` re-runs
* this standalone — a no-op when the bundle already matches — as a fallback
* for `--force` rebuilds or a first build that failed during bootstrap, and it
* re-runs bootstrap first when the live server belongs to a retired plugin root
* (a mid-session `/plugin` update) so the new bundle is not left calling routes
* the running server never had.
*
* The client source is copied out of the (replaced-on-update) plugin cache into
* the runtime dir before building, so neither the cache nor the checkout is
* written to, and the output lands where the server already serves from
* (`DASHBOARD_CLIENT_DIST`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
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";
function run(cmd, args, cwd, stdio) {
const res = spawnSync(cmd, args, { cwd, stdio, shell: process.platform === "win32" });
return res.status === 0;
}
/**
* Build the bundle into `<runtime>/client-dist`. Idempotent: an existing bundle
* is left alone unless `force` is set.
*
* `logPath`, when given, redirects npm's output there instead of inheriting the
* caller's stdio — used when this runs inside the detached bootstrap worker,
* whose own stdout is discarded, so a build failure is still debuggable.
*
* `exec` is the command runner, swappable for tests.
*
* @returns {"ready"|"built"|"failed"}
*/
function buildClient({
rt = boot.runtimeDir(),
root = PLUGIN_ROOT,
force = false,
logPath,
exec = run,
} = {}) {
const dist = path.join(rt, "client-dist");
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
const logFd = logPath ? fs.openSync(logPath, "a") : null;
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
try {
const src = path.join(rt, "client-src");
fs.rmSync(src, { recursive: true, force: true });
fs.cpSync(path.join(root, "client"), src, {
recursive: true,
filter: (p) => !/[\\/](node_modules|dist)$/.test(p),
});
// `npm install` reuses whatever the cache and a previous run left behind and
// still reports success, so a truncated cache entry or an interrupted install
// yields a tree that is missing files nobody notices until `tsc` dies with
// TS2318 (every global type gone, because typescript's own lib/*.d.ts never
// landed). `npm ci` starts from an empty node_modules and checks every
// tarball against the lockfile's integrity hash, so a bad cache entry is
// refetched instead of silently reused. It needs a lockfile that matches
// package.json — fall back to `install` when the copy has none or has drifted.
const npmFlags = ["--no-audit", "--no-fund"];
if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio)
) {
return "failed";
}
if (!exec(NPM, ["run", "build"], src, stdio)) return "failed";
fs.rmSync(dist, { recursive: true, force: true });
fs.cpSync(path.join(src, "dist"), dist, { recursive: true });
return "built";
} finally {
if (logFd !== null) fs.closeSync(logFd);
}
}
function dashboardUrl() {
try {
return `http://localhost:${resolveDashboardPort()}`;
} catch {
return "http://localhost:4820";
}
}
// Exported before the entry block below, not after it: `bootstrap()` requires
// this module back for `buildClient`, and when plugin-open is itself the entry
// that lazy require resolves to whatever is on module.exports at that moment.
module.exports = { buildClient, dashboardUrl };
if (require.main === module) {
// A plugin update applied mid-session (/plugin + /reload-plugins) fires no
// SessionStart, so the server keeps executing a cache dir Claude Code has
// already retired: a bundle rebuilt from the new root then calls routes that
// old server never had. Hand the version handover back to bootstrap, which
// owns stop/start and the dependency install the new root may need.
const stale = (boot.readState() || {}).pluginRoot !== PLUGIN_ROOT || !boot.serverIsLive();
if (stale) boot.bootstrap();
const result = buildClient({ force: process.argv.includes("--force") });
if (result === "failed") {
console.error("Client build failed — the API still works.");
process.exitCode = 1;
} else {
if (result === "built") console.log("Dashboard UI built.");
if (stale) console.log("Server restarted on the current plugin version.");
console.log(dashboardUrl());
}
}