mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-22 15:06:57 +00:00
628 lines
25 KiB
TypeScript
628 lines
25 KiB
TypeScript
// Generates postbuild runtime artifacts: plugin metadata, SDK aliases, stable
|
|
// runtime aliases, static assets, and compatibility chunks for live upgrades.
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import { pathToFileURL } from "node:url";
|
|
import { buildSync } from "esbuild";
|
|
import {
|
|
readRuntimeDependencyOwnership,
|
|
RUNTIME_DEPENDENCY_OWNERSHIP_RELATIVE_PATH,
|
|
type RuntimeDependencyOwnership,
|
|
} from "../src/infra/runtime-dependency-ownership.ts";
|
|
import { verifyBuiltPluginControlPlaneModules } from "./check-built-plugin-control-plane-modules.mts";
|
|
import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mts";
|
|
import { copyHookMetadata, listHookMetadataOutputs } from "./copy-hook-metadata.ts";
|
|
import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts";
|
|
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
|
|
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
import {
|
|
copyStaticExtensionAssets,
|
|
copyStaticExtensionAssetsToRuntimeOverlay,
|
|
discoverStaticExtensionAssets,
|
|
shouldCopyStaticExtensionAssets,
|
|
} from "./lib/static-extension-assets.mts";
|
|
import {
|
|
isUpdateCompatibilityChunk,
|
|
listUpdateCompatibilityChunkPaths,
|
|
readUpdateCompatibilityInventory,
|
|
writeUpdateCompatibilityChunks,
|
|
} from "./lib/update-compat-chunks.mts";
|
|
import { buildUpdateConfigRuntimeAlias } from "./lib/update-config-runtime-compat.mts";
|
|
import { CLI_DIAGNOSTIC_COMPANIONS, writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
|
|
import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mts";
|
|
import { writeBuildInfo } from "./write-build-info.ts";
|
|
import { writeOfficialChannelCatalog } from "./write-official-channel-catalog.mts";
|
|
|
|
type RuntimePostBuildParams = {
|
|
rootDir?: string;
|
|
repoRoot?: string;
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
fs?: typeof fs;
|
|
timings?: boolean | "verbose";
|
|
warn?: (message: string) => void;
|
|
};
|
|
type RuntimeFsParams = Pick<RuntimePostBuildParams, "rootDir" | "fs">;
|
|
type RuntimeAliasCandidate = { candidate: string; source: string };
|
|
|
|
const LEGACY_UPDATE_NODE_RUNNER_COMPAT_CHUNK =
|
|
'export { resolveNodeRunner } from "./cli/update-cli/node-runner.js";\n';
|
|
|
|
const ROOT = resolveRepoRoot(import.meta.url);
|
|
const UPDATE_COMPATIBILITY_INVENTORY = path.join(ROOT, "scripts/lib/update-compat-inventory.json");
|
|
const ROOT_RUNTIME_ALIAS_PATTERN = /^(?<base>.+\.(?:runtime|contract))-[A-Za-z0-9_-]+\.m?js$/u;
|
|
const ROOT_STABLE_RUNTIME_ALIAS_PATTERN = /^.+\.(?:runtime|contract)\.js$/u;
|
|
const ROOT_RUNTIME_IMPORT_SPECIFIER_PATTERN =
|
|
/(["'])\.\/([^"']+\.(?:runtime|contract)-[A-Za-z0-9_-]+\.m?js)\1/gu;
|
|
const OFFICIAL_CHANNEL_CATALOG_OUTPUT = "dist/channel-catalog.json";
|
|
const EXPORT_HTML_SOURCE_DIR = "src/auto-reply/reply/export-html";
|
|
const EXPORT_HTML_OUTPUT_DIR = "dist/export-html";
|
|
const EXPORT_HTML_OUTPUTS = [
|
|
`${EXPORT_HTML_OUTPUT_DIR}/template.css`,
|
|
`${EXPORT_HTML_OUTPUT_DIR}/template.html`,
|
|
`${EXPORT_HTML_OUTPUT_DIR}/template.js`,
|
|
`${EXPORT_HTML_OUTPUT_DIR}/vendor/highlight.min.js`,
|
|
`${EXPORT_HTML_OUTPUT_DIR}/vendor/marked.min.js`,
|
|
];
|
|
const EXPORT_HTML_VENDOR_ENTRYPOINTS = [
|
|
{
|
|
fileName: "marked.min.js",
|
|
packageEntry: "marked",
|
|
packageName: "marked",
|
|
globalName: "marked",
|
|
licenseFile: "LICENSE",
|
|
},
|
|
{
|
|
fileName: "highlight.min.js",
|
|
packageEntry: "highlight.js/lib/common",
|
|
packageName: "highlight.js",
|
|
globalName: "hljs",
|
|
footer: "hljs = hljs.default || hljs;",
|
|
licenseFile: "LICENSE",
|
|
},
|
|
];
|
|
const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES: Array<readonly [string, string]> = [
|
|
// v2026.6.8 text-transform runtime. The stable alias remains
|
|
// for old chunks, but new chunks keep hashed imports because the alias export
|
|
// set expanded in v2026.6.8 and may already be cached in a live gateway.
|
|
["text-transforms.runtime-sEqsN4pN.js", "text-transforms.runtime.js"],
|
|
];
|
|
const ROOT_RUNTIME_STABLE_IMPORT_SKIP_ALIASES = new Set(["text-transforms.runtime.js"]);
|
|
const PLUGIN_INSTALL_RUNTIME_ALIAS = {
|
|
aliasFileName: "install.runtime.js",
|
|
sourceIncludes: [
|
|
"scanPackageInstallSource",
|
|
"scanInstalledPackageDependencyTree",
|
|
"scanBundleInstallSource",
|
|
],
|
|
};
|
|
/** Compatibility chunks for old updater and CLI exit modules after package replacement. */
|
|
const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [
|
|
// v2026.8.2 and the exact d413210 and 0229a108 builds load these after replacing dist/.
|
|
// Remove only after the source artifacts fall outside the supported upgrade window.
|
|
{
|
|
dest: "dist/shared-Y6bNiw2w.js",
|
|
contents: LEGACY_UPDATE_NODE_RUNNER_COMPAT_CHUNK,
|
|
},
|
|
{
|
|
dest: "dist/shared-DTaQo6Hi.js",
|
|
contents: LEGACY_UPDATE_NODE_RUNNER_COMPAT_CHUNK,
|
|
},
|
|
{
|
|
dest: "dist/shared-1Uyqkfns.js",
|
|
contents: LEGACY_UPDATE_NODE_RUNNER_COMPAT_CHUNK,
|
|
},
|
|
];
|
|
|
|
function collectStableRootRuntimeAliasCandidates(distDir: string, fsImpl: typeof fs) {
|
|
const candidatesByAlias = new Map<string, RuntimeAliasCandidate[]>();
|
|
const pluginInstallCandidates: RuntimeAliasCandidate[] = [];
|
|
let entries;
|
|
try {
|
|
entries = fsImpl.readdirSync(distDir, { withFileTypes: true });
|
|
} catch {
|
|
return { entries: [], candidatesByAlias, pluginInstallCandidates };
|
|
}
|
|
|
|
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
const match = entry.name.match(ROOT_RUNTIME_ALIAS_PATTERN);
|
|
if (!match?.groups?.base) {
|
|
continue;
|
|
}
|
|
let source = "";
|
|
try {
|
|
source = fsImpl.readFileSync(path.join(distDir, entry.name), "utf8");
|
|
} catch {
|
|
// Unreadable candidates still participate in ambiguity detection below.
|
|
}
|
|
const aliasFileName = `${match.groups.base}.js`;
|
|
const candidate = { candidate: entry.name, source };
|
|
if (aliasFileName === PLUGIN_INSTALL_RUNTIME_ALIAS.aliasFileName) {
|
|
// Marker disambiguation also inspects compatibility facades.
|
|
pluginInstallCandidates.push(candidate);
|
|
}
|
|
if (isUpdateCompatibilityChunk(source)) {
|
|
continue;
|
|
}
|
|
const candidates = candidatesByAlias.get(aliasFileName) ?? [];
|
|
candidates.push(candidate);
|
|
candidatesByAlias.set(aliasFileName, candidates);
|
|
}
|
|
// Importer rewrites retain directory order; only alias candidates are sorted.
|
|
return { entries, candidatesByAlias, pluginInstallCandidates };
|
|
}
|
|
|
|
function resolveStableRootRuntimeAliasCandidate(
|
|
aliasFileName: string,
|
|
candidates: RuntimeAliasCandidate[],
|
|
pluginInstallCandidates: RuntimeAliasCandidate[],
|
|
) {
|
|
const implementationCandidates = candidates.filter(
|
|
({ source }) => !isRuntimeAliasSource(source, aliasFileName),
|
|
);
|
|
const candidateNames = implementationCandidates.map(({ candidate }) => candidate);
|
|
if (candidateNames.length === 1) {
|
|
return candidateNames[0] ?? null;
|
|
}
|
|
if (aliasFileName === PLUGIN_INSTALL_RUNTIME_ALIAS.aliasFileName) {
|
|
const matches = pluginInstallCandidates.filter(({ source }) =>
|
|
PLUGIN_INSTALL_RUNTIME_ALIAS.sourceIncludes.every((marker) => source.includes(marker)),
|
|
);
|
|
return matches.length === 1 ? (matches[0]?.candidate ?? null) : null;
|
|
}
|
|
const wrappers = implementationCandidates.filter(({ candidate, source }) =>
|
|
candidateNames.some(
|
|
(target) =>
|
|
target !== candidate &&
|
|
source.includes(`"./${target}"`) &&
|
|
!source.includes("\n//#region "),
|
|
),
|
|
);
|
|
return wrappers.length === 1 ? (wrappers[0]?.candidate ?? null) : null;
|
|
}
|
|
|
|
/**
|
|
* Lists stable aliases for hashed root runtime/contract chunks.
|
|
*/
|
|
function listStableRootRuntimeAliasOutputs(params: RuntimeFsParams = {}) {
|
|
const rootDir = params.rootDir ?? ROOT;
|
|
const distDir = path.join(rootDir, "dist");
|
|
const fsImpl = params.fs ?? fs;
|
|
const { candidatesByAlias, pluginInstallCandidates } = collectStableRootRuntimeAliasCandidates(
|
|
distDir,
|
|
fsImpl,
|
|
);
|
|
return [...candidatesByAlias]
|
|
.filter(([aliasFileName, candidates]) =>
|
|
resolveStableRootRuntimeAliasCandidate(aliasFileName, candidates, pluginInstallCandidates),
|
|
)
|
|
.map(([aliasFileName]) => `dist/${aliasFileName}`)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
/**
|
|
* Lists legacy hashed runtime aliases that may be needed during live upgrades.
|
|
*/
|
|
function listLegacyRootRuntimeCompatOutputs(params: RuntimeFsParams = {}) {
|
|
const rootDir = params.rootDir ?? ROOT;
|
|
const distDir = path.join(rootDir, "dist");
|
|
const fsImpl = params.fs ?? fs;
|
|
return LEGACY_ROOT_RUNTIME_COMPAT_ALIASES.filter(([, aliasFileName]) =>
|
|
fsImpl.existsSync(path.join(distDir, aliasFileName)),
|
|
)
|
|
.map(([legacyFileName]) => `dist/${legacyFileName}`)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function listCliDiagnosticCompanionOutputs(params: RuntimeFsParams = {}) {
|
|
const rootDir = params.rootDir ?? ROOT;
|
|
const fsImpl = params.fs ?? fs;
|
|
// The facade is emitted only for private QA; normal builds run helpers from source.
|
|
return fsImpl.existsSync(path.join(rootDir, "dist/plugin-sdk/test-env.js"))
|
|
? CLI_DIAGNOSTIC_COMPANIONS.map((fileName) => `dist/${fileName}`)
|
|
: [];
|
|
}
|
|
|
|
/**
|
|
* Lists all core runtime postbuild outputs expected after a build.
|
|
*/
|
|
export function listCoreRuntimePostBuildOutputs(params: RuntimeFsParams = {}) {
|
|
return [
|
|
"dist/build-info.json",
|
|
...listHookMetadataOutputs(params),
|
|
OFFICIAL_CHANNEL_CATALOG_OUTPUT,
|
|
...listExportHtmlTemplateOutputs(params),
|
|
...listCliDiagnosticCompanionOutputs(params),
|
|
...listStableRootRuntimeAliasOutputs(params),
|
|
...listLegacyRootRuntimeCompatOutputs(params),
|
|
...LEGACY_CLI_EXIT_COMPAT_CHUNKS.map(({ dest }) => dest),
|
|
...listUpdateCompatibilityChunkPaths(
|
|
readUpdateCompatibilityInventory(UPDATE_COMPATIBILITY_INVENTORY),
|
|
).map((fileName) => `dist/${fileName}`),
|
|
].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
/** Builds deterministic browser globals from the pinned workspace packages. */
|
|
export function generateExportHtmlVendorAssets(
|
|
params: Pick<RuntimePostBuildParams, "rootDir"> = {},
|
|
): Readonly<Record<string, string>> {
|
|
const rootDir = path.resolve(params.rootDir ?? ROOT);
|
|
const resolveFromRoot = createRequire(path.join(rootDir, "package.json")).resolve;
|
|
return Object.fromEntries(
|
|
EXPORT_HTML_VENDOR_ENTRYPOINTS.map(
|
|
({ fileName, packageEntry, packageName, globalName, footer, licenseFile }) => {
|
|
const result = buildSync({
|
|
absWorkingDir: rootDir,
|
|
bundle: true,
|
|
entryPoints: [packageEntry],
|
|
footer: footer ? { js: footer } : undefined,
|
|
format: "iife",
|
|
globalName,
|
|
legalComments: "inline",
|
|
logLevel: "silent",
|
|
minify: true,
|
|
platform: "browser",
|
|
target: "es2018",
|
|
write: false,
|
|
});
|
|
const output = result.outputFiles?.[0];
|
|
if (!output || result.outputFiles?.length !== 1) {
|
|
throw new Error(`Expected one generated export-html asset for ${packageEntry}`);
|
|
}
|
|
const packageRoot = path.dirname(resolveFromRoot(`${packageName}/package.json`));
|
|
const license = fs.readFileSync(path.join(packageRoot, licenseFile), "utf8").trimEnd();
|
|
if (license.includes("*/")) {
|
|
throw new Error(`Cannot embed ${packageName} license in a JavaScript comment`);
|
|
}
|
|
return [fileName, `/*!\n${license}\n*/\n${output.text}`];
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
function listExportHtmlTemplateOutputs(params: RuntimeFsParams = {}) {
|
|
const rootDir = params.rootDir ?? ROOT;
|
|
const fsImpl = params.fs ?? fs;
|
|
const hasSource = fsImpl.existsSync(path.join(rootDir, EXPORT_HTML_SOURCE_DIR));
|
|
const hasBuiltAssets = fsImpl.existsSync(path.join(rootDir, EXPORT_HTML_OUTPUT_DIR));
|
|
return hasSource || hasBuiltAssets ? [...EXPORT_HTML_OUTPUTS] : [];
|
|
}
|
|
|
|
/** Copies authored templates and generates dependency-owned browser payloads. */
|
|
export function copyExportHtmlTemplates(params: RuntimeFsParams = {}) {
|
|
const rootDir = path.resolve(params.rootDir ?? ROOT);
|
|
const fsImpl = params.fs ?? fs;
|
|
const sourceDir = path.join(rootDir, EXPORT_HTML_SOURCE_DIR);
|
|
if (!fsImpl.existsSync(sourceDir)) {
|
|
return;
|
|
}
|
|
const outputDir = path.join(rootDir, EXPORT_HTML_OUTPUT_DIR);
|
|
assertRealOutputRoot(path.join(rootDir, "dist"), { fs: fsImpl });
|
|
fsImpl.rmSync(outputDir, { recursive: true, force: true });
|
|
fsImpl.mkdirSync(outputDir, { recursive: true });
|
|
for (const entry of fsImpl.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || entry.name.endsWith(".test.ts")) {
|
|
continue;
|
|
}
|
|
fsImpl.copyFileSync(path.join(sourceDir, entry.name), path.join(outputDir, entry.name));
|
|
}
|
|
const vendorDir = path.join(outputDir, "vendor");
|
|
fsImpl.mkdirSync(vendorDir, { recursive: true });
|
|
for (const [fileName, contents] of Object.entries(generateExportHtmlVendorAssets({ rootDir }))) {
|
|
fsImpl.writeFileSync(path.join(vendorDir, fileName), contents);
|
|
}
|
|
}
|
|
|
|
const RUNTIME_CHUNK_DEFAULT_EXPORT_PATTERN =
|
|
/(^|\n)\s*export\b\s*(?:default\b|\{[^}]*(?:\bas\s+default\b|\bdefault\b\s*(?=[,}]))[^}]*\})/u;
|
|
|
|
function formatRuntimeAliasSource(targetFileName: string, forwardDefault = false) {
|
|
const specifier = JSON.stringify(`./${targetFileName}`);
|
|
const starSource = `export * from ${specifier};\n`;
|
|
return forwardDefault ? `${starSource}export { default } from ${specifier};\n` : starSource;
|
|
}
|
|
|
|
function isRuntimeAliasSource(source: string, targetFileName: string) {
|
|
const normalizedSource = source.trim();
|
|
return (
|
|
normalizedSource === formatRuntimeAliasSource(targetFileName).trim() ||
|
|
normalizedSource === formatRuntimeAliasSource(targetFileName, true).trim()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds alias module source for a runtime chunk target. `export * from`
|
|
* never re-exports `default`, so when the target chunk has a default export
|
|
* the alias must forward it explicitly — otherwise lazy `import()` consumers
|
|
* that destructure `default` receive `undefined` (e.g. the post-compaction
|
|
* count reconcile failed this way with "TypeError: reconcile is not a
|
|
* function" on every compaction).
|
|
*/
|
|
function buildRuntimeAliasSource(targetFileName: string, distDir: string, fsImpl: typeof fs) {
|
|
let targetSource;
|
|
try {
|
|
targetSource = fsImpl.readFileSync(path.join(distDir, targetFileName), "utf8");
|
|
} catch {
|
|
return formatRuntimeAliasSource(targetFileName);
|
|
}
|
|
return formatRuntimeAliasSource(
|
|
targetFileName,
|
|
RUNTIME_CHUNK_DEFAULT_EXPORT_PATTERN.test(targetSource),
|
|
);
|
|
}
|
|
|
|
function writeRuntimeDependencyOwnership(
|
|
rootDir: string,
|
|
ownership: RuntimeDependencyOwnership | null,
|
|
fsImpl: typeof fs,
|
|
) {
|
|
if (ownership) {
|
|
fsImpl.writeFileSync(
|
|
path.join(rootDir, RUNTIME_DEPENDENCY_OWNERSHIP_RELATIVE_PATH),
|
|
`${JSON.stringify({ chunks: Object.fromEntries(Object.entries(ownership.chunks).toSorted(([a], [b]) => a.localeCompare(b))) })}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes stable aliases for current hashed runtime chunks.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function writeStableRootRuntimeAliases(params: RuntimeFsParams = {}) {
|
|
const rootDir = params.rootDir ?? ROOT;
|
|
const distDir = path.join(rootDir, "dist");
|
|
const fsImpl = params.fs ?? fs;
|
|
// Alias rewrites delete files under dist; fail closed on a symlinked root
|
|
// so a stale alias removal cannot land inside the link target.
|
|
assertRealOutputRoot(distDir, { fs: fsImpl });
|
|
const { candidatesByAlias, pluginInstallCandidates } = collectStableRootRuntimeAliasCandidates(
|
|
distDir,
|
|
fsImpl,
|
|
);
|
|
|
|
const ownership = readRuntimeDependencyOwnership(rootDir, fsImpl);
|
|
using parser = createNativeTypeScriptParser({ cwd: rootDir });
|
|
for (const [aliasFileName, candidates] of candidatesByAlias) {
|
|
const aliasPath = path.join(distDir, aliasFileName);
|
|
const candidate = resolveStableRootRuntimeAliasCandidate(
|
|
aliasFileName,
|
|
candidates,
|
|
pluginInstallCandidates,
|
|
);
|
|
if (!candidate) {
|
|
fsImpl.rmSync?.(aliasPath, { force: true });
|
|
if (ownership) {
|
|
delete ownership.chunks[aliasFileName];
|
|
}
|
|
continue;
|
|
}
|
|
const source =
|
|
aliasFileName === "io.runtime.js"
|
|
? buildUpdateConfigRuntimeAlias(
|
|
candidate,
|
|
parser.parseSourceFile(
|
|
path.join(distDir, candidate),
|
|
fsImpl.readFileSync(path.join(distDir, candidate), "utf8"),
|
|
),
|
|
)
|
|
: buildRuntimeAliasSource(candidate, distDir, fsImpl);
|
|
const owner = ownership?.chunks[candidate];
|
|
if (ownership && owner) {
|
|
const targetSource = fsImpl.readFileSync(path.join(distDir, candidate));
|
|
if (createHash("sha256").update(targetSource).digest("hex") !== owner.sha256) {
|
|
throw new Error(
|
|
`runtime dependency ownership no longer matches ${candidate}; rebuild dist`,
|
|
);
|
|
}
|
|
ownership.chunks[aliasFileName] = {
|
|
extensions: owner.extensions,
|
|
sha256: createHash("sha256").update(source).digest("hex"),
|
|
};
|
|
} else if (ownership) {
|
|
delete ownership.chunks[aliasFileName];
|
|
}
|
|
writeTextFileIfChanged(aliasPath, source);
|
|
}
|
|
writeRuntimeDependencyOwnership(rootDir, ownership, fsImpl);
|
|
}
|
|
|
|
/**
|
|
* Rewrites hashed runtime imports to stable aliases so live updates survive swaps.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function rewriteRootRuntimeImportsToStableAliases(params: RuntimeFsParams = {}) {
|
|
const rootDir = params.rootDir ?? ROOT;
|
|
const distDir = path.join(rootDir, "dist");
|
|
const fsImpl = params.fs ?? fs;
|
|
const { entries, candidatesByAlias, pluginInstallCandidates } =
|
|
collectStableRootRuntimeAliasCandidates(distDir, fsImpl);
|
|
const runtimeAliasFiles = new Map<string, string>();
|
|
for (const [aliasFileName, candidates] of candidatesByAlias) {
|
|
const candidate = resolveStableRootRuntimeAliasCandidate(
|
|
aliasFileName,
|
|
candidates,
|
|
pluginInstallCandidates,
|
|
);
|
|
if (candidate) {
|
|
if (ROOT_RUNTIME_STABLE_IMPORT_SKIP_ALIASES.has(aliasFileName)) {
|
|
continue;
|
|
}
|
|
runtimeAliasFiles.set(candidate, aliasFileName);
|
|
}
|
|
}
|
|
if (runtimeAliasFiles.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const ownership = readRuntimeDependencyOwnership(rootDir, fsImpl);
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !/\.m?js$/u.test(entry.name)) {
|
|
continue;
|
|
}
|
|
if (ROOT_STABLE_RUNTIME_ALIAS_PATTERN.test(entry.name)) {
|
|
continue;
|
|
}
|
|
const filePath = path.join(distDir, entry.name);
|
|
let source;
|
|
try {
|
|
source = fsImpl.readFileSync(filePath, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
const rewritten = source.replace(
|
|
ROOT_RUNTIME_IMPORT_SPECIFIER_PATTERN,
|
|
(specifier: string, quote: string, fileName: string) => {
|
|
const aliasFileName = runtimeAliasFiles.get(fileName);
|
|
return aliasFileName ? `${quote}./${aliasFileName}${quote}` : specifier;
|
|
},
|
|
);
|
|
if (rewritten !== source) {
|
|
const owner = ownership?.chunks[entry.name];
|
|
// Carry the producer's proof through this exact transformation; never
|
|
// rehash unknown or independently modified build outputs.
|
|
if (owner) {
|
|
if (createHash("sha256").update(source).digest("hex") !== owner.sha256) {
|
|
throw new Error(
|
|
`runtime dependency ownership no longer matches ${entry.name}; rebuild dist`,
|
|
);
|
|
}
|
|
owner.sha256 = createHash("sha256").update(rewritten).digest("hex");
|
|
}
|
|
writeTextFileIfChanged(filePath, rewritten);
|
|
}
|
|
}
|
|
writeRuntimeDependencyOwnership(rootDir, ownership, fsImpl);
|
|
}
|
|
|
|
/**
|
|
* Writes compatibility aliases for shipped hashed runtime chunk names.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function writeLegacyRootRuntimeCompatAliases(params: RuntimeFsParams = {}) {
|
|
const rootDir = params.rootDir ?? ROOT;
|
|
const distDir = path.join(rootDir, "dist");
|
|
const fsImpl = params.fs ?? fs;
|
|
for (const [legacyFileName, aliasFileName] of LEGACY_ROOT_RUNTIME_COMPAT_ALIASES) {
|
|
const legacyPath = path.join(distDir, legacyFileName);
|
|
if (fsImpl.existsSync(legacyPath)) {
|
|
continue;
|
|
}
|
|
if (!fsImpl.existsSync(path.join(distDir, aliasFileName))) {
|
|
continue;
|
|
}
|
|
writeTextFileIfChanged(legacyPath, buildRuntimeAliasSource(aliasFileName, distDir, fsImpl));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes small compatibility chunks for old CLI exit imports.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function writeLegacyCliExitCompatChunks(params: { rootDir?: string } = {}) {
|
|
const rootDir = params.rootDir ?? ROOT;
|
|
for (const { dest, contents } of LEGACY_CLI_EXIT_COMPAT_CHUNKS) {
|
|
writeTextFileIfChanged(path.join(rootDir, dest), contents);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs every runtime postbuild phase after the main dist build.
|
|
*/
|
|
export function runRuntimePostBuild(params: RuntimePostBuildParams = {}) {
|
|
const rootDir = path.resolve(params.rootDir ?? params.cwd ?? params.repoRoot ?? ROOT);
|
|
const fsImpl = params.fs ?? fs;
|
|
const phaseParams = { ...params, cwd: rootDir, repoRoot: rootDir, rootDir };
|
|
// Postbuild phases share both roots. Validate the whole mutation set before
|
|
// any phase runs so a later unsafe root cannot leave earlier output changed.
|
|
assertRealOutputRoot(path.join(rootDir, "dist"), { fs: fsImpl });
|
|
assertRealOutputRoot(path.join(rootDir, "dist-runtime"), { fs: fsImpl });
|
|
const timingsSetting = params.timings ?? process.env.OPENCLAW_RUNTIME_POSTBUILD_TIMINGS;
|
|
const timingsEnabled = timingsSetting !== "0" && timingsSetting !== false;
|
|
// Per-phase lines are debug detail; default output is one summary line so a
|
|
// routine rebuild does not print nine near-identical timing rows.
|
|
const perPhaseEnabled = timingsSetting === "verbose" || timingsSetting === true;
|
|
const phaseTimings: Array<{ label: string; durationMs: number }> = [];
|
|
const runPhase = <T,>(label: string, action: () => T): T => {
|
|
const startedAt = performance.now();
|
|
try {
|
|
return action();
|
|
} finally {
|
|
const durationMs = Math.round(performance.now() - startedAt);
|
|
phaseTimings.push({ label, durationMs });
|
|
if (perPhaseEnabled) {
|
|
console.error(`runtime-postbuild: ${label} completed in ${durationMs}ms`);
|
|
}
|
|
}
|
|
};
|
|
const logSummary = () => {
|
|
if (!timingsEnabled || perPhaseEnabled || phaseTimings.length === 0) {
|
|
return;
|
|
}
|
|
const totalMs = phaseTimings.reduce((sum, phase) => sum + phase.durationMs, 0);
|
|
const slowest = phaseTimings.reduce((max, phase) =>
|
|
phase.durationMs > max.durationMs ? phase : max,
|
|
);
|
|
console.error(
|
|
`runtime-postbuild: ${phaseTimings.length} phases completed in ${totalMs}ms (slowest: ${slowest.label} ${slowest.durationMs}ms)`,
|
|
);
|
|
};
|
|
runPhase("bundled plugin metadata", () => copyBundledPluginMetadata(phaseParams));
|
|
runPhase("bundled hook metadata", () => copyHookMetadata(phaseParams));
|
|
runPhase("official channel catalog", () => writeOfficialChannelCatalog(phaseParams));
|
|
runPhase("export HTML assets", () => copyExportHtmlTemplates(phaseParams));
|
|
runPhase("private CLI diagnostic companions", () => {
|
|
for (const output of listCliDiagnosticCompanionOutputs(phaseParams)) {
|
|
writeTextFileIfChanged(
|
|
path.join(rootDir, output),
|
|
fsImpl.readFileSync(path.join(rootDir, "src/cli", path.basename(output)), "utf8"),
|
|
);
|
|
}
|
|
});
|
|
runPhase("bundled plugin runtime overlay", () => stageBundledPluginRuntime(phaseParams));
|
|
runPhase("static extension assets", () => {
|
|
if (!shouldCopyStaticExtensionAssets(phaseParams)) {
|
|
return;
|
|
}
|
|
const assetParams = {
|
|
...phaseParams,
|
|
assets: discoverStaticExtensionAssets(phaseParams),
|
|
};
|
|
copyStaticExtensionAssets(assetParams);
|
|
copyStaticExtensionAssetsToRuntimeOverlay(assetParams);
|
|
});
|
|
runPhase("stable root runtime imports", () =>
|
|
rewriteRootRuntimeImportsToStableAliases(phaseParams),
|
|
);
|
|
runPhase("stable root runtime aliases", () => writeStableRootRuntimeAliases(phaseParams));
|
|
runPhase("legacy root runtime compat aliases", () =>
|
|
writeLegacyRootRuntimeCompatAliases(phaseParams),
|
|
);
|
|
runPhase("legacy CLI exit compat chunks", () => writeLegacyCliExitCompatChunks(phaseParams));
|
|
runPhase("previous release update compat chunks", () =>
|
|
writeUpdateCompatibilityChunks({
|
|
distDir: path.join(rootDir, "dist"),
|
|
sourceDir: rootDir,
|
|
inventory: readUpdateCompatibilityInventory(UPDATE_COMPATIBILITY_INVENTORY),
|
|
}),
|
|
);
|
|
runPhase("built plugin control-plane loads", () =>
|
|
verifyBuiltPluginControlPlaneModules(phaseParams),
|
|
);
|
|
// Source runners launch directly after postbuild, without the full UI build's
|
|
// final metadata step. Publish identity only after the runtime is complete.
|
|
runPhase("build provenance", () => writeBuildInfo({ rootDir, env: params.env }));
|
|
logSummary();
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
await withDistArtifactOwnership(process.cwd(), async () => {
|
|
runRuntimePostBuild();
|
|
});
|
|
}
|