#!/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. It exits 1 when that bootstrap does not answer * `ok` (bar `locked` — another one is running) as well as when the build fails, * so `/agenthub-open` knows to run the doctor. * * 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ĩ */ const fs = require("fs"); const { rotateIfLarge } = require("./log-rotate"); 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 = "npm"; const NODE = "node"; /** NPM runs through `boot.npmCommand`, NODE is the running node itself — * neither is ever a PATH lookup. */ function run(cmd, args, cwd, stdio) { if (cmd === NODE) return spawnSync(process.execPath, args, { cwd, stdio }).status === 0; const npm = boot.npmCommand(args); const res = spawnSync(npm.file, npm.args, { cwd, stdio, env: npm.env, shell: npm.shell }); return res.status === 0; } /** * Build the bundle into `/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"; if (logPath) rotateIfLarge(logPath); const logFd = logPath ? fs.openSync(logPath, "a") : null; const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd]; try { const src = path.join(rt, "client-src"); // The installed tree is kept across updates while the client's manifests // are unchanged (`stamp`, written after a successful install): a release // rarely touches them, and the clean `npm ci` below fetched all of the // ~200 MB toolchain again on every update. const stamp = path.join(src, "node_modules", ".agenthub-deps"); const want = clientDepsHash(root); const reuse = readText(stamp) === want; if (reuse) { for (const e of fs.readdirSync(src)) if (e !== "node_modules") fs.rmSync(path.join(src, e), { recursive: true, force: true }); } else { 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. // // `--include=dev` is not optional here: the whole build toolchain (vite, // typescript, @vitejs/plugin-react, tailwindcss, @types/*) lives in the // client's devDependencies, and npm reads `NODE_ENV=production` as // `--omit=dev`. Every bootstrap descended from the running server inherits // that NODE_ENV — the self-update chain and any SessionStart fired from a // tmux pane the server started — so the install silently dropped `vite`, // `npm run build` failed, and the PREVIOUS bundle stayed in place: the // dashboard kept serving the old version's UI while the API was already the // new one. An explicit CLI flag outranks the env, which is the point. // // `--ignore-scripts`, and the build run by node itself below rather than // `npm run build`: a lifecycle script and a `.bin/*.cmd` shim both go // through cmd.exe and look `node` up on PATH, and on Windows the server's // PATH did not have it — esbuild's postinstall died with "'node' is not // recognized", both installs failed, and the update kept the old UI. // esbuild is the only dependency with an install script, and it finds its // platform binary without it. const npmFlags = ["--no-audit", "--no-fund", "--include=dev", "--ignore-scripts"]; if (!reuse) { if ( !exec(NPM, ["ci", ...npmFlags], src, stdio) && !exec(NPM, ["install", ...npmFlags], src, stdio) ) { return "failed"; } fs.mkdirSync(path.dirname(stamp), { recursive: true }); fs.writeFileSync(stamp, want); } // client/package.json's "build" script is `tsc -b && vite build`; only the // vite half runs here. The type check emits nothing the bundle needs, and on // a user's machine it cost ~25 s and ~900 MB of memory per update — and a // type error in a release then kept the old UI everywhere (v2.7.0). The // release itself still runs `tsc -b`. A test keeps the two in step. const bin = (...p) => path.join(src, "node_modules", ...p); if (!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)) { // Maybe a damaged kept tree: the next build starts from a clean install. fs.rmSync(stamp, { force: true }); 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 readText(file) { try { return fs.readFileSync(file, "utf8"); } catch { return null; } } /** The client's manifests, which decide whether its installed tree still fits. */ function clientDepsHash(root) { const h = require("crypto").createHash("sha256"); for (const f of ["package.json", "package-lock.json"]) h.update(readText(path.join(root, "client", f)) ?? `no ${f}`); return h.digest("hex").slice(0, 16); } 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(); // Its answer matters: printing a URL with exit 0 after a refused bootstrap // (old Node, failed install) told /agenthub-open everything was fine. const booted = stale ? boot.bootstrap() : "ok"; if (booted === "locked") { // A SessionStart bootstrap holds the lock and will (re)start the server // itself; a doctor's `--force` would only hit the same lock. console.error("Another bootstrap is running — the server restarts when it finishes."); } else if (booted !== "ok") { console.error( `The dashboard server was not (re)started: bootstrap answered "${booted}" — ` + `see ${path.join(boot.runtimeDir(), "bootstrap.log")}` ); process.exitCode = 1; } 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 && booted === "ok") console.log("Server restarted on the current plugin version."); console.log(dashboardUrl()); } }