Files
openclaw/scripts/ui.js
2026-06-21 11:15:14 +02:00

362 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
// Routes UI package commands through the repo's Node/pnpm wrappers.
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 { resolvePnpmRunner } from "./pnpm-runner.mjs";
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } 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");
}
/**
* Returns whether Windows needs cmd.exe for a command shim.
*/
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);
}
/**
* Builds the spawn call for a UI command, including Windows cmd.exe wrapping.
*/
export function resolveSpawnCall(cmd, args, envOverride, params = {}) {
const platform = params.platform ?? process.platform;
const options = {
cwd: params.cwd ?? uiDir,
stdio: "inherit",
env: envOverride ?? process.env,
shell: false,
};
if (shouldUseCmdExeForCommand(cmd, platform)) {
const comSpec = params.comSpec ?? resolveWindowsCmdExePath(options.env);
return {
command: comSpec,
args: ["/d", "/s", "/c", buildCmdExeCommandLine(cmd, args)],
options: {
...options,
windowsVerbatimArguments: true,
},
};
}
return {
command: cmd,
args,
options,
};
}
/**
* Builds the pnpm-backed spawn call for UI package scripts.
*/
export function resolvePnpmSpawnCall(pnpmArgs, envOverride, params = {}) {
const env = envOverride ?? process.env;
const platform = params.platform ?? process.platform;
const cwd = params.cwd ?? uiDir;
const runner = resolvePnpmRunner({
cwd,
env,
pnpmArgs,
nodeExecPath: params.nodeExecPath ?? process.execPath,
npmExecPath: params.npmExecPath ?? env.npm_execpath,
comSpec: params.comSpec,
platform,
});
return {
command: runner.command,
args: runner.args,
options: {
cwd,
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 ${label}:`, err);
process.exit(1);
return;
}
let forwardedSignal = null;
let forwardedSignalPids = [];
let forceKillTimer = null;
let forwardedSignalDrainTimer = null;
const clearForwardedSignalTimers = () => {
if (forceKillTimer) {
clearTimeout(forceKillTimer);
forceKillTimer = null;
}
if (forwardedSignalDrainTimer) {
clearInterval(forwardedSignalDrainTimer);
forwardedSignalDrainTimer = null;
}
};
const finishForwardedSignal = () => {
cleanupSignalHandlers();
process.kill(process.pid, forwardedSignal);
};
const waitForForwardedSignalChildren = () => {
if (!forwardedSignal || processTreeIsAlive(forwardedSignalPids)) {
return;
}
finishForwardedSignal();
};
// Keep UI dev children in the foreground process group for native TTY
// resize/job-control behavior. Forward wrapper shutdown signals to the
// captured child tree instead of using a detached process group.
const forwardedSignals = ["SIGTERM", "SIGHUP"];
const signalHandlers = new Map(
forwardedSignals.map((signal) => [
signal,
() => {
if (!forwardedSignal) {
forwardedSignal = signal;
forwardedSignalPids = collectChildProcessTreePids(child);
signalProcessTree(child, signal, forwardedSignalPids);
forwardedSignalDrainTimer = setInterval(waitForForwardedSignalChildren, 25);
forceKillTimer = setTimeout(() => {
signalProcessTree(child, "SIGKILL", forwardedSignalPids);
}, 5_000);
forceKillTimer.unref?.();
}
},
]),
);
const cleanupSignalHandlers = () => {
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
clearForwardedSignalTimers();
};
for (const [signal, handler] of signalHandlers) {
process.on(signal, handler);
}
child.on("error", (err) => {
cleanupSignalHandlers();
console.error(`Failed to launch ${label}:`, err);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (forwardedSignal) {
waitForForwardedSignalChildren();
return;
}
cleanupSignalHandlers();
if (signal) {
process.kill(process.pid, signal);
return;
}
if (code !== 0) {
process.exit(code ?? 1);
}
});
}
function collectChildProcessTreePids(child) {
if (process.platform === "win32" || typeof child.pid !== "number") {
return typeof child.pid === "number" ? [child.pid] : [];
}
const ps = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
if (ps.status !== 0) {
return [child.pid];
}
const childrenByParent = new Map();
for (const line of ps.stdout.split("\n")) {
const match = line.trim().match(/^(\d+)\s+(\d+)$/u);
if (!match) {
continue;
}
const pid = Number(match[1]);
const ppid = Number(match[2]);
const siblings = childrenByParent.get(ppid) ?? [];
siblings.push(pid);
childrenByParent.set(ppid, siblings);
}
const pids = [child.pid];
for (const parentPid of pids) {
for (const pid of childrenByParent.get(parentPid) ?? []) {
pids.push(pid);
}
}
return [...new Set(pids)];
}
function processTreeIsAlive(pids) {
return pids.some((pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
});
}
function signalProcessTree(child, signal, pids) {
if (process.platform === "win32") {
child.kill(signal);
return;
}
if (pids.length === 0) {
child.kill(signal);
return;
}
for (const pid of pids.toReversed()) {
try {
process.kill(pid, signal);
} catch (error) {
if (error?.code !== "ESRCH") {
throw error;
}
}
}
}
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 ${label}:`, err);
process.exit(1);
return;
}
if (result.signal) {
process.kill(process.pid, result.signal);
return;
}
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
}
function runPnpmSync(args, envOverride) {
runSpawnCallSync(resolvePnpmSpawnCall(args, envOverride), "pnpm");
}
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;
}
if (action === "install") {
runPnpm(["install", ...rest]);
return;
}
if (!depsInstalled(action === "test" ? "test" : "build")) {
const installEnv = process.env;
const installArgs = ["install"];
runPnpmSync(installArgs, installEnv);
}
runPnpm(["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();
}