Skip to content

Commit 974f3df

Browse files
committed
fix(doctor): preview broken managed peer links
1 parent 5f541d7 commit 974f3df

4 files changed

Lines changed: 181 additions & 2 deletions

File tree

src/commands/doctor-plugin-registry.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,4 +542,40 @@ describe("maybeRepairPluginRegistryState", () => {
542542
expect(fs.realpathSync(linkPath)).toBe(fs.realpathSync(process.cwd()));
543543
expect(vi.mocked(note).mock.calls.join("\n")).toContain("Repaired OpenClaw host peer link");
544544
});
545+
546+
it("warns about broken managed npm openclaw peer links without repairing them", async () => {
547+
const stateDir = makeTempDir();
548+
const managed = createManagedNpmPlugin({
549+
stateDir,
550+
id: "codex",
551+
packageName: "codex-plugin",
552+
version: "2026.5.3",
553+
peerDependencies: {
554+
openclaw: ">=2026.5.3",
555+
},
556+
});
557+
await writePersistedInstalledPluginIndex(
558+
createCurrentIndexWithNpmRecord({
559+
pluginId: "codex",
560+
packageName: "codex-plugin",
561+
packageDir: managed.packageDir,
562+
version: "2026.5.3",
563+
}),
564+
{ stateDir },
565+
);
566+
567+
await maybeRepairPluginRegistryState({
568+
stateDir,
569+
env: hermeticEnv(),
570+
config: {},
571+
prompter: { shouldRepair: false },
572+
});
573+
574+
const linkPath = path.join(managed.packageDir, "node_modules", "openclaw");
575+
const notes = vi.mocked(note).mock.calls.join("\n");
576+
expect(notes).toContain("Managed npm OpenClaw host peer links need repair");
577+
expect(notes).toContain("codex-plugin");
578+
expect(notes).toContain("openclaw doctor --fix");
579+
expect(fs.existsSync(linkPath)).toBe(false);
580+
});
545581
});

src/commands/doctor-plugin-registry.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
type InstalledPluginIndexRecordStoreOptions,
1111
} from "../plugins/installed-plugin-index-records.js";
1212
import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js";
13-
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "../plugins/plugin-peer-link.js";
13+
import {
14+
auditOpenClawPeerDependenciesInManagedNpmRoot,
15+
relinkOpenClawPeerDependenciesInManagedNpmRoot,
16+
} from "../plugins/plugin-peer-link.js";
1417
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
1518
import { note } from "../terminal/note.js";
1619
import { shortenHomePath } from "../utils.js";
@@ -241,7 +244,19 @@ export function maybeRepairStaleManagedNpmBundledPlugins(
241244
export async function maybeRepairManagedNpmOpenClawPeerLinks(
242245
params: PluginRegistryDoctorRepairParams,
243246
): Promise<boolean> {
247+
const npmRoot = resolveManagedPluginNpmRoot(params);
244248
if (!params.prompter.shouldRepair) {
249+
const audit = await auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot });
250+
if (audit.broken > 0) {
251+
note(
252+
[
253+
"Managed npm OpenClaw host peer links need repair:",
254+
...audit.issues.map((issue) => `- ${issue.packageName}: ${issue.reason}`),
255+
`Repair with ${formatCliCommand("openclaw doctor --fix")} to relink managed npm plugin packages.`,
256+
].join("\n"),
257+
"Plugin registry",
258+
);
259+
}
245260
return false;
246261
}
247262

@@ -251,7 +266,7 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks(
251266
warn: (message) => messages.push({ level: "warn", message }),
252267
};
253268
const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({
254-
npmRoot: resolveManagedPluginNpmRoot(params),
269+
npmRoot,
255270
logger,
256271
});
257272

src/plugins/plugin-peer-link.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs";
22
import path from "node:path";
33
import { afterEach, describe, expect, it } from "vitest";
44
import {
5+
auditOpenClawPeerDependenciesInManagedNpmRoot,
56
linkOpenClawPeerDependencies,
67
relinkOpenClawPeerDependenciesInManagedNpmRoot,
78
} from "./plugin-peer-link.js";
@@ -50,6 +51,32 @@ describe("plugin peer links", () => {
5051
expect(messages.join("\n")).toContain('Linked peerDependency "openclaw"');
5152
});
5253

