mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 20:40:45 +00:00
Merged via squash.
Prepared head SHA: edd8ed8254
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
349 lines
9.6 KiB
JavaScript
349 lines
9.6 KiB
JavaScript
#!/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 runNodeSourceRoots = ["src", "extensions"];
|
|
const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"];
|
|
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
|
|
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);
|
|
};
|
|
|
|
export const isBuildRelevantRunNodePath = (repoPath) => {
|
|
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
|
|
if (runNodeConfigFiles.includes(normalizedPath)) {
|
|
return true;
|
|
}
|
|
if (normalizedPath.startsWith("src/")) {
|
|
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
|
|
}
|
|
if (normalizedPath.startsWith("extensions/")) {
|
|
return isBuildRelevantSourcePath(normalizedPath.slice("extensions/".length));
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const isRestartRelevantExtensionPath = (relativePath) => {
|
|
const normalizedPath = normalizePath(relativePath);
|
|
if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) {
|
|
return true;
|
|
}
|
|
return isBuildRelevantSourcePath(normalizedPath);
|
|
};
|
|
|
|
export const isRestartRelevantRunNodePath = (repoPath) => {
|
|
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
|
|
if (runNodeConfigFiles.includes(normalizedPath)) {
|
|
return true;
|
|
}
|
|
if (normalizedPath.startsWith("src/")) {
|
|
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
|
|
}
|
|
if (normalizedPath.startsWith("extensions/")) {
|
|
return isRestartRelevantExtensionPath(normalizedPath.slice("extensions/".length));
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const statMtime = (filePath, fsImpl = fs) => {
|
|
try {
|
|
return fsImpl.statSync(filePath).mtimeMs;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
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 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 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;
|
|
};
|
|
|
|
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) {
|
|
// Best-effort stamp; still allow the runner to start.
|
|
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.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({
|
|
name: sourceRoot,
|
|
path: path.join(deps.cwd, sourceRoot),
|
|
}));
|
|
deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath));
|
|
|
|
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);
|
|
});
|
|
}
|