fix: unblock windows update build

This commit is contained in:
Peter Steinberger
2026-04-08 07:17:07 +01:00
parent 3eb47e9e73
commit 4f5c137f88
11 changed files with 689 additions and 171 deletions

View File

@@ -1,39 +1,131 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const nodeBin = process.execPath;
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const steps = [
{ cmd: pnpmBin, args: ["canvas:a2ui:bundle"] },
{ cmd: nodeBin, args: ["scripts/tsdown-build.mjs"] },
{ cmd: nodeBin, args: ["scripts/runtime-postbuild.mjs"] },
{ cmd: nodeBin, args: ["scripts/build-stamp.mjs"] },
{ cmd: pnpmBin, args: ["build:plugin-sdk:dts"] },
{ cmd: nodeBin, args: ["--import", "tsx", "scripts/write-plugin-sdk-entry-dts.ts"] },
{ cmd: nodeBin, args: ["scripts/check-plugin-sdk-exports.mjs"] },
{ cmd: nodeBin, args: ["--import", "tsx", "scripts/canvas-a2ui-copy.ts"] },
{ cmd: nodeBin, args: ["--import", "tsx", "scripts/copy-hook-metadata.ts"] },
{ cmd: nodeBin, args: ["--import", "tsx", "scripts/copy-export-html-templates.ts"] },
{ cmd: nodeBin, args: ["--import", "tsx", "scripts/write-build-info.ts"] },
const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096;
export const BUILD_ALL_STEPS = [
{ label: "canvas:a2ui:bundle", kind: "pnpm", pnpmArgs: ["canvas:a2ui:bundle"] },
{ label: "tsdown", kind: "node", args: ["scripts/tsdown-build.mjs"] },
{ label: "runtime-postbuild", kind: "node", args: ["scripts/runtime-postbuild.mjs"] },
{ label: "build-stamp", kind: "node", args: ["scripts/build-stamp.mjs"] },
{
cmd: nodeBin,
label: "build:plugin-sdk:dts",
kind: "pnpm",
pnpmArgs: ["build:plugin-sdk:dts"],
windowsNodeOptions: `--max-old-space-size=${WINDOWS_BUILD_MAX_OLD_SPACE_MB}`,
},
{
label: "write-plugin-sdk-entry-dts",
kind: "node",
args: ["--import", "tsx", "scripts/write-plugin-sdk-entry-dts.ts"],
},
{
label: "check-plugin-sdk-exports",
kind: "node",
args: ["scripts/check-plugin-sdk-exports.mjs"],
},
{
label: "canvas-a2ui-copy",
kind: "node",
args: ["--import", "tsx", "scripts/canvas-a2ui-copy.ts"],
},
{
label: "copy-hook-metadata",
kind: "node",
args: ["--import", "tsx", "scripts/copy-hook-metadata.ts"],
},
{
label: "copy-export-html-templates",
kind: "node",
args: ["--import", "tsx", "scripts/copy-export-html-templates.ts"],
},
{
label: "write-build-info",
kind: "node",
args: ["--import", "tsx", "scripts/write-build-info.ts"],
},
{
label: "write-cli-startup-metadata",
kind: "node",
args: ["--experimental-strip-types", "scripts/write-cli-startup-metadata.ts"],
},
{ cmd: nodeBin, args: ["--import", "tsx", "scripts/write-cli-compat.ts"] },
{
label: "write-cli-compat",
kind: "node",
args: ["--import", "tsx", "scripts/write-cli-compat.ts"],
},
];
for (const step of steps) {
const result = spawnSync(step.cmd, step.args, {
stdio: "inherit",
env: process.env,
});
if (typeof result.status === "number") {
if (result.status !== 0) {
process.exit(result.status);
}
continue;
function resolveStepEnv(step, env, platform) {
if (platform !== "win32" || !step.windowsNodeOptions) {
return env;
}
const currentNodeOptions = env.NODE_OPTIONS?.trim() ?? "";
if (currentNodeOptions.includes(step.windowsNodeOptions)) {
return env;
}
return {
...env,
NODE_OPTIONS: currentNodeOptions
? `${currentNodeOptions} ${step.windowsNodeOptions}`
: step.windowsNodeOptions,
};
}
export function resolveBuildAllStep(step, params = {}) {
const platform = params.platform ?? process.platform;
const env = resolveStepEnv(step, params.env ?? process.env, platform);
if (step.kind === "pnpm") {
const runner = resolvePnpmRunner({
pnpmArgs: step.pnpmArgs,
nodeExecPath: params.nodeExecPath ?? nodeBin,
npmExecPath: params.npmExecPath ?? env.npm_execpath,
comSpec: params.comSpec ?? env.ComSpec,
platform,
});
return {
command: runner.command,
args: runner.args,
options: {
stdio: "inherit",
env,
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
},
};
}
return {
command: params.nodeExecPath ?? nodeBin,
args: step.args,
options: {
stdio: "inherit",
env,
},
};
}
function isMainModule() {
const argv1 = process.argv[1];
if (!argv1) {
return false;
}
return import.meta.url === pathToFileURL(argv1).href;
}
if (isMainModule()) {
for (const step of BUILD_ALL_STEPS) {
console.error(`[build-all] ${step.label}`);
const invocation = resolveBuildAllStep(step);
const result = spawnSync(invocation.command, invocation.args, invocation.options);
if (typeof result.status === "number") {
if (result.status !== 0) {
process.exit(result.status);
}
continue;
}
process.exit(1);
}
process.exit(1);
}

162
scripts/bundle-a2ui.mjs Normal file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const hashFile = path.join(rootDir, "src", "canvas-host", "a2ui", ".bundle.hash");
const outputFile = path.join(rootDir, "src", "canvas-host", "a2ui", "a2ui.bundle.js");
const a2uiRendererDir = path.join(rootDir, "vendor", "a2ui", "renderers", "lit");
const a2uiAppDir = path.join(rootDir, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI");
const inputPaths = [
path.join(rootDir, "package.json"),
path.join(rootDir, "pnpm-lock.yaml"),
a2uiRendererDir,
a2uiAppDir,
];
function fail(message) {
console.error(message);
console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle");
console.error("If this persists, verify pnpm deps and try again.");
process.exit(1);
}
async function pathExists(targetPath) {
try {
await fs.stat(targetPath);
return true;
} catch {
return false;
}
}
async function walkFiles(entryPath, files) {
const stat = await fs.stat(entryPath);
if (!stat.isDirectory()) {
files.push(entryPath);
return;
}
const entries = await fs.readdir(entryPath);
for (const entry of entries) {
await walkFiles(path.join(entryPath, entry), files);
}
}
function normalizePath(filePath) {
return filePath.split(path.sep).join("/");
}
async function computeHash() {
const files = [];
for (const inputPath of inputPaths) {
await walkFiles(inputPath, files);
}
files.sort((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
const hash = createHash("sha256");
for (const filePath of files) {
hash.update(normalizePath(path.relative(rootDir, filePath)));
hash.update("\0");
hash.update(await fs.readFile(filePath));
hash.update("\0");
}
return hash.digest("hex");
}
function runStep(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: rootDir,
stdio: "inherit",
env: process.env,
...options,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function runPnpm(pnpmArgs) {
const runner = resolvePnpmRunner({
pnpmArgs,
nodeExecPath: process.execPath,
npmExecPath: process.env.npm_execpath,
comSpec: process.env.ComSpec,
platform: process.platform,
});
runStep(runner.command, runner.args, {
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
});
}
async function main() {
const hasRendererDir = await pathExists(a2uiRendererDir);
const hasAppDir = await pathExists(a2uiAppDir);
const hasOutputFile = await pathExists(outputFile);
if (!hasRendererDir || !hasAppDir) {
if (hasOutputFile) {
console.log("A2UI sources missing; keeping prebuilt bundle.");
return;
}
if (process.env.OPENCLAW_SPARSE_PROFILE || process.env.OPENCLAW_A2UI_SKIP_MISSING === "1") {
console.error(
"A2UI sources missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.",
);
return;
}
fail(`A2UI sources missing and no prebuilt bundle found at: ${outputFile}`);
}
const currentHash = await computeHash();
if (await pathExists(hashFile)) {
const previousHash = (await fs.readFile(hashFile, "utf8")).trim();
if (previousHash === currentHash && hasOutputFile) {
console.log("A2UI bundle up to date; skipping.");
return;
}
}
runPnpm(["-s", "exec", "tsc", "-p", path.join(a2uiRendererDir, "tsconfig.json")]);
const localRolldownCliCandidates = [
path.join(rootDir, "node_modules", ".pnpm", "node_modules", "rolldown", "bin", "cli.mjs"),
path.join(
rootDir,
"node_modules",
".pnpm",
"rolldown@1.0.0-rc.9",
"node_modules",
"rolldown",
"bin",
"cli.mjs",
),
];
const localRolldownCli = (
await Promise.all(
localRolldownCliCandidates.map(async (candidate) =>
(await pathExists(candidate)) ? candidate : null,
),
)
).find(Boolean);
if (localRolldownCli) {
runStep(process.execPath, [
localRolldownCli,
"-c",
path.join(a2uiAppDir, "rolldown.config.mjs"),
]);
} else {
runPnpm(["-s", "dlx", "rolldown", "-c", path.join(a2uiAppDir, "rolldown.config.mjs")]);
}
await fs.writeFile(hashFile, `${currentHash}\n`, "utf8");
}
await main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});

View File

@@ -1,104 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
on_error() {
echo "A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle" >&2
echo "If this persists, verify pnpm deps and try again." >&2
}
trap on_error ERR
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
A2UI_RENDERER_DIR="$ROOT_DIR/vendor/a2ui/renderers/lit"
A2UI_APP_DIR="$ROOT_DIR/apps/shared/OpenClawKit/Tools/CanvasA2UI"
# Docker builds exclude vendor/apps via .dockerignore.
# Sparse local builds can also omit these inputs intentionally.
if [[ ! -d "$A2UI_RENDERER_DIR" || ! -d "$A2UI_APP_DIR" ]]; then
if [[ -f "$OUTPUT_FILE" ]]; then
echo "A2UI sources missing; keeping prebuilt bundle."
exit 0
fi
if [[ -n "${OPENCLAW_SPARSE_PROFILE:-}" || "${OPENCLAW_A2UI_SKIP_MISSING:-}" == "1" ]]; then
echo "A2UI sources missing; skipping bundle because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set." >&2
exit 0
fi
echo "A2UI sources missing and no prebuilt bundle found at: $OUTPUT_FILE" >&2
exit 1
fi
INPUT_PATHS=(
"$ROOT_DIR/package.json"
"$ROOT_DIR/pnpm-lock.yaml"
"$A2UI_RENDERER_DIR"
"$A2UI_APP_DIR"
)
compute_hash() {
ROOT_DIR="$ROOT_DIR" node --input-type=module --eval '
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
const rootDir = process.env.ROOT_DIR ?? process.cwd();
const inputs = process.argv.slice(1);
const files = [];
async function walk(entryPath) {
const st = await fs.stat(entryPath);
if (st.isDirectory()) {
const entries = await fs.readdir(entryPath);
for (const entry of entries) {
await walk(path.join(entryPath, entry));
}
return;
}
files.push(entryPath);
}
for (const input of inputs) {
await walk(input);
}
function normalize(p) {
return p.split(path.sep).join("/");
}
files.sort((a, b) => normalize(a).localeCompare(normalize(b)));
const hash = createHash("sha256");
for (const filePath of files) {
const rel = normalize(path.relative(rootDir, filePath));
hash.update(rel);
hash.update("\0");
hash.update(await fs.readFile(filePath));
hash.update("\0");
}
process.stdout.write(hash.digest("hex"));
' "${INPUT_PATHS[@]}"
}
current_hash="$(compute_hash)"
if [[ -f "$HASH_FILE" ]]; then
previous_hash="$(cat "$HASH_FILE")"
if [[ "$previous_hash" == "$current_hash" && -f "$OUTPUT_FILE" ]]; then
echo "A2UI bundle up to date; skipping."
exit 0
fi
fi
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" \
-c "$A2UI_APP_DIR/rolldown.config.mjs"
else
pnpm -s dlx rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
fi
echo "$current_hash" > "$HASH_FILE"
exec node "$ROOT_DIR/scripts/bundle-a2ui.mjs" "$@"

View File

@@ -12,11 +12,33 @@ function relativeSymlinkTarget(sourcePath, targetPath) {
return relativeTarget || ".";
}
function ensureSymlink(targetValue, targetPath, type) {
function shouldFallbackToCopy(error) {
return (
process.platform === "win32" &&
(error?.code === "EPERM" || error?.code === "EINVAL" || error?.code === "UNKNOWN")
);
}
function copyPathFallback(sourcePath, targetPath) {
removePathIfExists(targetPath);
const stat = fs.statSync(sourcePath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
if (stat.isDirectory()) {
fs.cpSync(sourcePath, targetPath, { recursive: true, dereference: true });
return;
}
fs.copyFileSync(sourcePath, targetPath);
}
function ensureSymlink(targetValue, targetPath, type, fallbackSourcePath) {
try {
fs.symlinkSync(targetValue, targetPath, type);
return;
} catch (error) {
if (fallbackSourcePath && shouldFallbackToCopy(error)) {
copyPathFallback(fallbackSourcePath, targetPath);
return;
}
if (error?.code !== "EEXIST") {
throw error;
}
@@ -31,11 +53,19 @@ function ensureSymlink(targetValue, targetPath, type) {
}
removePathIfExists(targetPath);
fs.symlinkSync(targetValue, targetPath, type);
try {
fs.symlinkSync(targetValue, targetPath, type);
} catch (error) {
if (fallbackSourcePath && shouldFallbackToCopy(error)) {
copyPathFallback(fallbackSourcePath, targetPath);
return;
}
throw error;
}
}
function symlinkPath(sourcePath, targetPath, type) {
ensureSymlink(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type);
ensureSymlink(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type, sourcePath);
}
function shouldWrapRuntimeJsFile(sourcePath) {
@@ -85,7 +115,7 @@ function stagePluginRuntimeOverlay(sourceDir, targetDir) {
}
if (dirent.isSymbolicLink()) {
ensureSymlink(fs.readlinkSync(sourcePath), targetPath);
ensureSymlink(fs.readlinkSync(sourcePath), targetPath, undefined, sourcePath);
continue;
}
@@ -113,7 +143,12 @@ function linkPluginNodeModules(params) {
if (!fs.existsSync(params.sourcePluginNodeModulesDir)) {
return;
}
ensureSymlink(params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, symlinkType());
ensureSymlink(
params.sourcePluginNodeModulesDir,
runtimeNodeModulesDir,
symlinkType(),
params.sourcePluginNodeModulesDir,
);
}
export function stageBundledPluginRuntime(params = {}) {

View File

@@ -3,7 +3,9 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn";
const extraArgs = process.argv.slice(2);
@@ -61,42 +63,67 @@ function findFatalUnresolvedImport(lines) {
return null;
}
const result = spawnSync(
"pnpm",
["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel, ...extraArgs],
{
encoding: "utf8",
stdio: "pipe",
shell: process.platform === "win32",
},
);
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
if (stdout) {
process.stdout.write(stdout);
}
if (stderr) {
process.stderr.write(stderr);
export function resolveTsdownBuildInvocation(params = {}) {
const env = params.env ?? process.env;
const runner = resolvePnpmRunner({
pnpmArgs: ["exec", "tsdown", "--config-loader", "unrun", "--logLevel", logLevel, ...extraArgs],
nodeExecPath: params.nodeExecPath ?? process.execPath,
npmExecPath: params.npmExecPath ?? env.npm_execpath,
comSpec: params.comSpec ?? env.ComSpec,
platform: params.platform ?? process.platform,
});
return {
command: runner.command,
args: runner.args,
options: {
encoding: "utf8",
stdio: "pipe",
shell: runner.shell,
windowsVerbatimArguments: runner.windowsVerbatimArguments,
env,
},
};
}
if (result.status === 0 && INEFFECTIVE_DYNAMIC_IMPORT_RE.test(`${stdout}\n${stderr}`)) {
console.error(
"Build emitted [INEFFECTIVE_DYNAMIC_IMPORT]. Replace transparent runtime re-export facades with real runtime boundaries.",
);
function isMainModule() {
const argv1 = process.argv[1];
if (!argv1) {
return false;
}
return import.meta.url === pathToFileURL(argv1).href;
}
if (isMainModule()) {
const invocation = resolveTsdownBuildInvocation();
const result = spawnSync(invocation.command, invocation.args, invocation.options);
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
if (stdout) {
process.stdout.write(stdout);
}
if (stderr) {
process.stderr.write(stderr);
}
if (result.status === 0 && INEFFECTIVE_DYNAMIC_IMPORT_RE.test(`${stdout}\n${stderr}`)) {
console.error(
"Build emitted [INEFFECTIVE_DYNAMIC_IMPORT]. Replace transparent runtime re-export facades with real runtime boundaries.",
);
process.exit(1);
}
const fatalUnresolvedImport =
result.status === 0 ? findFatalUnresolvedImport(`${stdout}\n${stderr}`.split("\n")) : null;
if (fatalUnresolvedImport) {
console.error(`Build emitted [UNRESOLVED_IMPORT] outside extensions: ${fatalUnresolvedImport}`);
process.exit(1);
}
if (typeof result.status === "number") {
process.exit(result.status);
}
process.exit(1);
}
const fatalUnresolvedImport =
result.status === 0 ? findFatalUnresolvedImport(`${stdout}\n${stderr}`.split("\n")) : null;
if (fatalUnresolvedImport) {
console.error(`Build emitted [UNRESOLVED_IMPORT] outside extensions: ${fatalUnresolvedImport}`);
process.exit(1);
}
if (typeof result.status === "number") {
process.exit(result.status);
}
process.exit(1);