mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 17:11:46 +00:00
358 lines
10 KiB
JavaScript
358 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs";
|
|
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
|
|
import {
|
|
isSourceCheckoutRoot,
|
|
pruneBundledPluginSourceNodeModules,
|
|
} from "./postinstall-bundled-plugins.mjs";
|
|
|
|
const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn";
|
|
const extraArgs = process.argv.slice(2);
|
|
const INEFFECTIVE_DYNAMIC_IMPORT_MARKER = "[INEFFECTIVE_DYNAMIC_IMPORT]";
|
|
const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/;
|
|
const ANSI_ESCAPE_RE = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g");
|
|
const HASHED_ROOT_JS_RE = /^(?<base>.+)-[A-Za-z0-9_-]+\.js$/u;
|
|
const DEFAULT_CAPTURE_BYTES = 8 * 1024 * 1024;
|
|
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
const TERMINATION_GRACE_MS = 5_000;
|
|
const TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"];
|
|
|
|
function removeDistPluginNodeModulesSymlinks(rootDir) {
|
|
const extensionsDir = path.join(rootDir, "extensions");
|
|
if (!fs.existsSync(extensionsDir)) {
|
|
return;
|
|
}
|
|
|
|
for (const dirent of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
|
|
if (!dirent.isDirectory()) {
|
|
continue;
|
|
}
|
|
const nodeModulesPath = path.join(extensionsDir, dirent.name, "node_modules");
|
|
try {
|
|
if (fs.lstatSync(nodeModulesPath).isSymbolicLink()) {
|
|
fs.rmSync(nodeModulesPath, { force: true, recursive: true });
|
|
}
|
|
} catch {
|
|
// Skip missing or unreadable paths so the build can proceed.
|
|
}
|
|
}
|
|
}
|
|
|
|
function pruneStaleRuntimeSymlinks() {
|
|
const cwd = process.cwd();
|
|
// runtime-postbuild stages plugin-owned node_modules into dist/ and links the
|
|
// dist-runtime overlay back to that tree. Remove only those symlinks up front
|
|
// so tsdown's clean step cannot traverse stale runtime overlays on rebuilds.
|
|
removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist"));
|
|
removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist-runtime"));
|
|
}
|
|
|
|
export function cleanTsdownOutputRoots(params = {}) {
|
|
const cwd = params.cwd ?? process.cwd();
|
|
const fsImpl = params.fs ?? fs;
|
|
for (const root of TSDOWN_OUTPUT_ROOTS) {
|
|
const rootPath = path.join(cwd, root);
|
|
try {
|
|
fsImpl.rmSync(rootPath, { force: true, recursive: true });
|
|
} catch {
|
|
// Best-effort cleanup. tsdown will recreate the output tree it needs.
|
|
}
|
|
}
|
|
}
|
|
|
|
export function pruneStaleRootChunkFiles(params = {}) {
|
|
const cwd = params.cwd ?? process.cwd();
|
|
const fsImpl = params.fs ?? fs;
|
|
const roots = TSDOWN_OUTPUT_ROOTS.map((root) => path.join(cwd, root));
|
|
for (const root of roots) {
|
|
let entries = [];
|
|
try {
|
|
entries = fsImpl.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
if (!HASHED_ROOT_JS_RE.test(entry.name)) {
|
|
continue;
|
|
}
|
|
try {
|
|
fsImpl.rmSync(path.join(root, entry.name), { force: true });
|
|
} catch {
|
|
// Best-effort cleanup. The subsequent build will overwrite any stragglers.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function pruneSourceCheckoutBundledPluginNodeModules(params = {}) {
|
|
const cwd = params.cwd ?? process.cwd();
|
|
const logger = params.logger ?? console;
|
|
if (!isSourceCheckoutRoot({ packageRoot: cwd, existsSync: fs.existsSync })) {
|
|
return;
|
|
}
|
|
try {
|
|
pruneBundledPluginSourceNodeModules({
|
|
extensionsDir: path.join(cwd, "extensions"),
|
|
existsSync: fs.existsSync,
|
|
readdirSync: fs.readdirSync,
|
|
rmSync: fs.rmSync,
|
|
});
|
|
} catch (error) {
|
|
logger.warn(`tsdown: could not prune bundled plugin source node_modules: ${String(error)}`);
|
|
}
|
|
}
|
|
|
|
function findFatalUnresolvedImport(lines) {
|
|
for (const line of lines) {
|
|
if (!UNRESOLVED_IMPORT_RE.test(line)) {
|
|
continue;
|
|
}
|
|
|
|
const normalizedLine = line.replace(ANSI_ESCAPE_RE, "");
|
|
if (
|
|
!normalizedLine.includes(BUNDLED_PLUGIN_PATH_PREFIX) &&
|
|
!normalizedLine.includes("node_modules/")
|
|
) {
|
|
return normalizedLine;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parsePositiveInteger(value) {
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function parseNonNegativeInteger(value) {
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
export function createTsdownOutputScanner(params = {}) {
|
|
const maxCaptureBytes = params.maxCaptureBytes ?? DEFAULT_CAPTURE_BYTES;
|
|
let captured = "";
|
|
let pendingLine = "";
|
|
let hasIneffectiveDynamicImport = false;
|
|
let fatalUnresolvedImport = null;
|
|
|
|
function scanLines(text) {
|
|
const combined = pendingLine + text;
|
|
const lines = combined.split(/\r?\n/u);
|
|
pendingLine = lines.pop() ?? "";
|
|
for (const line of lines) {
|
|
fatalUnresolvedImport ??= findFatalUnresolvedImport([line]);
|
|
}
|
|
}
|
|
|
|
return {
|
|
append(chunk) {
|
|
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
if (text.includes(INEFFECTIVE_DYNAMIC_IMPORT_MARKER)) {
|
|
hasIneffectiveDynamicImport = true;
|
|
}
|
|
scanLines(text);
|
|
captured += text;
|
|
if (captured.length > maxCaptureBytes) {
|
|
captured = captured.slice(-maxCaptureBytes);
|
|
}
|
|
},
|
|
finish() {
|
|
if (pendingLine) {
|
|
fatalUnresolvedImport ??= findFatalUnresolvedImport([pendingLine]);
|
|
pendingLine = "";
|
|
}
|
|
return {
|
|
captured,
|
|
hasIneffectiveDynamicImport,
|
|
fatalUnresolvedImport,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function resolveTsdownBuildInvocation(params = {}) {
|
|
const env = params.env ?? process.env;
|
|
const runner = resolvePnpmRunner({
|
|
pnpmArgs: [
|
|
"exec",
|
|
"tsdown",
|
|
"--config-loader",
|
|
"unrun",
|
|
"--logLevel",
|
|
logLevel,
|
|
"--no-clean",
|
|
...extraArgs,
|
|
],
|
|
nodeExecPath: params.nodeExecPath ?? process.execPath,
|
|
npmExecPath: params.npmExecPath ?? env.npm_execpath,
|
|
comSpec: params.comSpec ?? env.ComSpec,
|
|
platform: params.platform ?? process.platform,
|
|
});
|
|
return {
|
|
command: runner.command,
|
|
args: runner.args,
|
|
options: {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
shell: runner.shell,
|
|
windowsVerbatimArguments: runner.windowsVerbatimArguments,
|
|
env,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function runTsdownBuildInvocation(invocation, params = {}) {
|
|
const stdout = params.stdout ?? process.stdout;
|
|
const stderr = params.stderr ?? process.stderr;
|
|
const env = params.env ?? process.env;
|
|
const scanner = params.scanner ?? createTsdownOutputScanner();
|
|
const timeoutMs = parsePositiveInteger(env.OPENCLAW_TSDOWN_TIMEOUT_MS);
|
|
const heartbeatMs =
|
|
parseNonNegativeInteger(env.OPENCLAW_TSDOWN_HEARTBEAT_MS) ?? DEFAULT_HEARTBEAT_MS;
|
|
let timedOut = false;
|
|
let settled = false;
|
|
let lastOutputAt = Date.now();
|
|
|
|
const child = spawn(invocation.command, invocation.args, invocation.options);
|
|
const pidText = child.pid ? ` pid=${child.pid}` : "";
|
|
|
|
function markOutput() {
|
|
lastOutputAt = Date.now();
|
|
}
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
markOutput();
|
|
scanner.append(chunk);
|
|
stdout.write(chunk);
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
markOutput();
|
|
scanner.append(chunk);
|
|
stderr.write(chunk);
|
|
});
|
|
|
|
const heartbeat =
|
|
heartbeatMs > 0
|
|
? setInterval(() => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
const silentForMs = Date.now() - lastOutputAt;
|
|
if (silentForMs < heartbeatMs) {
|
|
return;
|
|
}
|
|
stderr.write(
|
|
`[tsdown-build] still running${pidText}; no output for ${Math.round(
|
|
silentForMs / 1000,
|
|
)}s\n`,
|
|
);
|
|
lastOutputAt = Date.now();
|
|
}, heartbeatMs).unref()
|
|
: null;
|
|
|
|
const timeout =
|
|
timeoutMs !== null
|
|
? setTimeout(() => {
|
|
timedOut = true;
|
|
stderr.write(`[tsdown-build] timeout after ${timeoutMs}ms${pidText}; sending SIGTERM\n`);
|
|
child.kill("SIGTERM");
|
|
setTimeout(() => {
|
|
if (!settled) {
|
|
stderr.write(`[tsdown-build] forcing SIGKILL${pidText}\n`);
|
|
child.kill("SIGKILL");
|
|
}
|
|
}, TERMINATION_GRACE_MS).unref();
|
|
}, timeoutMs).unref()
|
|
: null;
|
|
|
|
return new Promise((resolve) => {
|
|
child.once("error", (error) => {
|
|
settled = true;
|
|
clearInterval(heartbeat);
|
|
clearTimeout(timeout);
|
|
stderr.write(`[tsdown-build] failed to start: ${String(error)}\n`);
|
|
resolve({
|
|
status: 1,
|
|
signal: null,
|
|
timedOut,
|
|
error,
|
|
...scanner.finish(),
|
|
});
|
|
});
|
|
child.once("close", (status, signal) => {
|
|
settled = true;
|
|
clearInterval(heartbeat);
|
|
clearTimeout(timeout);
|
|
resolve({
|
|
status,
|
|
signal,
|
|
timedOut,
|
|
error: null,
|
|
...scanner.finish(),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function isMainModule() {
|
|
const argv1 = process.argv[1];
|
|
if (!argv1) {
|
|
return false;
|
|
}
|
|
return import.meta.url === pathToFileURL(argv1).href;
|
|
}
|
|
|
|
if (isMainModule()) {
|
|
pruneSourceCheckoutBundledPluginNodeModules();
|
|
pruneStaleRuntimeSymlinks();
|
|
cleanTsdownOutputRoots();
|
|
const invocation = resolveTsdownBuildInvocation();
|
|
const result = await runTsdownBuildInvocation(invocation);
|
|
|
|
if (result.status === 0 && result.hasIneffectiveDynamicImport) {
|
|
console.error(
|
|
"Build emitted [INEFFECTIVE_DYNAMIC_IMPORT]. Replace transparent runtime re-export facades with real runtime boundaries.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (result.status === 0 && result.fatalUnresolvedImport) {
|
|
console.error(
|
|
`Build emitted [UNRESOLVED_IMPORT] outside extensions: ${result.fatalUnresolvedImport}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (result.timedOut) {
|
|
process.exit(124);
|
|
}
|
|
|
|
if (typeof result.status === "number") {
|
|
process.exit(result.status);
|
|
}
|
|
|
|
process.exit(1);
|
|
}
|