Files
openclaw/src/plugins/plugin-peer-link.ts
Peter Steinberger 8e533490ab fix(plugins): repair managed npm openclaw peers
Remove stale managed-root openclaw manifests, locks, hidden locks, and installed copies before npm plugin installs.

Relink plugin-local openclaw peer symlinks after shared-root npm install, rollback, update, and uninstall mutations so SDK-using plugins keep resolving openclaw/plugin-sdk/*.

Force safe npm commands out of inherited legacy/strict peer-dependency modes.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
2026-05-06 07:32:25 +01:00

144 lines
4.3 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
type PluginPeerLinkLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
type RelinkManagedNpmRootResult = {
checked: number;
attempted: number;
};
function readStringRecord(value: unknown): Record<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
const record: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw === "string") {
record[key] = raw;
}
}
return record;
}
async function readPackagePeerDependencies(packageDir: string): Promise<Record<string, string>> {
try {
const raw = await fs.readFile(path.join(packageDir, "package.json"), "utf8");
const parsed = JSON.parse(raw) as { peerDependencies?: unknown };
return readStringRecord(parsed.peerDependencies);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {};
}
throw error;
}
}
async function listManagedNpmRootPackageDirs(npmRoot: string): Promise<string[]> {
const nodeModulesDir = path.join(npmRoot, "node_modules");
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(nodeModulesDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
const packageDirs: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === ".bin") {
continue;
}
const entryPath = path.join(nodeModulesDir, entry.name);
if (entry.name.startsWith("@")) {
const scopedEntries = await fs.readdir(entryPath, { withFileTypes: true }).catch((error) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
});
for (const scopedEntry of scopedEntries) {
if (scopedEntry.isDirectory()) {
packageDirs.push(path.join(entryPath, scopedEntry.name));
}
}
continue;
}
if (!entry.name.startsWith(".")) {
packageDirs.push(entryPath);
}
}
return packageDirs.toSorted((a, b) => a.localeCompare(b));
}
/**
* Symlink the host openclaw package for plugins that declare it as a peer.
* Plugin package managers still own third-party dependencies; this only wires
* the host SDK package into the plugin-local Node graph.
*/
export async function linkOpenClawPeerDependencies(params: {
installedDir: string;
peerDependencies: Record<string, string>;
logger: PluginPeerLinkLogger;
}): Promise<void> {
const peers = Object.keys(params.peerDependencies).filter((name) => name === "openclaw");
if (peers.length === 0) {
return;
}
const hostRoot = resolveOpenClawPackageRootSync({
argv1: process.argv[1],
moduleUrl: import.meta.url,
cwd: process.cwd(),
});
if (!hostRoot) {
params.logger.warn?.(
"Could not locate openclaw package root to symlink peerDependencies; plugin may fail to resolve openclaw at runtime.",
);
return;
}
const nodeModulesDir = path.join(params.installedDir, "node_modules");
await fs.mkdir(nodeModulesDir, { recursive: true });
for (const peerName of peers) {
const linkPath = path.join(nodeModulesDir, peerName);
try {
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(hostRoot, linkPath, "junction");
params.logger.info?.(`Linked peerDependency "${peerName}" -> ${hostRoot}`);
} catch (err) {
params.logger.warn?.(`Failed to symlink peerDependency "${peerName}": ${String(err)}`);
}
}
}
export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
npmRoot: string;
logger: PluginPeerLinkLogger;
}): Promise<RelinkManagedNpmRootResult> {
let checked = 0;
let attempted = 0;
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
const peerDependencies = await readPackagePeerDependencies(packageDir);
if (!Object.hasOwn(peerDependencies, "openclaw")) {
continue;
}
checked += 1;
await linkOpenClawPeerDependencies({
installedDir: packageDir,
peerDependencies,
logger: params.logger,
});
attempted += 1;
}
return { checked, attempted };
}