mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 22:44:47 +00:00
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.
326 lines
9.4 KiB
TypeScript
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 };
|
|
}
|