#!/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 = /^(?.+)-[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); }