Files
nntrivi2001 ecbbb4dd8f perf: faster plugin updates, half-size first bundle, images from the chat attach on Windows
- Chat view: an attached image's path is pasted on its own after the text
  (promptForPane). claude turns a paste that is only an image path into
  [Image #n], but left a Windows path inside a paste of text as a plain path.
  Other files and @-mentions stay in the text as before.
- Plugin bootstrap: depsHash covers only what npm installs (no release
  version), so a version bump no longer reinstalls runtime deps; the client's
  installed tree is kept while its manifests are unchanged (no npm ci per
  update); the user-side build runs vite only (tsc -b cost ~25 s / ~900 MB);
  clientBuild is recorded only after a build that did not fail, so a failed
  one is retried; npm output goes to runtime/npm-install.log.
- Server: a port held by another program ends with a clear message and exit 1
  instead of an uncaught 'error' stack trace.
- Client: the onboarding wizard is lazy and rendered only before onboarding;
  usePrefersReducedMotion moved to hooks/ so PetSettings no longer pulls the
  Sagi chat/pane stack into the entry chunk (884 KB -> 431 KB, gzip 268 -> 133 KB).
2026-10-16 10:05:03 +07:00

207 lines
9.1 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. 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ĩ <vinnt@smartgift.vn>
*/
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 `<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";
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());
}
}