Files
openclaw/scripts/ui.js
xin zhuang 6704d0ab27 fix(scripts): include ui:build in build-all full and ciArtifacts profiles (#86010)
* fix(scripts): include ui:build in build-all full and ciArtifacts profiles

Closes #85206.

scripts/build-all.mjs only ran ui:build via a separate `pnpm ui:build`
command. Because `pnpm build` invokes tsdown which removes `dist/`,
a backend rebuild silently deletes any previously generated
dist/control-ui assets, leaving the gateway to serve the
"Control UI assets not found" message at startup. Documentation and
startup auto-repair masked the bug at the worst possible time
(LaunchAgent readiness / remote recovery) instead of guaranteeing the
build artifact contract.

This change adds ui:build as a build-all step after
copy-export-html-templates and before write-build-info, and includes
it in the full and ciArtifacts profiles. Minimal backend dev profiles
(gatewayWatch, cliStartup) keep their existing fast-loop step lists
and do not run ui:build.

Regression coverage:
- ciArtifacts step list assertion updated to match the new ordering.
- Three new resolveBuildAllSteps assertions: ui:build is in full and
  ciArtifacts and runs after tsdown/runtime-postbuild-stamp and before
  write-build-info; ui:build is excluded from gatewayWatch/cliStartup;
  ui:build cache outputs declare dist/control-ui.

* fix(scripts): leave ui:build uncached so dist/control-ui never restores stale build IDs

ClawSweeper review on #86010 flagged that the original ui:build cache only
hashed ui/, scripts/ui.js, and scripts/lib/copy-assets.ts, but
ui/vite.config.ts also reads package.json plus git HEAD and the
OPENCLAW_CONTROL_UI_BUILD_ID/OPENCLAW_VERSION env vars to embed a build ID
into the app and service worker. A file-input cache signature cannot
exactly invalidate those metadata sources, so a warm build-all hit could
restore a previously generated dist/control-ui after tsdown clears dist
and ship stale service-worker/app cache metadata.

Leaving the step uncached keeps the contract simple: every pnpm build
re-runs Vite, which is fast for the Control UI bundle and matches the
existing behavior of every other un-cached build-all step. Backend-only
profiles (gatewayWatch, cliStartup) are still unchanged.

Tests:
- Updated the ui:build cache assertion to require step.cache to be
  undefined and explain the metadata-input reason.
- Existing presence/order/exclusion assertions for ui:build are unchanged
  and still cover the full and ciArtifacts profile contract.

* fix(scripts): keep ui build fallback pnpm-free

---------

Co-authored-by: 1052326311 <1052326311@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-25 08:08:52 +01:00

229 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const uiDir = path.join(repoRoot, "ui");
const WINDOWS_CMD_EXE_EXTENSIONS = new Set([".cmd", ".bat"]);
function usage() {
// keep this tiny; it's invoked from npm scripts too
process.stderr.write("Usage: node scripts/ui.js <install|dev|build|test> [...args]\n");
}
function which(cmd) {
try {
const key = process.platform === "win32" ? "Path" : "PATH";
const paths = (process.env[key] ?? process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean);
const extensions =
process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
: [""];
for (const entry of paths) {
for (const ext of extensions) {
const candidate = path.join(entry, process.platform === "win32" ? `${cmd}${ext}` : cmd);
try {
if (fs.existsSync(candidate)) {
return candidate;
}
} catch {
// ignore
}
}
}
} catch {
// ignore
}
return null;
}
function resolveRunner() {
const pnpm = which("pnpm");
if (pnpm) {
return { cmd: pnpm, kind: "pnpm" };
}
return null;
}
export function shouldUseCmdExeForCommand(cmd, platform = process.platform) {
if (platform !== "win32") {
return false;
}
const extension = path.extname(cmd).toLowerCase();
return WINDOWS_CMD_EXE_EXTENSIONS.has(extension);
}
export function resolveSpawnCall(cmd, args, envOverride, params = {}) {
const platform = params.platform ?? process.platform;
const comSpec = params.comSpec ?? process.env.ComSpec ?? "cmd.exe";
const options = {
cwd: params.cwd ?? uiDir,
stdio: "inherit",
env: envOverride ?? process.env,
shell: false,
};
if (shouldUseCmdExeForCommand(cmd, platform)) {
return {
command: comSpec,
args: ["/d", "/s", "/c", buildCmdExeCommandLine(cmd, args)],
options: {
...options,
windowsVerbatimArguments: true,
},
};
}
return {
command: cmd,
args,
options,
};
}
function run(cmd, args) {
const { command, args: spawnArgs, options } = resolveSpawnCall(cmd, args);
let child;
try {
child = spawn(command, spawnArgs, options);
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);
return;
}
child.on("error", (err) => {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);
});
child.on("exit", (code) => {
if (code !== 0) {
process.exit(code ?? 1);
}
});
}
function runSync(cmd, args, envOverride) {
const { command, args: spawnArgs, options } = resolveSpawnCall(cmd, args, envOverride);
let result;
try {
result = spawnSync(command, spawnArgs, options);
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
process.exit(1);
return;
}
if (result.signal) {
process.exit(1);
}
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
}
function depsInstalled(kind) {
try {
const require = createRequire(path.join(uiDir, "package.json"));
require.resolve("vite");
require.resolve("dompurify");
if (kind === "test") {
require.resolve("vitest");
require.resolve("@vitest/browser-playwright");
require.resolve("playwright");
}
return true;
} catch {
return false;
}
}
function resolveScriptAction(action) {
if (action === "install") {
return null;
}
if (action === "dev") {
return "dev";
}
if (action === "build") {
return "build";
}
if (action === "test") {
return "test";
}
return null;
}
export function main(argv = process.argv.slice(2)) {
const [action, ...rest] = argv;
if (!action) {
usage();
process.exit(2);
}
const script = resolveScriptAction(action);
if (action !== "install" && !script) {
usage();
process.exit(2);
}
if (process.env.OPENCLAW_BUILD_ALL_NO_PNPM === "1" && action === "build") {
run(process.execPath, [path.join(repoRoot, "node_modules/vite/bin/vite.js"), "build", ...rest]);
return;
}
const runner = resolveRunner();
if (!runner) {
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
process.exit(1);
}
if (action === "install") {
run(runner.cmd, ["install", ...rest]);
return;
}
if (!depsInstalled(action === "test" ? "test" : "build")) {
const installEnv = process.env;
const installArgs = ["install"];
runSync(runner.cmd, installArgs, installEnv);
}
run(runner.cmd, ["run", script, ...rest]);
}
export function resolveDirectExecutionPath(entry, realpath = fs.realpathSync.native) {
const resolved = path.resolve(entry);
try {
return realpath(resolved);
} catch {
return resolved;
}
}
export function isDirectScriptExecution(
entry = process.argv[1],
scriptPath = fileURLToPath(import.meta.url),
realpath = fs.realpathSync.native,
) {
if (!entry) {
return false;
}
return (
resolveDirectExecutionPath(entry, realpath) === resolveDirectExecutionPath(scriptPath, realpath)
);
}
const isDirectExecution = isDirectScriptExecution();
if (isDirectExecution) {
main();
}