Files
openclaw/src/plugins/plugin-peer-link.ts
Alex Naidis a290cd633f fix(doctor): repair managed plugin peer links
Repair managed npm plugin OpenClaw peer links across doctor, install, and update flows.

- relink `peerDependencies.openclaw` packages under managed npm roots during doctor repair
- make read-only doctor preview broken peer links with a `doctor --fix` hint
- reject target plugin installs when their own peer link cannot be repaired, without blocking unrelated installs for stale sibling packages
- preserve update warning behavior for unrepairable package-local `node_modules`

Verification:
- `pnpm test src/plugins/plugin-peer-link.test.ts src/plugins/install.test.ts src/plugins/install.npm-spec.test.ts src/plugins/update.test.ts src/commands/doctor-plugin-registry.test.ts src/commands/doctor/repair-sequencing.test.ts -- --reporter=verbose`
- `pnpm exec oxfmt --check --threads=1 ...`
- `git diff --check`
- Crabbox/Testbox `tbx_01krde1jx199rnpm2rv1rdcj76`: focused tests + `pnpm check:changed`, exit 0
- Real CLI proof in PR body: read-only `openclaw doctor` warning plus `openclaw doctor --fix` symlink repair

Thanks @TheCrazyLex.
2026-05-12 07:49:08 +01:00

326 lines
9.4 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;
repaired: number;
skipped: number;
};
type OpenClawPeerLinkAuditIssue = {
packageName: string;
packageDir: string;
reason: string;
};
type AuditManagedNpmRootResult = {
checked: number;
broken: number;
issues: OpenClawPeerLinkAuditIssue[];
};
type OpenClawPeerLinkResult = "linked" | "skipped" | "unchanged";
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));
}
async function safeRealpath(filePath: string): Promise<string | null> {
try {
return await fs.realpath(filePath);
} catch {
return null;
}
}
function managedPackageNameFromDir(params: { npmRoot: string; packageDir: string }): string {
return path
.relative(path.join(params.npmRoot, "node_modules"), params.packageDir)
.split(path.sep)
.join("/");
}
async function auditOpenClawPeerDependency(params: {
hostRoot: string;
npmRoot: string;
packageDir: string;
}): Promise<OpenClawPeerLinkAuditIssue | null> {
const packageName = managedPackageNameFromDir({
npmRoot: params.npmRoot,
packageDir: params.packageDir,
});
const nodeModulesDir = path.join(params.packageDir, "node_modules");
try {
const existing = await fs.lstat(nodeModulesDir);
if (!existing.isDirectory() || existing.isSymbolicLink()) {
return {
packageName,
packageDir: params.packageDir,
reason: `${nodeModulesDir} is not a real directory`,
};
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {
packageName,
packageDir: params.packageDir,
reason: `missing ${path.join(nodeModulesDir, "openclaw")}`,
};
}
throw error;
}
const linkPath = path.join(nodeModulesDir, "openclaw");
const currentTarget = await safeRealpath(linkPath);
if (!currentTarget) {
return {
packageName,
packageDir: params.packageDir,
reason: `missing ${linkPath}`,
};
}
const expectedTarget = (await safeRealpath(params.hostRoot)) ?? params.hostRoot;
if (currentTarget !== expectedTarget) {
return {
packageName,
packageDir: params.packageDir,
reason: `${linkPath} points to ${currentTarget} instead of ${expectedTarget}`,
};
}
return null;
}
async function ensureRealNodeModulesDir(params: {
installedDir: string;
logger: PluginPeerLinkLogger;
}): Promise<string | null> {
const nodeModulesDir = path.join(params.installedDir, "node_modules");
try {
const existing = await fs.lstat(nodeModulesDir);
if (!existing.isDirectory() || existing.isSymbolicLink()) {
params.logger.warn?.(
`Skipping openclaw peerDependency link because ${nodeModulesDir} is not a real directory.`,
);
return null;
}
return nodeModulesDir;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
await fs.mkdir(nodeModulesDir, { recursive: true });
const created = await fs.lstat(nodeModulesDir);
if (!created.isDirectory() || created.isSymbolicLink()) {
params.logger.warn?.(
`Skipping openclaw peerDependency link because ${nodeModulesDir} is not a real directory.`,
);
return null;
}
return nodeModulesDir;
}
async function linkOpenClawPeerDependency(params: {
hostRoot: string;
installedDir: string;
peerName: string;
logger: PluginPeerLinkLogger;
}): Promise<OpenClawPeerLinkResult> {
const nodeModulesDir = await ensureRealNodeModulesDir({
installedDir: params.installedDir,
logger: params.logger,
});
if (!nodeModulesDir) {
return "skipped";
}
const linkPath = path.join(nodeModulesDir, params.peerName);
const expectedTarget = (await safeRealpath(params.hostRoot)) ?? params.hostRoot;
const currentTarget = await safeRealpath(linkPath);
if (currentTarget === expectedTarget) {
return "unchanged";
}
try {
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(params.hostRoot, linkPath, "junction");
params.logger.info?.(`Linked peerDependency "${params.peerName}" -> ${params.hostRoot}`);
return "linked";
} catch (err) {
params.logger.warn?.(`Failed to symlink peerDependency "${params.peerName}": ${String(err)}`);
return "skipped";
}
}
/**
* 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<{ repaired: number; skipped: number }> {
const peers = Object.keys(params.peerDependencies).filter((name) => name === "openclaw");
if (peers.length === 0) {
return { repaired: 0, skipped: 0 };
}
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 { repaired: 0, skipped: peers.length };
}
let repaired = 0;
let skipped = 0;
for (const peerName of peers) {
const result = await linkOpenClawPeerDependency({
hostRoot,
installedDir: params.installedDir,
peerName,
logger: params.logger,
});
if (result === "linked") {
repaired += 1;
} else if (result === "skipped") {
skipped += 1;
}
}
return { repaired, skipped };
}
export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
npmRoot: string;
logger: PluginPeerLinkLogger;
}): Promise<RelinkManagedNpmRootResult> {
let checked = 0;
let attempted = 0;
let repaired = 0;
let skipped = 0;
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
const peerDependencies = await readPackagePeerDependencies(packageDir);
if (!Object.hasOwn(peerDependencies, "openclaw")) {
continue;
}
checked += 1;
const result = await linkOpenClawPeerDependencies({
installedDir: packageDir,
peerDependencies,
logger: params.logger,
});
attempted += 1;
repaired += result.repaired;
skipped += result.skipped;
}
return { checked, attempted, repaired, skipped };
}
export async function auditOpenClawPeerDependenciesInManagedNpmRoot(params: {
npmRoot: string;
}): Promise<AuditManagedNpmRootResult> {
const hostRoot = resolveOpenClawPackageRootSync({
argv1: process.argv[1],
moduleUrl: import.meta.url,
cwd: process.cwd(),
});
if (!hostRoot) {
return { checked: 0, broken: 0, issues: [] };
}
let checked = 0;
const issues: OpenClawPeerLinkAuditIssue[] = [];
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
const peerDependencies = await readPackagePeerDependencies(packageDir);
if (!Object.hasOwn(peerDependencies, "openclaw")) {
continue;
}
checked += 1;
const issue = await auditOpenClawPeerDependency({
hostRoot,
npmRoot: params.npmRoot,
packageDir,
});
if (issue) {
issues.push(issue);
}
}
return { checked, broken: issues.length, issues };
}