mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* fix(update): keep Bun-only installs on Bun through update, repair, and install Bun-only installs (no Node anywhere, CLI launched as `<bun> openclaw.mjs`, Gateway service pinned to that Bun) could not update: maintenance children were spawned with a bare `node`, and the package preinstall rejected every Bun lifecycle without a persistent Node. - resolveNodeRunner() now returns the running Bun executable, making it the single owner for OpenClaw CLI children (finalize, repair maintenance, and legacy compat chunks now use it). - Service-runtime decisions stay Node-owned: under Bun there is no Bun-as-Node fallback, and the running Bun is not judged by Node engines. - The preinstall skips Bun's temporary lifecycle node shim and, only when no persistent Node exists, accepts an explicit OPENCLAW_PACKAGE_BUN_LAUNCHER (Bun 1.4+). The updater sets it when it runs under Bun. * fix(update): stage Bun and pnpm updates before reading admission-gated options Candidate-owned admission stages the package with admission-gated accessors. The Bun/pnpm staging preflight spread those params into the package-manager probe, which evaluated requirePackageReplacement before admission and failed every candidate-driven Bun- or pnpm-managed update with "Staged update has not been admitted for activation." Pass only the probe's fields instead. Also align the Bun-only docs with the refusals observed from published 2026.9.6 updaters. * fix(update): keep split-root Node service preflight under Bun Retain the recorded Node runner for an owned split-root Gateway when the updater runs under Bun, so target engine compatibility is checked before service refresh. Node-driven updates keep their current runner; Bun services keep the Bun runtime rules. The caller regression failed before the fix and passed afterward. After merging main, all 155 focused tests and both requested typechecks passed. Caller test cost: 4 tests in 90.68s wall, including worker preparation; the new regression itself took 16ms. Autoreview: scoped-clean at P2. * docs(install): run the Bun-only service install through Bun * fix(install): exempt only Bun's temporary node shim in preinstall Restrict the exemption to bun-node-<hex> directories targeting the running Bun. Persistent node aliases to that Bun now fail closed before later valid Node, with or without the launcher marker. Addresses the P1 finding on #159431. Validation: the two new launcher-contract cases failed before the fix (69 passed, 2 failed; 16.22s wall). After the fix, all 71 passed (5.32s wall; 306ms test time). Scripts typecheck, changed-file formatting, diff whitespace check, and scoped P0-P2 autoreview passed. * fix(install): recognize the Bun fork's per-user node shim directories Address the ClawSweeper P1 on #159431 by recognizing the stock and pinned fork lifecycle shim formats, including debug names and 16-hex private fallback suffixes. Keep the running-Bun realpath check and persistent-alias rejection unchanged. Validation: the focused preinstall suite failed before the fix on all four fork acceptance cases (76 passed, 4 failed; 3.45s wall), then passed all 80 tests after the fix (5.02s wall; 237ms test time). pnpm tsgo:scripts, targeted oxfmt check, and git diff --check passed. Independent autoreview was scoped-clean through P2. * test(security): keep the no-output policy timeout test off the real deadline The test fires its mocked 1 s no-output timer by hand once the forked process tree has written its pid file, but the 10 s overall deadline still ran on a real timer. On a loaded runner, starting the process tree can take the whole deadline, so the overall timeout won (CI run 36401631363 failed at exactly 10 s with "policy command timed out"). Intercept the overall timer with a non-firing placeholder so only the path under test can fire. A local probe with a forced 10.5 s startup stall fails with the CI signature before and passes after. * test(process): keep forked no-output fixtures silent after publishing their pid The forked descendant in writeForkingNoOutputScript printed "ready" to stderr after writing its pid file. Tests fire the mocked no-output deadline as soon as that file exists; the runner defers the decision by one 0 ms timer, and output arriving in that window refreshes the idle timer, which clears the pending decision (the timer has been reused since912b21b9). The run then settled only on the 10 s overall timeout, failing "kills forked policy command children on no-output timeout" in CI run 36401631363. Drop the post-pid output; the tests fire the deadline by hand, so nothing needs it. Revert80fda958, which masked the overall timeout on the wrong diagnosis and turned the race into a 120 s hang (CI run 36407894516). A forced-ordering probe (output 100 ms after the pid, decision deferred 300 ms) fails with the CI signature on the old fixture and passes on the new one. install-policy.test.ts and secrets/resolve.test.ts both pass.
613 lines
24 KiB
TypeScript
613 lines
24 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,
|
|
UPDATE_COMPATIBILITY_INVENTORY_FILE,
|
|
writeUpdateCompatibilityChunks,
|
|
} from "./lib/update-compat-chunks.mts";
|
|
import { buildUpdateConfigRuntimeAlias } from "./lib/update-config-runtime-compat.mts";
|
|
import { 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",
|
|
"scanFileInstallSource",
|
|
"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));
|
|
}
|
|
|
|
/**
|
|
* 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),
|
|
...listStableRootRuntimeAliasOutputs(params),
|
|
...listLegacyRootRuntimeCompatOutputs(params),
|
|
...LEGACY_CLI_EXIT_COMPAT_CHUNKS.map(({ dest }) => dest),
|
|
`dist/${UPDATE_COMPATIBILITY_INVENTORY_FILE}`,
|
|
...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("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();
|
|
});
|
|
}
|