mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-25 15:37:32 +00:00
fix: harden bundled plugin runtime deps
This commit is contained in:
67
src/commands/doctor-bundled-plugin-runtime-deps.test.ts
Normal file
67
src/commands/doctor-bundled-plugin-runtime-deps.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scanBundledPluginRuntimeDeps } from "./doctor-bundled-plugin-runtime-deps.js";
|
||||
|
||||
function writeJson(filePath: string, value: unknown) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
describe("doctor bundled plugin runtime deps", () => {
|
||||
it("skips source checkouts", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
|
||||
fs.mkdirSync(path.join(root, ".git"));
|
||||
fs.mkdirSync(path.join(root, "src"));
|
||||
fs.mkdirSync(path.join(root, "extensions"));
|
||||
writeJson(path.join(root, "dist", "extensions", "discord", "package.json"), {
|
||||
dependencies: {
|
||||
"dep-one": "1.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
const result = scanBundledPluginRuntimeDeps({ packageRoot: root });
|
||||
expect(result.missing).toEqual([]);
|
||||
expect(result.conflicts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports missing deps and conflicts", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
|
||||
writeJson(path.join(root, "package.json"), { name: "openclaw" });
|
||||
|
||||
writeJson(path.join(root, "dist", "extensions", "alpha", "package.json"), {
|
||||
dependencies: {
|
||||
"dep-one": "1.0.0",
|
||||
"@scope/dep-two": "2.0.0",
|
||||
},
|
||||
optionalDependencies: {
|
||||
"dep-opt": "3.0.0",
|
||||
},
|
||||
});
|
||||
writeJson(path.join(root, "dist", "extensions", "beta", "package.json"), {
|
||||
dependencies: {
|
||||
"dep-one": "1.0.0",
|
||||
"dep-conflict": "1.0.0",
|
||||
},
|
||||
});
|
||||
writeJson(path.join(root, "dist", "extensions", "gamma", "package.json"), {
|
||||
dependencies: {
|
||||
"dep-conflict": "2.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
writeJson(path.join(root, "node_modules", "dep-one", "package.json"), {
|
||||
name: "dep-one",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
const result = scanBundledPluginRuntimeDeps({ packageRoot: root });
|
||||
const missing = result.missing.map((dep) => `${dep.name}@${dep.version}`);
|
||||
|
||||
expect(missing).toEqual(["@scope/dep-two@2.0.0", "dep-opt@3.0.0"]);
|
||||
expect(result.conflicts).toHaveLength(1);
|
||||
expect(result.conflicts[0]?.name).toBe("dep-conflict");
|
||||
expect(result.conflicts[0]?.versions).toEqual(["1.0.0", "2.0.0"]);
|
||||
});
|
||||
});
|
||||
243
src/commands/doctor-bundled-plugin-runtime-deps.ts
Normal file
243
src/commands/doctor-bundled-plugin-runtime-deps.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
type RuntimeDepEntry = {
|
||||
name: string;
|
||||
version: string;
|
||||
pluginIds: string[];
|
||||
};
|
||||
|
||||
type RuntimeDepConflict = {
|
||||
name: string;
|
||||
versions: string[];
|
||||
pluginIdsByVersion: Map<string, string[]>;
|
||||
};
|
||||
|
||||
function isSourceCheckoutRoot(packageRoot: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(packageRoot, ".git")) &&
|
||||
fs.existsSync(path.join(packageRoot, "src")) &&
|
||||
fs.existsSync(path.join(packageRoot, "extensions"))
|
||||
);
|
||||
}
|
||||
|
||||
function dependencySentinelPath(depName: string): string {
|
||||
return path.join("node_modules", ...depName.split("/"), "package.json");
|
||||
}
|
||||
|
||||
function collectRuntimeDeps(packageJson: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...(packageJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(packageJson.optionalDependencies as Record<string, unknown> | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function collectBundledPluginRuntimeDeps(params: { extensionsDir: string }): {
|
||||
deps: RuntimeDepEntry[];
|
||||
conflicts: RuntimeDepConflict[];
|
||||
} {
|
||||
const versionMap = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const pluginId = entry.name;
|
||||
const packageJsonPath = path.join(params.extensionsDir, pluginId, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (const [name, rawVersion] of Object.entries(collectRuntimeDeps(packageJson))) {
|
||||
if (typeof rawVersion !== "string" || rawVersion.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
const version = rawVersion.trim();
|
||||
const byVersion = versionMap.get(name) ?? new Map<string, Set<string>>();
|
||||
const pluginIds = byVersion.get(version) ?? new Set<string>();
|
||||
pluginIds.add(pluginId);
|
||||
byVersion.set(version, pluginIds);
|
||||
versionMap.set(name, byVersion);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed plugin manifests; doctor will surface those separately.
|
||||
}
|
||||
}
|
||||
|
||||
const deps: RuntimeDepEntry[] = [];
|
||||
const conflicts: RuntimeDepConflict[] = [];
|
||||
for (const [name, byVersion] of versionMap.entries()) {
|
||||
if (byVersion.size === 1) {
|
||||
const [version, pluginIds] = [...byVersion.entries()][0] ?? [];
|
||||
if (version) {
|
||||
deps.push({
|
||||
name,
|
||||
version,
|
||||
pluginIds: [...pluginIds].toSorted((a, b) => a.localeCompare(b)),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const versions = [...byVersion.keys()].toSorted((a, b) => a.localeCompare(b));
|
||||
const pluginIdsByVersion = new Map<string, string[]>();
|
||||
for (const [version, pluginIds] of byVersion.entries()) {
|
||||
pluginIdsByVersion.set(
|
||||
version,
|
||||
[...pluginIds].toSorted((a, b) => a.localeCompare(b)),
|
||||
);
|
||||
}
|
||||
conflicts.push({
|
||||
name,
|
||||
versions,
|
||||
pluginIdsByVersion,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
deps: deps.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
conflicts: conflicts.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
};
|
||||
}
|
||||
|
||||
export function scanBundledPluginRuntimeDeps(params: { packageRoot: string }): {
|
||||
missing: RuntimeDepEntry[];
|
||||
conflicts: RuntimeDepConflict[];
|
||||
} {
|
||||
if (isSourceCheckoutRoot(params.packageRoot)) {
|
||||
return { missing: [], conflicts: [] };
|
||||
}
|
||||
const extensionsDir = path.join(params.packageRoot, "dist", "extensions");
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
return { missing: [], conflicts: [] };
|
||||
}
|
||||
const { deps, conflicts } = collectBundledPluginRuntimeDeps({ extensionsDir });
|
||||
const missing = deps.filter(
|
||||
(dep) => !fs.existsSync(path.join(params.packageRoot, dependencySentinelPath(dep.name))),
|
||||
);
|
||||
return { missing, conflicts };
|
||||
}
|
||||
|
||||
function createNestedNpmInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const nextEnv = { ...env };
|
||||
delete nextEnv.npm_config_global;
|
||||
delete nextEnv.npm_config_location;
|
||||
delete nextEnv.npm_config_prefix;
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
function installBundledRuntimeDeps(params: {
|
||||
packageRoot: string;
|
||||
missingSpecs: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}) {
|
||||
const result = spawnSync(
|
||||
"npm",
|
||||
[
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--no-save",
|
||||
"--package-lock=false",
|
||||
"--ignore-scripts",
|
||||
"--legacy-peer-deps",
|
||||
...params.missingSpecs,
|
||||
],
|
||||
{
|
||||
cwd: params.packageRoot,
|
||||
encoding: "utf8",
|
||||
env: createNestedNpmInstallEnv(params.env),
|
||||
stdio: "pipe",
|
||||
shell: false,
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
|
||||
throw new Error(output || "npm install failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeRepairBundledPluginRuntimeDeps(params: {
|
||||
runtime: RuntimeEnv;
|
||||
prompter: DoctorPrompter;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
packageRoot?: string | null;
|
||||
installDeps?: (params: { packageRoot: string; missingSpecs: string[] }) => void;
|
||||
}): Promise<void> {
|
||||
const packageRoot =
|
||||
params.packageRoot ??
|
||||
resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
moduleUrl: import.meta.url,
|
||||
});
|
||||
if (!packageRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { missing, conflicts } = scanBundledPluginRuntimeDeps({ packageRoot });
|
||||
if (conflicts.length > 0) {
|
||||
const conflictLines = conflicts.flatMap((conflict) => [
|
||||
`- ${conflict.name}: ${conflict.versions.join(", ")}`,
|
||||
...conflict.versions.flatMap((version) => {
|
||||
const pluginIds = conflict.pluginIdsByVersion.get(version) ?? [];
|
||||
return pluginIds.length > 0 ? [` - ${version}: ${pluginIds.join(", ")}`] : [];
|
||||
}),
|
||||
]);
|
||||
note(
|
||||
[
|
||||
"Bundled plugin runtime deps use conflicting versions.",
|
||||
...conflictLines,
|
||||
`Update bundled plugins and rerun ${formatCliCommand("openclaw doctor")}.`,
|
||||
].join("\n"),
|
||||
"Bundled plugins",
|
||||
);
|
||||
}
|
||||
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missingSpecs = missing.map((dep) => `${dep.name}@${dep.version}`);
|
||||
note(
|
||||
[
|
||||
"Bundled plugin runtime deps are missing.",
|
||||
...missing.map((dep) => `- ${dep.name}@${dep.version} (used by ${dep.pluginIds.join(", ")})`),
|
||||
`Fix: run ${formatCliCommand("openclaw doctor --fix")} to install them.`,
|
||||
].join("\n"),
|
||||
"Bundled plugins",
|
||||
);
|
||||
|
||||
const shouldRepair =
|
||||
params.prompter.shouldRepair ||
|
||||
(await params.prompter.confirmAutoFix({
|
||||
message: "Install missing bundled plugin runtime deps now?",
|
||||
initialValue: true,
|
||||
}));
|
||||
if (!shouldRepair) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const install =
|
||||
params.installDeps ??
|
||||
((installParams) =>
|
||||
installBundledRuntimeDeps({
|
||||
packageRoot: installParams.packageRoot,
|
||||
missingSpecs: installParams.missingSpecs,
|
||||
env: params.env ?? process.env,
|
||||
}));
|
||||
install({ packageRoot, missingSpecs });
|
||||
note(`Installed bundled plugin deps: ${missingSpecs.join(", ")}`, "Bundled plugins");
|
||||
} catch (error) {
|
||||
params.runtime.error(`Failed to install bundled plugin runtime deps: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../commands/doctor-auth.js";
|
||||
import { noteBootstrapFileSize } from "../commands/doctor-bootstrap-size.js";
|
||||
import { noteChromeMcpBrowserReadiness } from "../commands/doctor-browser.js";
|
||||
import { maybeRepairBundledPluginRuntimeDeps } from "../commands/doctor-bundled-plugin-runtime-deps.js";
|
||||
import { doctorShellCompletion } from "../commands/doctor-completion.js";
|
||||
import { maybeRepairLegacyCronStore } from "../commands/doctor-cron.js";
|
||||
import { maybeRepairGatewayDaemon } from "../commands/doctor-gateway-daemon-flow.js";
|
||||
@@ -243,6 +244,13 @@ async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Prom
|
||||
});
|
||||
}
|
||||
|
||||
async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
await maybeRepairBundledPluginRuntimeDeps({
|
||||
runtime: ctx.runtime,
|
||||
prompter: ctx.prompter,
|
||||
});
|
||||
}
|
||||
|
||||
async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath);
|
||||
}
|
||||
@@ -495,6 +503,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
label: "Legacy plugin manifests",
|
||||
run: runLegacyPluginManifestHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:bundled-plugin-runtime-deps",
|
||||
label: "Bundled plugin runtime deps",
|
||||
run: runBundledPluginRuntimeDepsHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:state-integrity",
|
||||
label: "State integrity",
|
||||
|
||||
Reference in New Issue
Block a user