diff --git a/package.json b/package.json index f180a6877f6..95719b39968 100644 --- a/package.json +++ b/package.json @@ -1074,7 +1074,7 @@ "canon:check": "node scripts/canon.mjs check", "canon:check:json": "node scripts/canon.mjs check --json", "canon:enforce": "node scripts/canon.mjs enforce --json", - "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", + "canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs", "check": "pnpm check:no-conflict-markers && pnpm tool-display:check && pnpm check:host-env-policy:swift && pnpm tsgo && node scripts/prepare-extension-package-boundary-artifacts.mjs && pnpm lint && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check", "check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check", diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index f497d2bfb23..34ce0b76db7 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -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); } diff --git a/scripts/bundle-a2ui.mjs b/scripts/bundle-a2ui.mjs new file mode 100644 index 00000000000..2537e2e27aa --- /dev/null +++ b/scripts/bundle-a2ui.mjs @@ -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)); +}); diff --git a/scripts/bundle-a2ui.sh b/scripts/bundle-a2ui.sh index 0f313914ffd..333c963715f 100755 --- a/scripts/bundle-a2ui.sh +++ b/scripts/bundle-a2ui.sh @@ -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" "$@" diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index 303ce164714..ee79d58eb8c 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -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 = {}) { diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 28bd5d9f242..49bb77055d3 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -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); diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 4d4c68a4631..c03bcab0ec8 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -791,6 +791,102 @@ describe("runGatewayUpdate", () => { } }); + it("adds heap headroom to windows pnpm build steps during dev updates", async () => { + await setupGitPackageManagerFixture(); + const upstreamSha = "upstream123"; + const buildNodeOptions: string[] = []; + const doctorNodePath = await resolveStableNodePath(process.execPath); + const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`; + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + + try { + const runCommand = async ( + argv: string[], + options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number }, + ) => { + const key = argv.join(" "); + + if (key === `git -C ${tempDir} rev-parse --show-toplevel`) { + return { stdout: tempDir, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse HEAD`) { + return { stdout: "abc123", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) { + return { stdout: "main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) { + return { stdout: "origin/main", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} fetch --all --prune --tags`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-parse @{upstream}`) { + return { stdout: upstreamSha, stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) { + return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 }; + } + if (key === "pnpm --version") { + return { stdout: "10.0.0", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) && + key.endsWith(` ${upstreamSha}`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 }; + } + if ( + key.startsWith("git -C /tmp/") && + preflightPrefixPattern.test(key) && + key.includes(" checkout --detach ") && + key.endsWith(upstreamSha) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key === "pnpm install --ignore-scripts" || + key === "pnpm lint" || + key === "pnpm ui:build" + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === "pnpm build") { + buildNodeOptions.push(options?.env?.NODE_OPTIONS ?? ""); + return { stdout: "", stderr: "", code: 0 }; + } + if ( + key.startsWith(`git -C ${tempDir} worktree remove --force /tmp/`) && + preflightPrefixPattern.test(key) + ) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} worktree prune`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === `git -C ${tempDir} rebase ${upstreamSha}`) { + return { stdout: "", stderr: "", code: 0 }; + } + if (key === doctorCommand) { + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runWithCommand(runCommand, { channel: "dev" }); + + expect(result.status).toBe("ok"); + expect(buildNodeOptions).toHaveLength(2); + expect(buildNodeOptions).toEqual(["--max-old-space-size=4096", "--max-old-space-size=4096"]); + } finally { + platformSpy.mockRestore(); + } + }); + it("does not fall back to npm scripts when a pnpm repo cannot bootstrap pnpm", async () => { await setupGitPackageManagerFixture(); const calls: string[] = []; diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 2de5e8265a0..822fa9b9f0f 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -108,6 +108,7 @@ const PREFLIGHT_TEMP_PREFIX = process.platform === "win32" ? "ocu-pf-" : "openclaw-update-preflight-"; const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktree"; const WINDOWS_PREFLIGHT_BASE_DIR = "ocu"; +const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096; function normalizeDir(value?: string | null) { if (!value) { @@ -362,6 +363,35 @@ function shouldPreferIgnoreScriptsForWindowsPreflight(manager: "pnpm" | "bun" | return process.platform === "win32" && manager === "pnpm"; } +function resolveWindowsBuildNodeOptions(baseOptions: string | undefined): string { + const current = baseOptions?.trim() ?? ""; + const desired = `--max-old-space-size=${WINDOWS_BUILD_MAX_OLD_SPACE_MB}`; + const existingMatch = /(?:^|\s)--max-old-space-size=(\d+)(?=\s|$)/.exec(current); + if (!existingMatch) { + return current ? `${current} ${desired}` : desired; + } + const existingValue = Number(existingMatch[1]); + if (Number.isFinite(existingValue) && existingValue >= WINDOWS_BUILD_MAX_OLD_SPACE_MB) { + return current; + } + return current.replace(/(?:^|\s)--max-old-space-size=\d+(?=\s|$)/, ` ${desired}`).trim(); +} + +function resolveWindowsBuildEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv | undefined { + if (process.platform !== "win32") { + return env; + } + const currentNodeOptions = env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS; + const nextNodeOptions = resolveWindowsBuildNodeOptions(currentNodeOptions); + if (nextNodeOptions === currentNodeOptions) { + return env; + } + return { + ...env, + NODE_OPTIONS: nextNodeOptions, + }; +} + function isSupersededInstallFailure( step: UpdateStepResult, steps: readonly UpdateStepResult[], @@ -720,7 +750,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< `preflight build (${shortSha})`, managerScriptArgs(manager.manager, "build"), worktreeDir, - manager.env, + resolveWindowsBuildEnv(manager.env), ), ); steps.push(buildStep); @@ -910,7 +940,12 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< } const buildStep = await runStep( - step("build", managerScriptArgs(manager.manager, "build"), gitRoot, manager.env), + step( + "build", + managerScriptArgs(manager.manager, "build"), + gitRoot, + resolveWindowsBuildEnv(manager.env), + ), ); steps.push(buildStep); if (buildStep.exitCode !== 0) { diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts new file mode 100644 index 00000000000..3b94991f15f --- /dev/null +++ b/test/scripts/build-all.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { BUILD_ALL_STEPS, resolveBuildAllStep } from "../../scripts/build-all.mjs"; + +describe("resolveBuildAllStep", () => { + it("routes pnpm steps through the npm_execpath pnpm runner on Windows", () => { + const step = BUILD_ALL_STEPS.find((entry) => entry.label === "canvas:a2ui:bundle"); + expect(step).toBeTruthy(); + + const result = resolveBuildAllStep(step, { + platform: "win32", + nodeExecPath: "C:\\Program Files\\nodejs\\node.exe", + npmExecPath: "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", + env: {}, + }); + + expect(result).toEqual({ + command: "C:\\Program Files\\nodejs\\node.exe", + args: ["C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "canvas:a2ui:bundle"], + options: { + stdio: "inherit", + env: {}, + shell: false, + windowsVerbatimArguments: undefined, + }, + }); + }); + + it("keeps node steps on the current node binary", () => { + const step = BUILD_ALL_STEPS.find((entry) => entry.label === "runtime-postbuild"); + expect(step).toBeTruthy(); + + const result = resolveBuildAllStep(step, { + nodeExecPath: "/custom/node", + env: { FOO: "bar" }, + }); + + expect(result).toEqual({ + command: "/custom/node", + args: ["scripts/runtime-postbuild.mjs"], + options: { + stdio: "inherit", + env: { FOO: "bar" }, + }, + }); + }); + + it("adds heap headroom for plugin-sdk dts on Windows", () => { + const step = BUILD_ALL_STEPS.find((entry) => entry.label === "build:plugin-sdk:dts"); + expect(step).toBeTruthy(); + + const result = resolveBuildAllStep(step, { + platform: "win32", + nodeExecPath: "C:\\Program Files\\nodejs\\node.exe", + npmExecPath: "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", + env: { FOO: "bar" }, + }); + + expect(result).toEqual({ + command: "C:\\Program Files\\nodejs\\node.exe", + args: ["C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "build:plugin-sdk:dts"], + options: { + stdio: "inherit", + env: { + FOO: "bar", + NODE_OPTIONS: "--max-old-space-size=4096", + }, + shell: false, + windowsVerbatimArguments: undefined, + }, + }); + }); +}); diff --git a/test/scripts/stage-bundled-plugin-runtime.test.ts b/test/scripts/stage-bundled-plugin-runtime.test.ts new file mode 100644 index 00000000000..10275746b46 --- /dev/null +++ b/test/scripts/stage-bundled-plugin-runtime.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs"; + +async function withTempDir(run: (dir: string) => Promise) { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-stage-runtime-")); + try { + await run(dir); + } finally { + await fs.promises.rm(dir, { recursive: true, force: true }); + } +} + +describe("stageBundledPluginRuntime", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("copies files when Windows rejects runtime overlay symlinks", async () => { + await withTempDir(async (repoRoot) => { + const sourceFile = path.join( + repoRoot, + "dist", + "extensions", + "acpx", + "skills", + "acp-router", + "SKILL.md", + ); + await fs.promises.mkdir(path.dirname(sourceFile), { recursive: true }); + await fs.promises.writeFile(sourceFile, "skill-body\n", "utf8"); + + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const symlinkSpy = vi + .spyOn(fs, "symlinkSync") + .mockImplementation((target, targetPath, type) => { + if ( + String(targetPath).includes(`${path.sep}dist-runtime${path.sep}`) && + type !== "junction" + ) { + const error = new Error("no symlink privilege"); + Object.assign(error, { code: "EPERM" }); + throw error; + } + return undefined; + }); + + stageBundledPluginRuntime({ repoRoot }); + + const runtimeFile = path.join( + repoRoot, + "dist-runtime", + "extensions", + "acpx", + "skills", + "acp-router", + "SKILL.md", + ); + expect(await fs.promises.readFile(runtimeFile, "utf8")).toBe("skill-body\n"); + expect(fs.lstatSync(runtimeFile).isSymbolicLink()).toBe(false); + expect(symlinkSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts new file mode 100644 index 00000000000..9788327ea3b --- /dev/null +++ b/test/scripts/tsdown-build.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { resolveTsdownBuildInvocation } from "../../scripts/tsdown-build.mjs"; + +describe("resolveTsdownBuildInvocation", () => { + it("routes Windows tsdown builds through the pnpm runner instead of shell=true", () => { + const result = resolveTsdownBuildInvocation({ + platform: "win32", + nodeExecPath: "C:\\Program Files\\nodejs\\node.exe", + npmExecPath: "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", + env: {}, + }); + + expect(result).toEqual({ + command: "C:\\Program Files\\nodejs\\node.exe", + args: [ + "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", + "exec", + "tsdown", + "--config-loader", + "unrun", + "--logLevel", + "warn", + ], + options: { + encoding: "utf8", + stdio: "pipe", + shell: false, + windowsVerbatimArguments: undefined, + env: {}, + }, + }); + }); +});