#!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import { resolveGitHead, writeBuildStamp as writeDistBuildStamp } from "./build-stamp.mjs"; import { BUNDLED_PLUGIN_PATH_PREFIX, BUNDLED_PLUGIN_ROOT_DIR, } from "./lib/bundled-plugin-paths.mjs"; import { runRuntimePostBuild } from "./runtime-postbuild.mjs"; const buildScript = "scripts/tsdown-build.mjs"; const compilerArgs = [buildScript, "--no-clean"]; const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR]; const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; const ignoredRunNodeRepoPaths = new Set([ "src/canvas-host/a2ui/.bundle.hash", "src/canvas-host/a2ui/a2ui.bundle.js", ]); const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]); const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/"); const isIgnoredSourcePath = (relativePath) => { const normalizedPath = normalizePath(relativePath); return ( normalizedPath.endsWith(".test.ts") || normalizedPath.endsWith(".test.tsx") || normalizedPath.endsWith("test-helpers.ts") ); }; const isBuildRelevantSourcePath = (relativePath) => { const normalizedPath = normalizePath(relativePath); return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); }; const isRestartRelevantExtensionPath = (relativePath) => { const normalizedPath = normalizePath(relativePath); if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { return true; } return isBuildRelevantSourcePath(normalizedPath); }; const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => { const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); if (ignoredRunNodeRepoPaths.has(normalizedPath)) { return false; } if (runNodeConfigFiles.includes(normalizedPath)) { return true; } if (normalizedPath.startsWith("src/")) { return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); } if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length)); } return false; }; export const isBuildRelevantRunNodePath = (repoPath) => isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath); export const isRestartRelevantRunNodePath = (repoPath) => isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath); const statMtime = (filePath, fsImpl = fs) => { try { return fsImpl.statSync(filePath).mtimeMs; } catch { return null; } }; const resolvePrivateQaRequiredDistEntries = (distRoot) => [ path.join(distRoot, "plugin-sdk", "qa-lab.js"), path.join(distRoot, "plugin-sdk", "qa-runtime.js"), ]; const isExcludedSource = (filePath, sourceRoot, sourceRootName) => { const relativePath = normalizePath(path.relative(sourceRoot, filePath)); if (relativePath.startsWith("..")) { return false; } return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath)); }; const findLatestMtime = (dirPath, shouldSkip, deps) => { let latest = null; const queue = [dirPath]; while (queue.length > 0) { const current = queue.pop(); if (!current) { continue; } let entries = []; try { entries = deps.fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const fullPath = path.join(current, entry.name); if (entry.isDirectory()) { queue.push(fullPath); continue; } if (!entry.isFile()) { continue; } if (shouldSkip?.(fullPath)) { continue; } const mtime = statMtime(fullPath, deps.fs); if (mtime == null) { continue; } if (latest == null || mtime > latest) { latest = mtime; } } } return latest; }; const readGitStatus = (deps) => { try { const result = deps.spawnSync( "git", ["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths], { cwd: deps.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }, ); if (result.status !== 0) { return null; } return result.stdout ?? ""; } catch { return null; } }; const parseGitStatusPaths = (output) => output .split("\n") .flatMap((line) => line.slice(3).split(" -> ")) .map((entry) => normalizePath(entry.trim())) .filter(Boolean); const hasDirtySourceTree = (deps) => { const output = readGitStatus(deps); if (output === null) { return null; } return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath)); }; const readBuildStamp = (deps) => { const mtime = statMtime(deps.buildStampPath, deps.fs); if (mtime == null) { return { mtime: null, head: null }; } try { const raw = deps.fs.readFileSync(deps.buildStampPath, "utf8").trim(); if (!raw.startsWith("{")) { return { mtime, head: null }; } const parsed = JSON.parse(raw); const head = typeof parsed?.head === "string" && parsed.head.trim() ? parsed.head.trim() : null; return { mtime, head }; } catch { return { mtime, head: null }; } }; const hasSourceMtimeChanged = (stampMtime, deps) => { let latestSourceMtime = null; for (const sourceRoot of deps.sourceRoots) { const sourceMtime = findLatestMtime( sourceRoot.path, (candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name), deps, ); if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) { latestSourceMtime = sourceMtime; } } return latestSourceMtime != null && latestSourceMtime > stampMtime; }; export const resolveBuildRequirement = (deps) => { if (deps.env.OPENCLAW_FORCE_BUILD === "1") { return { shouldBuild: true, reason: "force_build" }; } if ( deps.env.OPENCLAW_BUILD_PRIVATE_QA === "1" && (deps.privateQaRequiredDistEntries ?? resolvePrivateQaRequiredDistEntries(deps.distRoot)).some( (entry) => statMtime(entry, deps.fs) == null, ) ) { return { shouldBuild: true, reason: "missing_private_qa_dist" }; } const stamp = readBuildStamp(deps); if (stamp.mtime == null) { return { shouldBuild: true, reason: "missing_build_stamp" }; } if (statMtime(deps.distEntry, deps.fs) == null) { return { shouldBuild: true, reason: "missing_dist_entry" }; } for (const filePath of deps.configFiles) { const mtime = statMtime(filePath, deps.fs); if (mtime != null && mtime > stamp.mtime) { return { shouldBuild: true, reason: "config_newer" }; } } const currentHead = resolveGitHead(deps); if (currentHead && !stamp.head) { return { shouldBuild: true, reason: "build_stamp_missing_head" }; } if (currentHead && stamp.head && currentHead !== stamp.head) { return { shouldBuild: true, reason: "git_head_changed" }; } if (currentHead) { const dirty = hasDirtySourceTree(deps); if (dirty === true) { return { shouldBuild: true, reason: "dirty_watched_tree" }; } if (dirty === false) { return { shouldBuild: false, reason: "clean" }; } } if (hasSourceMtimeChanged(stamp.mtime, deps)) { return { shouldBuild: true, reason: "source_mtime_newer" }; } return { shouldBuild: false, reason: "clean" }; }; const BUILD_REASON_LABELS = { force_build: "forced by OPENCLAW_FORCE_BUILD", missing_build_stamp: "build stamp missing", missing_dist_entry: "dist entry missing", config_newer: "config newer than build stamp", build_stamp_missing_head: "build stamp missing git head", git_head_changed: "git head changed", dirty_watched_tree: "dirty watched source tree", source_mtime_newer: "source mtime newer than build stamp", missing_private_qa_dist: "private QA dist entry missing", clean: "clean", }; const formatBuildReason = (reason) => BUILD_REASON_LABELS[reason] ?? reason; const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, }; const isSignalKey = (signal) => Object.hasOwn(SIGNAL_EXIT_CODES, signal); const getSignalExitCode = (signal) => (isSignalKey(signal) ? SIGNAL_EXIT_CODES[signal] : 1); const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG"; const resolveRunNodeOutputLogPath = (deps) => { const outputLog = deps.env[RUN_NODE_OUTPUT_LOG_ENV]?.trim(); if (!outputLog) { return null; } return path.resolve(deps.cwd, outputLog); }; const createRunNodeOutputTee = (deps) => { const outputLogPath = resolveRunNodeOutputLogPath(deps); if (!outputLogPath) { return null; } deps.fs.mkdirSync(path.dirname(outputLogPath), { recursive: true }); const stream = deps.fs.createWriteStream(outputLogPath, { flags: "a", mode: 0o600, }); let streamError = null; stream.on("error", (error) => { streamError = error; }); deps.env[RUN_NODE_OUTPUT_LOG_ENV] = outputLogPath; return { outputLogPath, write(chunk) { if (!streamError) { stream.write(chunk); } }, async close() { if (streamError) { throw streamError; } await new Promise((resolve, reject) => { stream.once("error", reject); stream.end(resolve); }); if (streamError) { throw streamError; } }, }; }; const logRunner = (message, deps) => { if (deps.env.OPENCLAW_RUNNER_LOG === "0") { return; } const line = `[openclaw] ${message}\n`; deps.stderr.write(line); deps.outputTee?.write(line); }; const waitForSpawnedProcess = async (childProcess, deps) => { let forwardedSignal = null; let onSigInt; let onSigTerm; const cleanupSignals = () => { if (onSigInt) { deps.process.off("SIGINT", onSigInt); } if (onSigTerm) { deps.process.off("SIGTERM", onSigTerm); } }; const forwardSignal = (signal) => { if (forwardedSignal) { return; } forwardedSignal = signal; try { childProcess.kill?.(signal); } catch { // Best-effort only. Exit handling still happens via the child "exit" event. } }; onSigInt = () => { forwardSignal("SIGINT"); }; onSigTerm = () => { forwardSignal("SIGTERM"); }; deps.process.on("SIGINT", onSigInt); deps.process.on("SIGTERM", onSigTerm); try { return await new Promise((resolve) => { childProcess.on("exit", (exitCode, exitSignal) => { resolve({ exitCode, exitSignal, forwardedSignal }); }); }); } finally { cleanupSignals(); } }; const getInterruptedSpawnExitCode = (res) => { if (res.exitSignal) { return getSignalExitCode(res.exitSignal); } if (res.forwardedSignal) { return getSignalExitCode(res.forwardedSignal); } return null; }; const runOpenClaw = async (deps) => { const nodeProcess = deps.spawn(deps.execPath, ["openclaw.mjs", ...deps.args], { cwd: deps.cwd, env: deps.env, stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit", }); pipeSpawnedOutput(nodeProcess, deps); const res = await waitForSpawnedProcess(nodeProcess, deps); const interruptedExitCode = getInterruptedSpawnExitCode(res); if (interruptedExitCode !== null) { return interruptedExitCode; } return res.exitCode ?? 1; }; const pipeSpawnedOutput = (childProcess, deps) => { if (!deps.outputTee) { return; } childProcess.stdout?.on("data", (chunk) => { deps.stdout.write(chunk); deps.outputTee.write(chunk); }); childProcess.stderr?.on("data", (chunk) => { deps.stderr.write(chunk); deps.outputTee.write(chunk); }); }; const closeRunNodeOutputTee = async (deps, exitCode) => { if (!deps.outputTee) { return exitCode; } try { await deps.outputTee.close(); } catch (error) { deps.stderr.write( `[openclaw] Failed to write output log: ${error?.message ?? "unknown error"}\n`, ); return exitCode === 0 ? 1 : exitCode; } return exitCode; }; const syncRuntimeArtifacts = (deps) => { try { deps.runRuntimePostBuild({ cwd: deps.cwd }); } catch (error) { logRunner( `Failed to write runtime build artifacts: ${error?.message ?? "unknown error"}`, deps, ); return false; } return true; }; const writeBuildStamp = (deps) => { try { writeDistBuildStamp({ cwd: deps.cwd, fs: deps.fs, spawnSync: deps.spawnSync, }); } catch (error) { // Best-effort stamp; still allow the runner to start. logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`, deps); } }; const shouldSkipCleanWatchRuntimeSync = (deps) => deps.env.OPENCLAW_WATCH_MODE === "1"; export async function runNodeMain(params = {}) { const deps = { spawn: params.spawn ?? spawn, spawnSync: params.spawnSync ?? spawnSync, fs: params.fs ?? fs, stderr: params.stderr ?? process.stderr, stdout: params.stdout ?? process.stdout, process: params.process ?? process, execPath: params.execPath ?? process.execPath, cwd: params.cwd ?? process.cwd(), args: params.args ?? process.argv.slice(2), env: params.env ? { ...params.env } : { ...process.env }, runRuntimePostBuild: params.runRuntimePostBuild ?? runRuntimePostBuild, }; deps.distRoot = path.join(deps.cwd, "dist"); deps.distEntry = path.join(deps.distRoot, "/entry.js"); deps.buildStampPath = path.join(deps.distRoot, ".buildstamp"); deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({ name: sourceRoot, path: path.join(deps.cwd, sourceRoot), })); deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); deps.privateQaRequiredDistEntries = resolvePrivateQaRequiredDistEntries(deps.distRoot); if (deps.args[0] === "qa") { deps.env.OPENCLAW_BUILD_PRIVATE_QA = "1"; deps.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; } deps.outputTee = createRunNodeOutputTee(deps); try { let exitCode = 1; const buildRequirement = resolveBuildRequirement(deps); if (!buildRequirement.shouldBuild) { if (!shouldSkipCleanWatchRuntimeSync(deps) && !syncRuntimeArtifacts(deps)) { return await closeRunNodeOutputTee(deps, 1); } exitCode = await runOpenClaw(deps); return await closeRunNodeOutputTee(deps, exitCode); } logRunner( `Building TypeScript (dist is stale: ${buildRequirement.reason} - ${formatBuildReason(buildRequirement.reason)}).`, deps, ); const buildCmd = deps.execPath; const buildArgs = compilerArgs; const build = deps.spawn(buildCmd, buildArgs, { cwd: deps.cwd, env: deps.env, stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit", }); pipeSpawnedOutput(build, deps); const buildRes = await waitForSpawnedProcess(build, deps); const interruptedExitCode = getInterruptedSpawnExitCode(buildRes); if (interruptedExitCode !== null) { return await closeRunNodeOutputTee(deps, interruptedExitCode); } if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) { return await closeRunNodeOutputTee(deps, buildRes.exitCode); } if (!syncRuntimeArtifacts(deps)) { return await closeRunNodeOutputTee(deps, 1); } writeBuildStamp(deps); exitCode = await runOpenClaw(deps); return await closeRunNodeOutputTee(deps, exitCode); } catch (error) { await closeRunNodeOutputTee(deps, 1); throw error; } } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { void runNodeMain() .then((code) => process.exit(code)) .catch((err) => { console.error(err); process.exit(1); }); }