| #!/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"; |
|
|
| const compiler = "tsdown"; |
| const compilerArgs = ["exec", compiler, "--no-clean"]; |
|
|
| const gitWatchedPaths = ["src", "tsconfig.json", "package.json"]; |
|
|
| const statMtime = (filePath, fsImpl = fs) => { |
| try { |
| return fsImpl.statSync(filePath).mtimeMs; |
| } catch { |
| return null; |
| } |
| }; |
|
|
| const isExcludedSource = (filePath, srcRoot) => { |
| const relativePath = path.relative(srcRoot, filePath); |
| if (relativePath.startsWith("..")) { |
| return false; |
| } |
| return ( |
| relativePath.endsWith(".test.ts") || |
| relativePath.endsWith(".test.tsx") || |
| relativePath.endsWith(`test-helpers.ts`) |
| ); |
| }; |
|
|
| 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 runGit = (gitArgs, deps) => { |
| try { |
| const result = deps.spawnSync("git", gitArgs, { |
| cwd: deps.cwd, |
| encoding: "utf8", |
| stdio: ["ignore", "pipe", "ignore"], |
| }); |
| if (result.status !== 0) { |
| return null; |
| } |
| return (result.stdout ?? "").trim(); |
| } catch { |
| return null; |
| } |
| }; |
|
|
| const resolveGitHead = (deps) => { |
| const head = runGit(["rev-parse", "HEAD"], deps); |
| return head || null; |
| }; |
|
|
| const hasDirtySourceTree = (deps) => { |
| const output = runGit( |
| ["status", "--porcelain", "--untracked-files=normal", "--", ...gitWatchedPaths], |
| deps, |
| ); |
| if (output === null) { |
| return null; |
| } |
| return output.length > 0; |
| }; |
|
|
| 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) => { |
| const srcMtime = findLatestMtime( |
| deps.srcRoot, |
| (candidate) => isExcludedSource(candidate, deps.srcRoot), |
| deps, |
| ); |
| return srcMtime != null && srcMtime > stampMtime; |
| }; |
|
|
| const shouldBuild = (deps) => { |
| if (deps.env.OPENCLAW_FORCE_BUILD === "1") { |
| return true; |
| } |
| const stamp = readBuildStamp(deps); |
| if (stamp.mtime == null) { |
| return true; |
| } |
| if (statMtime(deps.distEntry, deps.fs) == null) { |
| return true; |
| } |
|
|
| for (const filePath of deps.configFiles) { |
| const mtime = statMtime(filePath, deps.fs); |
| if (mtime != null && mtime > stamp.mtime) { |
| return true; |
| } |
| } |
|
|
| const currentHead = resolveGitHead(deps); |
| if (currentHead && !stamp.head) { |
| return hasSourceMtimeChanged(stamp.mtime, deps); |
| } |
| if (currentHead && stamp.head && currentHead !== stamp.head) { |
| return hasSourceMtimeChanged(stamp.mtime, deps); |
| } |
| if (currentHead) { |
| const dirty = hasDirtySourceTree(deps); |
| if (dirty === true) { |
| return true; |
| } |
| if (dirty === false) { |
| return false; |
| } |
| } |
|
|
| if (hasSourceMtimeChanged(stamp.mtime, deps)) { |
| return true; |
| } |
| return false; |
| }; |
|
|
| const logRunner = (message, deps) => { |
| if (deps.env.OPENCLAW_RUNNER_LOG === "0") { |
| return; |
| } |
| deps.stderr.write(`[openclaw] ${message}\n`); |
| }; |
|
|
| const runOpenClaw = async (deps) => { |
| const nodeProcess = deps.spawn(deps.execPath, ["openclaw.mjs", ...deps.args], { |
| cwd: deps.cwd, |
| env: deps.env, |
| stdio: "inherit", |
| }); |
| 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 = (deps) => { |
| try { |
| deps.fs.mkdirSync(deps.distRoot, { recursive: true }); |
| const stamp = { |
| builtAt: Date.now(), |
| head: resolveGitHead(deps), |
| }; |
| deps.fs.writeFileSync(deps.buildStampPath, `${JSON.stringify(stamp)}\n`); |
| } catch (error) { |
| |
| logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`, deps); |
| } |
| }; |
|
|
| 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 = |
| deps.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs; |
| const build = deps.spawn(buildCmd, buildArgs, { |
| cwd: deps.cwd, |
| env: deps.env, |
| stdio: "inherit", |
| }); |
|
|
| 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); |
| }); |
| } |
|
|