mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:10:45 +00:00
refactor(plugins): split runtime deps materialization
This commit is contained in:
152
src/plugins/bundled-runtime-deps-materialization.ts
Normal file
152
src/plugins/bundled-runtime-deps-materialization.ts
Normal 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");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user