diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index d06f70e556..9f922949eb 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -3,29 +3,22 @@ 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"; -const args = process.argv.slice(2); -const env = { ...process.env }; -const cwd = process.cwd(); const compiler = "tsdown"; const compilerArgs = ["exec", compiler, "--no-clean"]; -const distRoot = path.join(cwd, "dist"); -const distEntry = path.join(distRoot, "/entry.js"); -const buildStampPath = path.join(distRoot, ".buildstamp"); -const srcRoot = path.join(cwd, "src"); -const configFiles = [path.join(cwd, "tsconfig.json"), path.join(cwd, "package.json")]; const gitWatchedPaths = ["src", "tsconfig.json", "package.json"]; -const statMtime = (filePath) => { +const statMtime = (filePath, fsImpl = fs) => { try { - return fs.statSync(filePath).mtimeMs; + return fsImpl.statSync(filePath).mtimeMs; } catch { return null; } }; -const isExcludedSource = (filePath) => { +const isExcludedSource = (filePath, srcRoot) => { const relativePath = path.relative(srcRoot, filePath); if (relativePath.startsWith("..")) { return false; @@ -37,7 +30,7 @@ const isExcludedSource = (filePath) => { ); }; -const findLatestMtime = (dirPath, shouldSkip) => { +const findLatestMtime = (dirPath, shouldSkip, deps) => { let latest = null; const queue = [dirPath]; while (queue.length > 0) { @@ -47,7 +40,7 @@ const findLatestMtime = (dirPath, shouldSkip) => { } let entries = []; try { - entries = fs.readdirSync(current, { withFileTypes: true }); + entries = deps.fs.readdirSync(current, { withFileTypes: true }); } catch { continue; } @@ -63,7 +56,7 @@ const findLatestMtime = (dirPath, shouldSkip) => { if (shouldSkip?.(fullPath)) { continue; } - const mtime = statMtime(fullPath); + const mtime = statMtime(fullPath, deps.fs); if (mtime == null) { continue; } @@ -75,10 +68,10 @@ const findLatestMtime = (dirPath, shouldSkip) => { return latest; }; -const runGit = (args) => { +const runGit = (gitArgs, deps) => { try { - const result = spawnSync("git", args, { - cwd, + const result = deps.spawnSync("git", gitArgs, { + cwd: deps.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); @@ -91,32 +84,29 @@ const runGit = (args) => { } }; -const resolveGitHead = () => { - const head = runGit(["rev-parse", "HEAD"]); +const resolveGitHead = (deps) => { + const head = runGit(["rev-parse", "HEAD"], deps); return head || null; }; -const hasDirtySourceTree = () => { - const output = runGit([ - "status", - "--porcelain", - "--untracked-files=normal", - "--", - ...gitWatchedPaths, - ]); +const hasDirtySourceTree = (deps) => { + const output = runGit( + ["status", "--porcelain", "--untracked-files=normal", "--", ...gitWatchedPaths], + deps, + ); if (output === null) { return null; } return output.length > 0; }; -const readBuildStamp = () => { - const mtime = statMtime(buildStampPath); +const readBuildStamp = (deps) => { + const mtime = statMtime(deps.buildStampPath, deps.fs); if (mtime == null) { return { mtime: null, head: null }; } try { - const raw = fs.readFileSync(buildStampPath, "utf8").trim(); + const raw = deps.fs.readFileSync(deps.buildStampPath, "utf8").trim(); if (!raw.startsWith("{")) { return { mtime, head: null }; } @@ -128,39 +118,43 @@ const readBuildStamp = () => { } }; -const hasSourceMtimeChanged = (stampMtime) => { - const srcMtime = findLatestMtime(srcRoot, isExcludedSource); +const hasSourceMtimeChanged = (stampMtime, deps) => { + const srcMtime = findLatestMtime( + deps.srcRoot, + (candidate) => isExcludedSource(candidate, deps.srcRoot), + deps, + ); return srcMtime != null && srcMtime > stampMtime; }; -const shouldBuild = () => { - if (env.OPENCLAW_FORCE_BUILD === "1") { +const shouldBuild = (deps) => { + if (deps.env.OPENCLAW_FORCE_BUILD === "1") { return true; } - const stamp = readBuildStamp(); + const stamp = readBuildStamp(deps); if (stamp.mtime == null) { return true; } - if (statMtime(distEntry) == null) { + if (statMtime(deps.distEntry, deps.fs) == null) { return true; } - for (const filePath of configFiles) { - const mtime = statMtime(filePath); + for (const filePath of deps.configFiles) { + const mtime = statMtime(filePath, deps.fs); if (mtime != null && mtime > stamp.mtime) { return true; } } - const currentHead = resolveGitHead(); + const currentHead = resolveGitHead(deps); if (currentHead && !stamp.head) { - return hasSourceMtimeChanged(stamp.mtime); + return hasSourceMtimeChanged(stamp.mtime, deps); } if (currentHead && stamp.head && currentHead !== stamp.head) { - return hasSourceMtimeChanged(stamp.mtime); + return hasSourceMtimeChanged(stamp.mtime, deps); } if (currentHead) { - const dirty = hasDirtySourceTree(); + const dirty = hasDirtySourceTree(deps); if (dirty === true) { return true; } @@ -169,69 +163,101 @@ const shouldBuild = () => { } } - if (hasSourceMtimeChanged(stamp.mtime)) { + if (hasSourceMtimeChanged(stamp.mtime, deps)) { return true; } return false; }; -const logRunner = (message) => { - if (env.OPENCLAW_RUNNER_LOG === "0") { +const logRunner = (message, deps) => { + if (deps.env.OPENCLAW_RUNNER_LOG === "0") { return; } - process.stderr.write(`[openclaw] ${message}\n`); + deps.stderr.write(`[openclaw] ${message}\n`); }; -const runNode = () => { - const nodeProcess = spawn(process.execPath, ["openclaw.mjs", ...args], { - cwd, - env, +const runOpenClaw = async (deps) => { + const nodeProcess = deps.spawn(deps.execPath, ["openclaw.mjs", ...deps.args], { + cwd: deps.cwd, + env: deps.env, stdio: "inherit", }); - - nodeProcess.on("exit", (exitCode, exitSignal) => { - if (exitSignal) { - process.exit(1); - } - process.exit(exitCode ?? 1); + const res = await new Promise((resolve) => { + nodeProcess.on("exit", (exitCode, exitSignal) => { + resolve({ exitCode, exitSignal }); + }); }); + if (res.exitSignal) { + return 1; + } + return res.exitCode ?? 1; }; -const writeBuildStamp = () => { +const writeBuildStamp = (deps) => { try { - fs.mkdirSync(distRoot, { recursive: true }); + deps.fs.mkdirSync(deps.distRoot, { recursive: true }); const stamp = { builtAt: Date.now(), - head: resolveGitHead(), + head: resolveGitHead(deps), }; - fs.writeFileSync(buildStampPath, `${JSON.stringify(stamp)}\n`); + deps.fs.writeFileSync(deps.buildStampPath, `${JSON.stringify(stamp)}\n`); } catch (error) { // Best-effort stamp; still allow the runner to start. - logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`); + logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`, deps); } }; -if (!shouldBuild()) { - runNode(); -} else { - logRunner("Building TypeScript (dist is stale)."); - const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm"; +export async function runNodeMain(params = {}) { + const deps = { + spawn: params.spawn ?? spawn, + spawnSync: params.spawnSync ?? spawnSync, + fs: params.fs ?? fs, + stderr: params.stderr ?? process.stderr, + execPath: params.execPath ?? process.execPath, + cwd: params.cwd ?? process.cwd(), + args: params.args ?? process.argv.slice(2), + env: params.env ? { ...params.env } : { ...process.env }, + platform: params.platform ?? process.platform, + }; + + deps.distRoot = path.join(deps.cwd, "dist"); + deps.distEntry = path.join(deps.distRoot, "/entry.js"); + deps.buildStampPath = path.join(deps.distRoot, ".buildstamp"); + deps.srcRoot = path.join(deps.cwd, "src"); + deps.configFiles = [path.join(deps.cwd, "tsconfig.json"), path.join(deps.cwd, "package.json")]; + + if (!shouldBuild(deps)) { + return await runOpenClaw(deps); + } + + logRunner("Building TypeScript (dist is stale).", deps); + const buildCmd = deps.platform === "win32" ? "cmd.exe" : "pnpm"; const buildArgs = - process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs; - const build = spawn(buildCmd, buildArgs, { - cwd, - env, + deps.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs; + const build = deps.spawn(buildCmd, buildArgs, { + cwd: deps.cwd, + env: deps.env, stdio: "inherit", }); - build.on("exit", (code, signal) => { - if (signal) { - process.exit(1); - } - if (code !== 0 && code !== null) { - process.exit(code); - } - writeBuildStamp(); - runNode(); + const buildRes = await new Promise((resolve) => { + build.on("exit", (exitCode, exitSignal) => resolve({ exitCode, exitSignal })); }); + if (buildRes.exitSignal) { + return 1; + } + if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) { + return buildRes.exitCode; + } + writeBuildStamp(deps); + return await runOpenClaw(deps); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + void runNodeMain() + .then((code) => process.exit(code)) + .catch((err) => { + console.error(err); + process.exit(1); + }); } diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 5c5df23e19..72713220c1 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -18,54 +17,51 @@ describe("run-node script", () => { "preserves control-ui assets by building with tsdown --no-clean", async () => { await withTempDir(async (tmp) => { - const runNodeScript = path.join(process.cwd(), "scripts", "run-node.mjs"); - const fakeBinDir = path.join(tmp, ".fake-bin"); - const fakePnpmPath = path.join(fakeBinDir, "pnpm"); const argsPath = path.join(tmp, ".pnpm-args.txt"); const indexPath = path.join(tmp, "dist", "control-ui", "index.html"); - await fs.mkdir(fakeBinDir, { recursive: true }); await fs.mkdir(path.dirname(indexPath), { recursive: true }); await fs.writeFile(indexPath, "sentinel\n", "utf-8"); - await fs.writeFile( - path.join(tmp, "openclaw.mjs"), - "#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('9.9.9-test');\n", - "utf-8", - ); - await fs.chmod(path.join(tmp, "openclaw.mjs"), 0o755); - - const fakePnpm = `#!/usr/bin/env node -const fs = require("node:fs"); -const path = require("node:path"); -const args = process.argv.slice(2); -const cwd = process.cwd(); -fs.writeFileSync(path.join(cwd, ".pnpm-args.txt"), args.join(" "), "utf-8"); -if (!args.includes("--no-clean")) { - fs.rmSync(path.join(cwd, "dist", "control-ui"), { recursive: true, force: true }); -} -fs.mkdirSync(path.join(cwd, "dist"), { recursive: true }); -fs.writeFileSync(path.join(cwd, "dist", "entry.js"), "export {}\\n", "utf-8"); -`; - await fs.writeFile(fakePnpmPath, fakePnpm, "utf-8"); - await fs.chmod(fakePnpmPath, 0o755); - - const env = { - ...process.env, - PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, - OPENCLAW_FORCE_BUILD: "1", - OPENCLAW_RUNNER_LOG: "0", + const nodeCalls: string[][] = []; + const spawn = (cmd: string, args: string[]) => { + if (cmd === "pnpm") { + void fs.writeFile(argsPath, args.join(" "), "utf-8"); + if (!args.includes("--no-clean")) { + void fs.rm(path.join(tmp, "dist", "control-ui"), { recursive: true, force: true }); + } + } + if (cmd === process.execPath) { + nodeCalls.push([cmd, ...args]); + } + return { + on: (event: string, cb: (code: number | null, signal: string | null) => void) => { + if (event === "exit") { + queueMicrotask(() => cb(0, null)); + } + return undefined; + }, + }; }; - const result = spawnSync(process.execPath, [runNodeScript, "--version"], { + + const { runNodeMain } = await import("../../scripts/run-node.mjs"); + const exitCode = await runNodeMain({ cwd: tmp, - env, - encoding: "utf-8", + args: ["--version"], + env: { + ...process.env, + OPENCLAW_FORCE_BUILD: "1", + OPENCLAW_RUNNER_LOG: "0", + }, + spawn, + execPath: process.execPath, + platform: process.platform, }); - expect(result.status).toBe(0); - expect(result.stdout).toContain("9.9.9-test"); + expect(exitCode).toBe(0); await expect(fs.readFile(argsPath, "utf-8")).resolves.toContain("exec tsdown --no-clean"); await expect(fs.readFile(indexPath, "utf-8")).resolves.toContain("sentinel"); + expect(nodeCalls).toEqual([[process.execPath, "openclaw.mjs", "--version"]]); }); }, );