#!/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 and MCP tools. `/ccam-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. * * 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 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 `/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. * * @returns {"ready"|"built"|"failed"} */ function buildClient({ rt = boot.runtimeDir(), root = PLUGIN_ROOT, force = false, logPath } = {}) { 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), }); if (!run(NPM, ["install", "--no-audit", "--no-fund"], src, stdio)) return "failed"; if (!run(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"; } } if (require.main === module) { const result = buildClient({ force: process.argv.includes("--force") }); if (result === "failed") { console.error("Client build failed — the API and MCP tools still work."); process.exitCode = 1; } else { if (result === "built") console.log("Dashboard UI built."); console.log(dashboardUrl()); } } module.exports = { buildClient, dashboardUrl };