refactor(plugins): split runtime deps materialization

This commit is contained in:
Peter Steinberger
2026-04-29 20:57:25 +01:00
parent 585c2bdba3
commit 9ae7db5562
3 changed files with 181 additions and 158 deletions

View File

@@ -0,0 +1,152 @@
import fs from "node:fs";
import path from "node:path";
import { readRuntimeDepsJsonObject, type JsonObject } from "./bundled-runtime-deps-json.js";
import {
collectPackageRuntimeDeps,
normalizeRuntimeDepSpecs,
parseInstallableRuntimeDep,
parseInstallableRuntimeDepSpec,
resolveDependencySentinelAbsolutePath,
} from "./bundled-runtime-deps-specs.js";
import { satisfies } from "./semver.runtime.js";
const LEGACY_RETAINED_RUNTIME_DEPS_MANIFEST = ".openclaw-runtime-deps.json";
export function readGeneratedInstallManifestSpecs(installRoot: string): string[] | null {
const parsed = readRuntimeDepsJsonObject(path.join(installRoot, "package.json"));
if (parsed?.name !== "openclaw-runtime-deps-install") {
return null;
}
const dependencies = parsed.dependencies;
if (!dependencies || typeof dependencies !== "object" || Array.isArray(dependencies)) {
return [];
}
const specs: string[] = [];
for (const [name, version] of Object.entries(dependencies as Record<string, unknown>)) {
const dep = parseInstallableRuntimeDep(name, version);
if (dep) {
specs.push(`${dep.name}@${dep.version}`);
}
}
return normalizeRuntimeDepSpecs(specs);
}
function readPackageRuntimeDepSpecs(packageRoot: string): string[] | null {
const parsed = readRuntimeDepsJsonObject(path.join(packageRoot, "package.json"));
if (!parsed || parsed.name === "openclaw-runtime-deps-install") {
return null;
}
const specs = Object.entries(collectPackageRuntimeDeps(parsed))
.map(([name, rawVersion]) => parseInstallableRuntimeDep(name, rawVersion))
.filter((dep): dep is { name: string; version: string } => Boolean(dep))
.map((dep) => `${dep.name}@${dep.version}`);
return normalizeRuntimeDepSpecs(specs);
}
function sameRuntimeDepSpecs(left: readonly string[], right: readonly string[]): boolean {
const normalizedLeft = normalizeRuntimeDepSpecs(left);
const normalizedRight = normalizeRuntimeDepSpecs(right);
return (
normalizedLeft.length === normalizedRight.length &&
normalizedLeft.every((entry, index) => entry === normalizedRight[index])
);
}
function readInstalledRuntimeDepVersion(rootDir: string, depName: string): string | null {
try {
const parsed = JSON.parse(
fs.readFileSync(resolveDependencySentinelAbsolutePath(rootDir, depName), "utf8"),
) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const version = (parsed as JsonObject).version;
return typeof version === "string" && version.trim() ? version.trim() : null;
} catch {
return null;
}
}
export function isRuntimeDepSatisfied(
rootDir: string,
dep: { name: string; version: string },
): boolean {
const installedVersion = readInstalledRuntimeDepVersion(rootDir, dep.name);
return Boolean(installedVersion && satisfies(installedVersion, dep.version));
}
export function isRuntimeDepSatisfiedInAnyRoot(
dep: { name: string; version: string },
roots: readonly string[],
): boolean {
return roots.some((root) => isRuntimeDepSatisfied(root, dep));
}
function hasSatisfiedInstallSpecPackages(rootDir: string, specs: readonly string[]): boolean {
return specs
.map(parseInstallableRuntimeDepSpec)
.every((dep) => isRuntimeDepSatisfied(rootDir, dep));
}
export function isRuntimeDepsPlanMaterialized(
installRoot: string,
installSpecs: readonly string[],
): boolean {
const generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot);
const packageManifestSpecs =
generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot);
return (
((generatedManifestSpecs !== null &&
sameRuntimeDepSpecs(generatedManifestSpecs, installSpecs)) ||
(packageManifestSpecs !== null && sameRuntimeDepSpecs(packageManifestSpecs, installSpecs))) &&
hasSatisfiedInstallSpecPackages(installRoot, installSpecs)
);
}
export function assertBundledRuntimeDepsInstalled(rootDir: string, specs: readonly string[]): void {
const missingSpecs = specs.filter((spec) => {
const dep = parseInstallableRuntimeDepSpec(spec);
return !isRuntimeDepSatisfied(rootDir, dep);
});
if (missingSpecs.length === 0) {
return;
}
throw new Error(
`package manager install did not place bundled runtime deps in ${rootDir}: ${missingSpecs.join(", ")}`,
);
}
export function removeLegacyRuntimeDepsManifest(installRoot: string): void {
fs.rmSync(path.join(installRoot, LEGACY_RETAINED_RUNTIME_DEPS_MANIFEST), {
force: true,
});
}
function createNpmInstallExecutionManifest(installSpecs: readonly string[]): JsonObject {
const dependencies: Record<string, string> = {};
for (const spec of installSpecs) {
const dep = parseInstallableRuntimeDepSpec(spec);
dependencies[dep.name] = dep.version;
}
const sortedDependencies = Object.fromEntries(
Object.entries(dependencies).toSorted(([left], [right]) => left.localeCompare(right)),
);
return {
name: "openclaw-runtime-deps-install",
private: true,
...(Object.keys(sortedDependencies).length > 0 ? { dependencies: sortedDependencies } : {}),
};
}
export function ensureNpmInstallExecutionManifest(
installExecutionRoot: string,
installSpecs: readonly string[] = [],
): void {
const manifestPath = path.join(installExecutionRoot, "package.json");
const manifest = createNpmInstallExecutionManifest(installSpecs);
const nextContents = `${JSON.stringify(manifest, null, 2)}\n`;
if (fs.existsSync(manifestPath) && fs.readFileSync(manifestPath, "utf8") === nextContents) {
return;
}
fs.writeFileSync(manifestPath, nextContents, "utf8");
}

