| #!/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"; |
|
|
| 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) => { |
| try { |
| return fs.statSync(filePath).mtimeMs; |
| } catch { |
| return null; |
| } |
| }; |
|
|
| const isExcludedSource = (filePath) => { |
| 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) => { |
| let latest = null; |
| const queue = [dirPath]; |
| while (queue.length > 0) { |
| const current = queue.pop(); |
| if (!current) { |
| continue; |
| } |
| let entries = []; |
| try { |
| entries = 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); |
| if (mtime == null) { |
| continue; |
| } |
| if (latest == null || mtime > latest) { |
| latest = mtime; |
| } |
| } |
| } |
| return latest; |
| }; |
|
|
| const runGit = (args) => { |
| try { |
| const result = spawnSync("git", args, { |
| cwd, |
| encoding: "utf8", |
| stdio: ["ignore", "pipe", "ignore"], |
| }); |
| if (result.status !== 0) { |
| return null; |
| } |
| return (result.stdout ?? "").trim(); |
| } catch { |
| return null; |
| } |
| }; |
|
|
| const resolveGitHead = () => { |
| const head = runGit(["rev-parse", "HEAD"]); |
| return head || null; |
| }; |
|
|
| const hasDirtySourceTree = () => { |
| const output = runGit([ |
| "status", |
| "--porcelain", |
| "--untracked-files=normal", |
| "--", |
| ...gitWatchedPaths, |
| ]); |
| if (output === null) { |
| return null; |
| } |
| return output.length > 0; |
| }; |
|
|
| const readBuildStamp = () => { |
| const mtime = statMtime(buildStampPath); |
| if (mtime == null) { |
| return { mtime: null, head: null }; |
| } |
| try { |
| const raw = fs.readFileSync(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) => { |
| const srcMtime = findLatestMtime(srcRoot, isExcludedSource); |
| return srcMtime != null && srcMtime > stampMtime; |
| }; |
|
|
| const shouldBuild = () => { |
| if (env.OPENCLAW_FORCE_BUILD === "1") { |
| return true; |
| } |
| const stamp = readBuildStamp(); |
| if (stamp.mtime == null) { |
| return true; |
| } |
| if (statMtime(distEntry) == null) { |
| return true; |
| } |
|
|
| for (const filePath of configFiles) { |
| const mtime = statMtime(filePath); |
| if (mtime != null && mtime > stamp.mtime) { |
| return true; |
| } |
| } |
|
|
| const currentHead = resolveGitHead(); |
| if (currentHead && !stamp.head) { |
| return hasSourceMtimeChanged(stamp.mtime); |
| } |
| if (currentHead && stamp.head && currentHead !== stamp.head) { |
| return hasSourceMtimeChanged(stamp.mtime); |
| } |
| if (currentHead) { |
| const dirty = hasDirtySourceTree(); |
| if (dirty === true) { |
| return true; |
| } |
| if (dirty === false) { |
| return false; |
| } |
| } |
|
|
| if (hasSourceMtimeChanged(stamp.mtime)) { |
| return true; |
| } |
| return false; |
| }; |
|
|
| const logRunner = (message) => { |
| if (env.OPENCLAW_RUNNER_LOG === "0") { |
| return; |
| } |
| process.stderr.write(`[openclaw] ${message}\n`); |
| }; |
|
|
| const runNode = () => { |
| const nodeProcess = spawn(process.execPath, ["openclaw.mjs", ...args], { |
| cwd, |
| env, |
| stdio: "inherit", |
| }); |
|
|
| nodeProcess.on("exit", (exitCode, exitSignal) => { |
| if (exitSignal) { |
| process.exit(1); |
| } |
| process.exit(exitCode ?? 1); |
| }); |
| }; |
|
|
| const writeBuildStamp = () => { |
| try { |
| fs.mkdirSync(distRoot, { recursive: true }); |
| const stamp = { |
| builtAt: Date.now(), |
| head: resolveGitHead(), |
| }; |
| fs.writeFileSync(buildStampPath, `${JSON.stringify(stamp)}\n`); |
| } catch (error) { |
| |
| logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`); |
| } |
| }; |
|
|
| if (!shouldBuild()) { |
| runNode(); |
| } else { |
| logRunner("Building TypeScript (dist is stale)."); |
| const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm"; |
| const buildArgs = |
| process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...compilerArgs] : compilerArgs; |
| const build = spawn(buildCmd, buildArgs, { |
| cwd, |
| env, |
| stdio: "inherit", |
| }); |
|
|
| build.on("exit", (code, signal) => { |
| if (signal) { |
| process.exit(1); |
| } |
| if (code !== 0 && code !== null) { |
| process.exit(code); |
| } |
| writeBuildStamp(); |
| runNode(); |
| }); |
| } |
|
|