From 9ae7db556289b142e8b9fda4f559484b0f422eef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 20:57:25 +0100 Subject: [PATCH] refactor(plugins): split runtime deps materialization --- .../bundled-runtime-deps-materialization.ts | 152 ++++++++++++++++ src/plugins/bundled-runtime-deps-specs.ts | 16 ++ src/plugins/bundled-runtime-deps.ts | 171 ++---------------- 3 files changed, 181 insertions(+), 158 deletions(-) create mode 100644 src/plugins/bundled-runtime-deps-materialization.ts diff --git a/src/plugins/bundled-runtime-deps-materialization.ts b/src/plugins/bundled-runtime-deps-materialization.ts new file mode 100644 index 00000000000..c5bf92c35b0 --- /dev/null +++ b/src/plugins/bundled-runtime-deps-materialization.ts @@ -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)) { + 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 = {}; + 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"); +} diff --git a/src/plugins/bundled-runtime-deps-specs.ts b/src/plugins/bundled-runtime-deps-specs.ts index d3fd3632128..18ed0743a85 100644 --- a/src/plugins/bundled-runtime-deps-specs.ts +++ b/src/plugins/bundled-runtime-deps-specs.ts @@ -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, +): Record { + return { + ...(packageJson.dependencies as Record | undefined), + ...(packageJson.optionalDependencies as Record | undefined), + }; +} + function dependencySentinelPath(depName: string): string { const normalizedDepName = normalizeInstallableRuntimeDepName(depName); if (!normalizedDepName) { diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 0d384dd2bd7..2560b216ece 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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 ` with // `cwd: pluginRoot` would make npm resolve the plugin's own `workspace:*` @@ -130,13 +135,6 @@ async function withBundledRuntimeDepsInstallRootLockAsync( ); } -function collectRuntimeDeps(packageJson: JsonObject): Record { - return { - ...(packageJson.dependencies as Record | undefined), - ...(packageJson.optionalDependencies as Record | 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)) { - 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 = {}; - 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 = {}; - 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) => ({