54+
it("audits missing managed npm openclaw peer links without relinking", async () => {
55+
const npmRoot = makeTempDir();
56+
const packageDir = path.join(npmRoot, "node_modules", "peer-plugin");
57+
fs.mkdirSync(packageDir, { recursive: true });
58+
fs.writeFileSync(
59+
path.join(packageDir, "package.json"),
60+
JSON.stringify({
61+
name: "peer-plugin",
62+
version: "1.0.0",
63+
peerDependencies: {
64+
openclaw: ">=2026.0.0",
65+
},
66+
}),
67+
"utf8",
68+
);
69+
70+
const result = await auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot });
71+
72+
const linkPath = path.join(packageDir, "node_modules", "openclaw");
73+
expect(result.checked).toBe(1);
74+
expect(result.broken).toBe(1);
75+
expect(result.issues[0]?.packageName).toBe("peer-plugin");
76+
expect(result.issues[0]?.reason).toContain(linkPath);
77+
expect(fs.existsSync(linkPath)).toBe(false);
78+
});
79+
5380
it.runIf(process.platform !== "win32")(
5481
"does not follow a package-local node_modules symlink while linking openclaw peers",
5582
async () => {

src/plugins/plugin-peer-link.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ type RelinkManagedNpmRootResult = {
1414
skipped: number;
1515
};
1616

17+
type OpenClawPeerLinkAuditIssue = {
18+
packageName: string;
19+
packageDir: string;
20+
reason: string;
21+
};
22+
23+
type AuditManagedNpmRootResult = {
24+
checked: number;
25+
broken: number;
26+
issues: OpenClawPeerLinkAuditIssue[];
27+
};
28+
1729
type OpenClawPeerLinkResult = "linked" | "skipped" | "unchanged";
1830

1931
function readStringRecord(value: unknown): Record<string, string> {
@@ -89,6 +101,63 @@ async function safeRealpath(filePath: string): Promise<string | null> {
89101
}
90102
}
91103

104+
function managedPackageNameFromDir(params: { npmRoot: string; packageDir: string }): string {
105+
return path
106+
.relative(path.join(params.npmRoot, "node_modules"), params.packageDir)
107+
.split(path.sep)
108+
.join("/");
109+
}
110+
111+
async function auditOpenClawPeerDependency(params: {
112+
hostRoot: string;
113+
npmRoot: string;
114+
packageDir: string;
115+
}): Promise<OpenClawPeerLinkAuditIssue | null> {
116+
const packageName = managedPackageNameFromDir({
117+
npmRoot: params.npmRoot,
118+
packageDir: params.packageDir,
119+
});
120+
const nodeModulesDir = path.join(params.packageDir, "node_modules");
121+
try {
122+
const existing = await fs.lstat(nodeModulesDir);
123+
if (!existing.isDirectory() || existing.isSymbolicLink()) {
124+
return {
125+
packageName,
126+
packageDir: params.packageDir,
127+
reason: `${nodeModulesDir} is not a real directory`,
128+
};
129+
}
130+
} catch (error) {
131+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
132+
return {
133+
packageName,
134+
packageDir: params.packageDir,
135+
reason: `missing ${path.join(nodeModulesDir, "openclaw")}`,
136+
};
137+
}
138+
throw error;
139+
}
140+
141+
const linkPath = path.join(nodeModulesDir, "openclaw");
142+
const currentTarget = await safeRealpath(linkPath);
143+
if (!currentTarget) {
144+
return {
145+
packageName,
146+
packageDir: params.packageDir,
147+
reason: `missing ${linkPath}`,
148+
};
149+
}
150+
const expectedTarget = (await safeRealpath(params.hostRoot)) ?? params.hostRoot;
151+
if (currentTarget !== expectedTarget) {
152+
return {
153+
packageName,
154+
packageDir: params.packageDir,
155+
reason: `${linkPath} points to ${currentTarget} instead of ${expectedTarget}`,
156+
};
157+
}
158+
return null;
159+
}
160+
92161
async function ensureRealNodeModulesDir(params: {
93162
installedDir: string;
94163
logger: PluginPeerLinkLogger;
@@ -222,3 +291,35 @@ export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
222291
}
223292
return { checked, attempted, repaired, skipped };
224293
}
294+
295+
export async function auditOpenClawPeerDependenciesInManagedNpmRoot(params: {
296+
npmRoot: string;
297+
}): Promise<AuditManagedNpmRootResult> {
298+
const hostRoot = resolveOpenClawPackageRootSync({
299+
argv1: process.argv[1],
300+
moduleUrl: import.meta.url,
301+
cwd: process.cwd(),
302+
});
303+
if (!hostRoot) {
304+
return { checked: 0, broken: 0, issues: [] };
305+
}
306+
307+
let checked = 0;
308+
const issues: OpenClawPeerLinkAuditIssue[] = [];
309+
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
310+
const peerDependencies = await readPackagePeerDependencies(packageDir);
311+
if (!Object.hasOwn(peerDependencies, "openclaw")) {
312+
continue;
313+
}
314+
checked += 1;
315+
const issue = await auditOpenClawPeerDependency({
316+
hostRoot,
317+
npmRoot: params.npmRoot,
318+
packageDir,
319+
});
320+
if (issue) {
321+
issues.push(issue);
322+
}
323+
}
324+
return { checked, broken: issues.length, issues };
325+
}

0 commit comments

Comments
 (0)