fix(build): support Windows UI builds

This commit is contained in:
Vincent Koc
2026-05-25 12:28:35 +02:00
parent 7ff29a9e6d
commit 0bb9b421f3
6 changed files with 86 additions and 54 deletions

View File

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- Discord: suppress a bot's previous reply body and referenced media from prompt context when a user replies to that bot message, while keeping reply metadata for routing. (#86238) Thanks @fuller-stack-dev.
- Docker E2E: avoid rebuilding the Control UI twice while preparing the shared OpenClaw package tarball for package-backed scenario runs.
- Tests: avoid rebuilding the Control UI twice during the installer Docker smoke now that `pnpm build` includes `ui:build`.
- Build: route `scripts/ui.js` through the shared pnpm runner and keep Control UI chunking helpers in sparse-included source so native Windows Corepack builds can produce `dist/control-ui`.
- Tests: collect QA gateway CPU/RSS metrics on native Windows and give the channel baseline enough turn budget to report slow gateway runs instead of timing out before proof.
- Install/update: bypass npm `min-release-age` policies with `--min-release-age=0` instead of `--before` so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo.
- WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd.

View File

@@ -4,6 +4,7 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -17,42 +18,6 @@ function usage() {
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;
@@ -89,19 +54,42 @@ export function resolveSpawnCall(cmd, args, envOverride, params = {}) {
};
}
function run(cmd, args) {
const { command, args: spawnArgs, options } = resolveSpawnCall(cmd, args);
export function resolvePnpmSpawnCall(pnpmArgs, envOverride, params = {}) {
const env = envOverride ?? process.env;
const platform = params.platform ?? process.platform;
const runner = resolvePnpmRunner({
pnpmArgs,
nodeExecPath: params.nodeExecPath ?? process.execPath,
npmExecPath: params.npmExecPath ?? env.npm_execpath,
comSpec: params.comSpec ?? env.ComSpec,
platform,
});
return {
command: runner.command,
args: runner.args,
options: {
cwd: params.cwd ?? uiDir,
stdio: "inherit",
env,
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
},
};
}
function runSpawnCall(spawnCall, label) {
const { command, args: spawnArgs, options } = spawnCall;
let child;
try {
child = spawn(command, spawnArgs, options);
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
console.error(`Failed to launch ${label}:`, err);
process.exit(1);
return;
}
child.on("error", (err) => {
console.error(`Failed to launch ${cmd}:`, err);
console.error(`Failed to launch ${label}:`, err);
process.exit(1);
});
child.on("exit", (code) => {
@@ -111,13 +99,21 @@ function run(cmd, args) {
});
}
function runSync(cmd, args, envOverride) {
const { command, args: spawnArgs, options } = resolveSpawnCall(cmd, args, envOverride);
function run(cmd, args) {
runSpawnCall(resolveSpawnCall(cmd, args), cmd);
}
function runPnpm(args, envOverride) {
runSpawnCall(resolvePnpmSpawnCall(args, envOverride), "pnpm");
}
function runSpawnCallSync(spawnCall, label) {
const { command, args: spawnArgs, options } = spawnCall;
let result;
try {
result = spawnSync(command, spawnArgs, options);
} catch (err) {
console.error(`Failed to launch ${cmd}:`, err);
console.error(`Failed to launch ${label}:`, err);
process.exit(1);
return;
}
@@ -129,6 +125,10 @@ function runSync(cmd, args, envOverride) {
}
}
function runPnpmSync(args, envOverride) {
runSpawnCallSync(resolvePnpmSpawnCall(args, envOverride), "pnpm");
}
function depsInstalled(kind) {
try {
const require = createRequire(path.join(uiDir, "package.json"));
@@ -179,24 +179,18 @@ export function main(argv = process.argv.slice(2)) {
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]);
runPnpm(["install", ...rest]);
return;
}
if (!depsInstalled(action === "test" ? "test" : "build")) {
const installEnv = process.env;
const installArgs = ["install"];
runSync(runner.cmd, installArgs, installEnv);
runPnpmSync(installArgs, installEnv);
}
run(runner.cmd, ["run", script, ...rest]);
runPnpm(["run", script, ...rest]);
}
export function resolveDirectExecutionPath(entry, realpath = fs.realpathSync.native) {

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
isDirectScriptExecution,
resolvePnpmSpawnCall,
resolveSpawnCall,
shouldUseCmdExeForCommand,
} from "../../scripts/ui.js";
@@ -75,6 +76,42 @@ describe("scripts/ui windows spawn behavior", () => {
).toThrow(/unsafe windows cmd\.exe argument/i);
});
it("routes Windows Corepack pnpm entrypoints through node", () => {
expect(
resolvePnpmSpawnCall(
["run", "build"],
{
npm_execpath:
"C:\\Users\\runner\\AppData\\Local\\node\\corepack\\v1\\pnpm\\11.2.2\\bin\\pnpm.mjs",
ComSpec: "C:\\Windows\\System32\\cmd.exe",
},
{
cwd: "C:\\repo\\ui",
nodeExecPath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
},
),
).toEqual({
command: "C:\\Program Files\\nodejs\\node.exe",
args: [
"C:\\Users\\runner\\AppData\\Local\\node\\corepack\\v1\\pnpm\\11.2.2\\bin\\pnpm.mjs",
"run",
"build",
],
options: {
cwd: "C:\\repo\\ui",
stdio: "inherit",
env: {
npm_execpath:
"C:\\Users\\runner\\AppData\\Local\\node\\corepack\\v1\\pnpm\\11.2.2\\bin\\pnpm.mjs",
ComSpec: "C:\\Windows\\System32\\cmd.exe",
},
shell: false,
windowsVerbatimArguments: undefined,
},
});
});
it("keeps non-Windows launches direct even with shell metacharacters", () => {
expect(
resolveSpawnCall(

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { controlUiManualChunk, normalizeModuleId } from "../../build/chunking.ts";
import { controlUiManualChunk, normalizeModuleId } from "./control-ui-chunking.ts";
describe("Control UI build chunking", () => {
it("groups stable runtime dependencies into bounded chunks", () => {

View File

@@ -3,7 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
import { controlUiManualChunk } from "./build/chunking.ts";
import { controlUiManualChunk } from "./src/ui/control-ui-chunking.ts";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");