Files
openclaw/scripts/stage-bundled-plugin-runtime-deps.mjs
2026-05-01 11:15:21 +01:00

462 lines
15 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { pathToFileURL } from "node:url";
import {
createBundledRuntimeDependencyInstallArgs,
createBundledRuntimeDependencyInstallEnv,
runBundledRuntimeDependencyNpmInstall,
} from "./lib/bundled-runtime-deps-install.mjs";
import {
listBundledPluginRuntimeDirs,
resolveInstalledWorkspacePluginRoot,
stageInstalledRootRuntimeDeps,
} from "./lib/bundled-runtime-deps-materialize.mjs";
import {
readInstalledDependencyVersionFromRoot,
resolveInstalledDependencyRoot,
resolveInstalledRuntimeClosureFingerprint,
} from "./lib/bundled-runtime-deps-package-tree.mjs";
import {
pruneStagedRuntimeDependencyCargo,
resolveRuntimeDepPruneConfig,
} from "./lib/bundled-runtime-deps-prune.mjs";
import {
assertPathIsNotSymlink,
makePluginOwnedTempDir,
removeLegacyBundledRuntimeDepsSymlink,
removeOwnedTempPathBestEffort,
removePathIfExists,
removeStaleRuntimeDepsTempDirs,
replaceDirAtomically,
sanitizeTempPrefixSegment,
writeJsonAtomically,
writeRuntimeDepsTempOwner,
} from "./lib/bundled-runtime-deps-stage-state.mjs";
import {
createRuntimeDepsCheapFingerprint,
createRuntimeDepsFingerprint,
readRuntimeDepsStamp,
resolveLegacyRuntimeDepsStampPath,
resolveRuntimeDepsStampPath,
} from "./lib/bundled-runtime-deps-stamp.mjs";
import { resolveNpmRunner } from "./npm-runner.mjs";
const exactVersionSpecRe = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function hasRuntimeDeps(packageJson) {
return (
Object.keys(packageJson.dependencies ?? {}).length > 0 ||
Object.keys(packageJson.optionalDependencies ?? {}).length > 0
);
}
function shouldStageRuntimeDeps(packageJson) {
return packageJson.openclaw?.bundle?.stageRuntimeDependencies === true;
}
function sanitizeBundledManifestForRuntimeInstall(pluginDir) {
const manifestPath = path.join(pluginDir, "package.json");
const packageJson = readJson(manifestPath);
let changed = false;
if (packageJson.peerDependencies) {
delete packageJson.peerDependencies;
changed = true;
}
if (packageJson.peerDependenciesMeta) {
delete packageJson.peerDependenciesMeta;
changed = true;
}
if (packageJson.devDependencies) {
delete packageJson.devDependencies;
changed = true;
}
if (changed) {
writeJson(manifestPath, packageJson);
}
return packageJson;
}
function isSafeRuntimeDependencySpec(spec) {
if (typeof spec !== "string") {
return false;
}
const normalized = spec.trim();
if (normalized.length === 0) {
return false;
}
const lower = normalized.toLowerCase();
if (
lower.startsWith("file:") ||
lower.startsWith("link:") ||
lower.startsWith("workspace:") ||
lower.startsWith("git:") ||
lower.startsWith("git+") ||
lower.startsWith("ssh:") ||
lower.startsWith("http:") ||
lower.startsWith("https:")
) {
return false;
}
if (normalized.includes("://")) {
return false;
}
if (
normalized.startsWith("/") ||
normalized.startsWith("\\") ||
normalized.startsWith("../") ||
normalized.startsWith("..\\") ||
normalized.includes("/../") ||
normalized.includes("\\..\\")
) {
return false;
}
return true;
}
function assertSafeRuntimeDependencySpec(depName, spec) {
if (!isSafeRuntimeDependencySpec(spec)) {
throw new Error(`disallowed runtime dependency spec for ${depName}: ${spec}`);
}
}
function resolveInstalledPinnedDependencyVersion(params) {
const depRoot = resolveInstalledDependencyRoot({
depName: params.depName,
enforceSpec: true,
parentPackageRoot: params.parentPackageRoot,
rootNodeModulesDir: params.rootNodeModulesDir,
spec: params.spec,
});
if (depRoot === null) {
return null;
}
return readInstalledDependencyVersionFromRoot(depRoot);
}
function resolvePinnedRuntimeDependencyVersion(params) {
assertSafeRuntimeDependencySpec(params.depName, params.spec);
if (exactVersionSpecRe.test(params.spec)) {
return params.spec;
}
const installedVersion = resolveInstalledPinnedDependencyVersion(params);
if (typeof installedVersion === "string" && exactVersionSpecRe.test(installedVersion)) {
return installedVersion;
}
throw new Error(
`runtime dependency ${params.depName} must resolve to an exact installed version, got: ${params.spec}`,
);
}
function collectRuntimeDependencyGroups(packageJson) {
const readRuntimeGroup = (group) =>
Object.fromEntries(
Object.entries(group ?? {}).filter(
(entry) => typeof entry[0] === "string" && typeof entry[1] === "string",
),
);
return {
dependencies: readRuntimeGroup(packageJson.dependencies),
optionalDependencies: readRuntimeGroup(packageJson.optionalDependencies),
};
}
function resolvePinnedRuntimeDependencyGroup(group, params = {}) {
return Object.fromEntries(
Object.entries(group).map(([name, version]) => {
const pinnedVersion = resolvePinnedRuntimeDependencyVersion({
depName: name,
parentPackageRoot: params.directDependencyPackageRoot ?? null,
rootNodeModulesDir: params.rootNodeModulesDir ?? path.join(process.cwd(), "node_modules"),
spec: version,
});
return [name, pinnedVersion];
}),
);
}
function resolvePinnedRuntimeDependencyGroups(packageJson, params = {}) {
const runtimeGroups = collectRuntimeDependencyGroups(packageJson);
return {
dependencies: resolvePinnedRuntimeDependencyGroup(runtimeGroups.dependencies, params),
optionalDependencies: resolvePinnedRuntimeDependencyGroup(
runtimeGroups.optionalDependencies,
params,
),
};
}
export function collectRuntimeDependencyInstallManifest(packageJson, params = {}) {
const pinnedGroups = resolvePinnedRuntimeDependencyGroups(packageJson, params);
return createRuntimeInstallManifest(params.pluginId ?? "runtime-deps", pinnedGroups);
}
export function collectRuntimeDependencyInstallSpecs(packageJson, params = {}) {
const manifest = collectRuntimeDependencyInstallManifest(packageJson, params);
const buildSpecs = (group) =>
Object.entries(group ?? {}).map(([name, version]) => `${name}@${String(version)}`);
return {
dependencies: buildSpecs(manifest.dependencies),
optionalDependencies: buildSpecs(manifest.optionalDependencies),
};
}
function createRuntimeInstallManifest(pluginId, pinnedGroups) {
const manifest = {
name: `openclaw-runtime-deps-${sanitizeTempPrefixSegment(pluginId)}`,
private: true,
version: "0.0.0",
};
if (Object.keys(pinnedGroups.dependencies).length > 0) {
manifest.dependencies = pinnedGroups.dependencies;
}
if (Object.keys(pinnedGroups.optionalDependencies).length > 0) {
manifest.optionalDependencies = pinnedGroups.optionalDependencies;
}
return manifest;
}
function runNpmInstall(params) {
return runBundledRuntimeDependencyNpmInstall({
cwd: params.cwd,
npmRunner: params.npmRunner,
env: createBundledRuntimeDependencyInstallEnv(params.npmRunner.env ?? process.env, {
ci: true,
quiet: true,
}),
spawnSyncImpl: params.spawnSyncImpl,
stdio: ["ignore", "pipe", "pipe"],
timeout: params.timeoutMs ?? 5 * 60 * 1000,
});
}
function installPluginRuntimeDepsWithRetries(params) {
const { attempts = 3 } = params;
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
params.install({ ...params.installParams, attempt });
return;
} catch (error) {
lastError = error;
if (attempt === attempts) {
break;
}
}
}
throw lastError;
}
function createRootRuntimeStagingError(params) {
const runtimeDependencyNames = [
...Object.keys(params.packageJson.dependencies ?? {}),
...Object.keys(params.packageJson.optionalDependencies ?? {}),
].toSorted((left, right) => left.localeCompare(right));
const dependencyLabel =
runtimeDependencyNames.length > 0 ? runtimeDependencyNames.join(", ") : "<none>";
const causeMessage =
params.cause instanceof Error && typeof params.cause.message === "string"
? ` Cause: ${params.cause.message}`
: "";
return new Error(
`failed to stage bundled runtime deps for ${params.pluginId}: ` +
`runtime dependency closure must resolve from the installed root workspace graph. ` +
`Could not materialize: ${dependencyLabel}. ` +
"Run `pnpm install` and rebuild from a trusted workspace checkout, or provide a hardened fallback installer." +
causeMessage,
);
}
function installPluginRuntimeDeps(params) {
const {
directDependencyPackageRoot = null,
cheapFingerprint,
fingerprint,
packageJson,
pluginDir,
pluginId,
pruneConfig,
repoRoot,
stampPath,
} = params;
const nodeModulesDir = path.join(pluginDir, "node_modules");
const tempInstallDir = makePluginOwnedTempDir(pluginDir, "install");
const pinnedGroups = resolvePinnedRuntimeDependencyGroups(packageJson, {
directDependencyPackageRoot,
rootNodeModulesDir: path.join(repoRoot, "node_modules"),
});
const requiredDependencyCount = Object.keys(pinnedGroups.dependencies).length;
try {
writeJson(
path.join(tempInstallDir, "package.json"),
createRuntimeInstallManifest(pluginId, pinnedGroups),
);
if (requiredDependencyCount > 0 || Object.keys(pinnedGroups.optionalDependencies).length > 0) {
runNpmInstall({
cwd: tempInstallDir,
npmRunner: resolveNpmRunner({
npmArgs: createBundledRuntimeDependencyInstallArgs([], {
noAudit: true,
noFund: true,
silent: true,
}),
}),
});
}
const stagedNodeModulesDir = path.join(tempInstallDir, "node_modules");
if (requiredDependencyCount > 0 && !fs.existsSync(stagedNodeModulesDir)) {
throw new Error(
`failed to stage bundled runtime deps for ${pluginId}: explicit npm install produced no node_modules directory`,
);
}
if (fs.existsSync(stagedNodeModulesDir)) {
pruneStagedRuntimeDependencyCargo(stagedNodeModulesDir, pruneConfig);
removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot);
replaceDirAtomically(nodeModulesDir, stagedNodeModulesDir);
} else {
removeLegacyBundledRuntimeDepsSymlink(nodeModulesDir, repoRoot);
assertPathIsNotSymlink(nodeModulesDir, "remove runtime deps");
removePathIfExists(nodeModulesDir);
}
writeJsonAtomically(stampPath, {
cheapFingerprint,
fingerprint,
generatedAt: new Date().toISOString(),
});
} finally {
removeOwnedTempPathBestEffort(tempInstallDir);
}
}
export function stageBundledPluginRuntimeDeps(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const installPluginRuntimeDepsImpl =
params.installPluginRuntimeDepsImpl ?? installPluginRuntimeDeps;
const installAttempts = params.installAttempts ?? 3;
const pruneConfig = resolveRuntimeDepPruneConfig(params);
const timingsEnabled =
params.timings ?? process.env.OPENCLAW_RUNTIME_DEPS_STAGING_TIMINGS === "1";
const runPluginPhase = (pluginId, label, action) => {
const startedAt = performance.now();
try {
return action();
} finally {
if (timingsEnabled) {
const durationMs = Math.round(performance.now() - startedAt);
console.error(
`stage-bundled-plugin-runtime-deps: ${pluginId} ${label} completed in ${durationMs}ms`,
);
}
}
};
for (const pluginDir of listBundledPluginRuntimeDirs(repoRoot)) {
const pluginId = path.basename(pluginDir);
const sourcePluginRoot = resolveInstalledWorkspacePluginRoot(repoRoot, pluginId);
const directDependencyPackageRoot = fs.existsSync(path.join(sourcePluginRoot, "package.json"))
? sourcePluginRoot
: null;
const packageJson = runPluginPhase(pluginId, "sanitize manifest", () =>
sanitizeBundledManifestForRuntimeInstall(pluginDir),
);
const nodeModulesDir = path.join(pluginDir, "node_modules");
const stampPath = resolveRuntimeDepsStampPath(repoRoot, pluginId);
const legacyStampPath = resolveLegacyRuntimeDepsStampPath(pluginDir);
runPluginPhase(pluginId, "cleanup stale runtime dirs", () => {
removePathIfExists(legacyStampPath);
removeStaleRuntimeDepsTempDirs(pluginDir);
});
if (!hasRuntimeDeps(packageJson) || !shouldStageRuntimeDeps(packageJson)) {
runPluginPhase(pluginId, "remove unstaged runtime deps", () => {
removePathIfExists(nodeModulesDir);
removePathIfExists(stampPath);
});
continue;
}
const cheapFingerprint = runPluginPhase(pluginId, "cheap fingerprint", () =>
createRuntimeDepsCheapFingerprint(packageJson, pruneConfig, {
repoRoot,
}),
);
const stamp = readRuntimeDepsStamp(stampPath);
const rootInstalledRuntimeFingerprint = runPluginPhase(
pluginId,
"installed runtime fingerprint",
() =>
resolveInstalledRuntimeClosureFingerprint({
directDependencyPackageRoot,
packageJson,
rootNodeModulesDir: path.join(repoRoot, "node_modules"),
}),
);
const fingerprint = createRuntimeDepsFingerprint(packageJson, pruneConfig, {
repoRoot,
rootInstalledRuntimeFingerprint,
});
if (fs.existsSync(nodeModulesDir) && stamp?.fingerprint === fingerprint) {
runPluginPhase(pluginId, "reuse staged runtime deps", () => {});
continue;
}
if (
runPluginPhase(pluginId, "stage installed root runtime deps", () =>
stageInstalledRootRuntimeDeps({
directDependencyPackageRoot,
fingerprint,
cheapFingerprint,
packageJson,
pluginDir,
pruneConfig,
repoRoot,
stampPath,
}),
)
) {
continue;
}
try {
runPluginPhase(pluginId, "fallback install runtime deps", () =>
installPluginRuntimeDepsWithRetries({
attempts: installAttempts,
install: installPluginRuntimeDepsImpl,
installParams: {
directDependencyPackageRoot,
fingerprint,
cheapFingerprint,
packageJson,
pluginDir,
pluginId,
pruneConfig,
repoRoot,
stampPath,
},
}),
);
} catch (error) {
throw createRootRuntimeStagingError({ packageJson, pluginId, cause: error });
}
}
}
export const __testing = {
removeStaleRuntimeDepsTempDirs,
replaceDirAtomically,
runNpmInstall,
writeRuntimeDepsTempOwner,
};
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
stageBundledPluginRuntimeDeps();
}