View File

@@ -86,6 +86,22 @@ export function parseInstallableRuntimeDepSpec(spec: string): { name: string; ve
return parsed;
}
export function normalizeRuntimeDepSpecs(specs: readonly string[]): string[] {
specs.forEach((spec) => {
parseInstallableRuntimeDepSpec(spec);
});
return [...new Set(specs)].toSorted((left, right) => left.localeCompare(right));
}
export function collectPackageRuntimeDeps(
packageJson: Record<string, unknown>,
): Record<string, unknown> {
return {
...(packageJson.dependencies as Record<string, unknown> | undefined),
...(packageJson.optionalDependencies as Record<string, unknown> | undefined),
};
}
function dependencySentinelPath(depName: string): string {
const normalizedDepName = normalizeInstallableRuntimeDepName(depName);
if (!normalizedDepName) {

View File

@@ -20,6 +20,13 @@ import {
withBundledRuntimeDepsFilesystemLock,
withBundledRuntimeDepsFilesystemLockAsync,
} from "./bundled-runtime-deps-lock.js";
import {
assertBundledRuntimeDepsInstalled,
ensureNpmInstallExecutionManifest,
isRuntimeDepSatisfiedInAnyRoot,
isRuntimeDepsPlanMaterialized,
removeLegacyRuntimeDepsManifest,
} from "./bundled-runtime-deps-materialization.js";
import {
createBundledRuntimeDepsInstallArgs,
createBundledRuntimeDepsInstallEnv,
@@ -31,10 +38,10 @@ import {
type BundledRuntimeDepsPackageManagerRunner,
} from "./bundled-runtime-deps-package-manager.js";
import {
collectPackageRuntimeDeps,
normalizeInstallableRuntimeDepName,
normalizeRuntimeDepSpecs,
parseInstallableRuntimeDep,
parseInstallableRuntimeDepSpec,
resolveDependencySentinelAbsolutePath,
type RuntimeDepEntry,
} from "./bundled-runtime-deps-specs.js";
import {
@@ -42,7 +49,6 @@ import {
type NormalizedPluginsConfig,
type NormalizePluginId,
} from "./config-normalization-shared.js";
import { satisfies } from "./semver.runtime.js";
export {
createBundledRuntimeDepsInstallArgs,
@@ -94,7 +100,6 @@ export type BundledRuntimeDepsPlan = {
installRootPlan: BundledRuntimeDepsInstallRootPlan;
};
const LEGACY_RETAINED_RUNTIME_DEPS_MANIFEST = ".openclaw-runtime-deps.json";
// Packaged bundled plugins (Docker image, npm global install) keep their
// `package.json` next to their entry point; running `npm install <specs>` with
// `cwd: pluginRoot` would make npm resolve the plugin's own `workspace:*`
@@ -130,13 +135,6 @@ async function withBundledRuntimeDepsInstallRootLockAsync<T>(
);
}
function collectRuntimeDeps(packageJson: JsonObject): Record<string, unknown> {
return {
...(packageJson.dependencies as Record<string, unknown> | undefined),
...(packageJson.optionalDependencies as Record<string, unknown> | undefined),
};
}
function collectDeclaredMirroredRootRuntimeDepNames(packageJson: JsonObject): string[] {
const openclaw = packageJson.openclaw;
const bundle =
@@ -179,7 +177,7 @@ function collectMirroredPackageRuntimeDeps(packageRoot: string | null): {
if (!packageJson) {
return [];
}
const runtimeDeps = collectRuntimeDeps(packageJson);
const runtimeDeps = collectPackageRuntimeDeps(packageJson);
const deps: RuntimeDepEntry[] = [];
for (const name of collectDeclaredMirroredRootRuntimeDepNames(packageJson)) {
const dep = parseInstallableRuntimeDep(name, runtimeDeps[name]);
@@ -295,107 +293,6 @@ function readPackageVersion(packageRoot: string): string {
return version || "unknown";
}
function normalizeRuntimeDepSpecs(specs: readonly string[]): string[] {
specs.forEach((spec) => {
parseInstallableRuntimeDepSpec(spec);
});
return [...new Set(specs)].toSorted((left, right) => left.localeCompare(right));
}
function readGeneratedInstallManifestSpecs(installRoot: string): string[] | null {
const parsed = readRuntimeDepsJsonObject(path.join(installRoot, "package.json"));
if (parsed?.name !== "openclaw-runtime-deps-install") {
return null;
}
const dependencies = parsed.dependencies;
if (!dependencies || typeof dependencies !== "object" || Array.isArray(dependencies)) {
return [];
}
const specs: string[] = [];
for (const [name, version] of Object.entries(dependencies as Record<string, unknown>)) {
const dep = parseInstallableRuntimeDep(name, version);
if (dep) {
specs.push(`${dep.name}@${dep.version}`);
}
}
return normalizeRuntimeDepSpecs(specs);
}
function readPackageRuntimeDepSpecs(packageRoot: string): string[] | null {
const parsed = readRuntimeDepsJsonObject(path.join(packageRoot, "package.json"));
if (!parsed || parsed.name === "openclaw-runtime-deps-install") {
return null;
}
const specs = Object.entries(collectRuntimeDeps(parsed))
.map(([name, rawVersion]) => parseInstallableRuntimeDep(name, rawVersion))
.filter((dep): dep is { name: string; version: string } => Boolean(dep))
.map((dep) => `${dep.name}@${dep.version}`);
return normalizeRuntimeDepSpecs(specs);
}
function sameRuntimeDepSpecs(left: readonly string[], right: readonly string[]): boolean {
const normalizedLeft = normalizeRuntimeDepSpecs(left);
const normalizedRight = normalizeRuntimeDepSpecs(right);
return (
normalizedLeft.length === normalizedRight.length &&
normalizedLeft.every((entry, index) => entry === normalizedRight[index])
);
}
function readInstalledRuntimeDepVersion(rootDir: string, depName: string): string | null {
try {
const parsed = JSON.parse(
fs.readFileSync(resolveDependencySentinelAbsolutePath(rootDir, depName), "utf8"),
) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const version = (parsed as JsonObject).version;
return typeof version === "string" && version.trim() ? version.trim() : null;
} catch {
return null;
}
}
function isRuntimeDepSatisfied(rootDir: string, dep: { name: string; version: string }): boolean {
const installedVersion = readInstalledRuntimeDepVersion(rootDir, dep.name);
return Boolean(installedVersion && satisfies(installedVersion, dep.version));
}
function isRuntimeDepSatisfiedInAnyRoot(
dep: { name: string; version: string },
roots: readonly string[],
): boolean {
return roots.some((root) => isRuntimeDepSatisfied(root, dep));
}
function hasSatisfiedInstallSpecPackages(rootDir: string, specs: readonly string[]): boolean {
return specs
.map(parseInstallableRuntimeDepSpec)
.every((dep) => isRuntimeDepSatisfied(rootDir, dep));
}
function isRuntimeDepsPlanMaterialized(
installRoot: string,
installSpecs: readonly string[],
): boolean {
const generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot);
const packageManifestSpecs =
generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot);
return (
((generatedManifestSpecs !== null &&
sameRuntimeDepSpecs(generatedManifestSpecs, installSpecs)) ||
(packageManifestSpecs !== null && sameRuntimeDepSpecs(packageManifestSpecs, installSpecs))) &&
hasSatisfiedInstallSpecPackages(installRoot, installSpecs)
);
}
function removeLegacyRuntimeDepsManifest(installRoot: string): void {
fs.rmSync(path.join(installRoot, LEGACY_RETAINED_RUNTIME_DEPS_MANIFEST), {
force: true,
});
}
export function isWritableDirectory(dir: string): boolean {
let probeDir: string | null = null;
try {
@@ -620,19 +517,6 @@ function createBundledRuntimeDepsPlan(params: {
};
}
function assertBundledRuntimeDepsInstalled(rootDir: string, specs: readonly string[]): void {
const missingSpecs = specs.filter((spec) => {
const dep = parseInstallableRuntimeDepSpec(spec);
return !isRuntimeDepSatisfied(rootDir, dep);
});
if (missingSpecs.length === 0) {
return;
}
throw new Error(
`package manager install did not place bundled runtime deps in ${rootDir}: ${missingSpecs.join(", ")}`,
);
}
function replaceNodeModulesDir(targetDir: string, sourceDir: string): void {
const parentDir = path.dirname(targetDir);
const tempDir = fs.mkdtempSync(path.join(parentDir, ".openclaw-runtime-deps-copy-"));
@@ -996,7 +880,7 @@ function collectBundledPluginRuntimeDeps(params: {
if (!packageJson) {
continue;
}
for (const [name, rawVersion] of Object.entries(collectRuntimeDeps(packageJson))) {
for (const [name, rawVersion] of Object.entries(collectPackageRuntimeDeps(packageJson))) {
const dep = parseInstallableRuntimeDep(name, rawVersion);
if (!dep) {
continue;
@@ -1237,7 +1121,7 @@ export function createBundledRuntimeDependencyAliasMap(params: {
return {};
}
const aliases: Record<string, string> = {};
for (const name of Object.keys(collectRuntimeDeps(packageJson)).toSorted((a, b) =>
for (const name of Object.keys(collectPackageRuntimeDeps(packageJson)).toSorted((a, b) =>
a.localeCompare(b),
)) {
const normalizedName = normalizeInstallableRuntimeDepName(name);
@@ -1261,35 +1145,6 @@ function shouldCleanBundledRuntimeDepsInstallExecutionRoot(params: {
return installExecutionRoot.startsWith(`${installRoot}${path.sep}`);
}
function createNpmInstallExecutionManifest(installSpecs: readonly string[]): JsonObject {
const dependencies: Record<string, string> = {};
for (const spec of installSpecs) {
const dep = parseInstallableRuntimeDepSpec(spec);
dependencies[dep.name] = dep.version;
}
const sortedDependencies = Object.fromEntries(
Object.entries(dependencies).toSorted(([left], [right]) => left.localeCompare(right)),
);
return {
name: "openclaw-runtime-deps-install",
private: true,
...(Object.keys(sortedDependencies).length > 0 ? { dependencies: sortedDependencies } : {}),
};
}
function ensureNpmInstallExecutionManifest(
installExecutionRoot: string,
installSpecs: readonly string[] = [],
): void {
const manifestPath = path.join(installExecutionRoot, "package.json");
const manifest = createNpmInstallExecutionManifest(installSpecs);
const nextContents = `${JSON.stringify(manifest, null, 2)}\n`;
if (fs.existsSync(manifestPath) && fs.readFileSync(manifestPath, "utf8") === nextContents) {
return;
}
fs.writeFileSync(manifestPath, nextContents, "utf8");
}
function formatBundledRuntimeDepsInstallError(result: {
error?: Error;
signal?: NodeJS.Signals | null;
@@ -1692,7 +1547,7 @@ export function ensureBundledPluginRuntimeDeps(params: {
if (!packageJson) {
return createBundledRuntimeDepsEnsureResult([]);
}
const pluginDeps = Object.entries(collectRuntimeDeps(packageJson))
const pluginDeps = Object.entries(collectPackageRuntimeDeps(packageJson))
.map(([name, rawVersion]) => parseInstallableRuntimeDep(name, rawVersion))
.filter((entry): entry is { name: string; version: string } => Boolean(entry));
const pluginDepEntries = pluginDeps.map((dep) => ({