Skip to content

Commit 9c0d0fb

Browse files
committed
feat(doctor): expose plugin registry findings
1 parent ddedf13 commit 9c0d0fb

4 files changed

Lines changed: 349 additions & 1 deletion

File tree

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

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@ import type { PluginCandidate } from "../plugins/discovery.js";
77
import { resolvePluginNpmProjectDir } from "../plugins/install-paths.js";
88
import {
99
readPersistedInstalledPluginIndex,
10+
resolveInstalledPluginIndexStorePath,
1011
writePersistedInstalledPluginIndex,
1112
} from "../plugins/installed-plugin-index-store.js";
1213
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
1314
import { markRetainedManagedNpmInstall } from "../plugins/managed-npm-retention.js";
1415
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js";
15-
import { maybeRepairPluginRegistryState } from "./doctor-plugin-registry.js";
16+
import {
17+
detectPluginRegistryHealthIssues,
18+
maybeRepairPluginRegistryState,
19+
pluginRegistryIssueToHealthFinding,
20+
pluginRegistryIssueToRepairEffect,
21+
} from "./doctor-plugin-registry.js";
1622
import { DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV } from "./doctor/shared/plugin-registry-migration.js";
1723

1824
vi.mock("../../packages/terminal-core/src/note.js", () => ({
@@ -291,6 +297,151 @@ function expectedPluginIndexRecord(params: {
291297
}
292298

293299
describe("maybeRepairPluginRegistryState", () => {
300+
it("maps missing plugin registry state to a structured finding and dry-run effect", async () => {
301+
const stateDir = makeTempDir();
302+
const registryPath = resolveInstalledPluginIndexStorePath({ stateDir });
303+
304+
const [issue] = await detectPluginRegistryHealthIssues({
305+
stateDir,
306+
env: hermeticEnv(),
307+
config: {},
308+
prompter: { shouldRepair: false },
309+
});
310+
311+
expect(issue).toEqual({
312+
kind: "registry-missing-or-stale",
313+
path: registryPath,
314+
});
315+
expect(pluginRegistryIssueToHealthFinding(issue)).toMatchObject({
316+
checkId: "core/doctor/plugin-registry",
317+
severity: "warning",
318+
path: registryPath,
319+
fixHint: "Run `openclaw doctor --fix` to rebuild the plugin registry from enabled plugins.",
320+
});
321+
expect(pluginRegistryIssueToRepairEffect(issue)).toEqual({
322+
kind: "state",
323+
action: "would-rebuild-plugin-registry",
324+
target: registryPath,
325+
dryRunSafe: false,
326+
});
327+
});
328+
329+
it("maps stale managed npm bundled plugin shadows to structured findings", async () => {
330+
const stateDir = makeTempDir();
331+
const bundledDir = path.join(stateDir, "bundled", "google-meet");
332+
fs.mkdirSync(bundledDir, { recursive: true });
333+
const managed = createManagedNpmPlugin({
334+
stateDir,
335+
id: "google-meet",
336+
packageName: "@openclaw/google-meet",
337+
version: "2026.5.2",
338+
});
339+
await writePersistedInstalledPluginIndex(createCurrentIndex(), { stateDir });
340+
341+
const issues = await detectPluginRegistryHealthIssues({
342+
stateDir,
343+
candidates: [
344+
createBundledCandidate({
345+
rootDir: bundledDir,
346+
id: "google-meet",
347+
packageName: "@openclaw/google-meet",
348+
version: "2026.5.3",
349+
}),
350+
],
351+
env: hermeticEnv(),
352+
config: {
353+
plugins: {
354+
allow: ["google-meet"],
355+
entries: {
356+
"google-meet": {
357+
enabled: true,
358+
config: {},
359+
},
360+
},
361+
},
362+
},
363+
prompter: { shouldRepair: false },
364+
});
365+
366+
const staleIssue = issues.find((issue) => issue.kind === "stale-managed-npm-bundled-plugin");
367+
expect(staleIssue).toMatchObject({
368+
kind: "stale-managed-npm-bundled-plugin",
369+
pluginId: "google-meet",
370+
packageName: "@openclaw/google-meet",
371+
packageDir: managed.packageDir,
372+
version: "2026.5.2",
373+
});
374+
expect(pluginRegistryIssueToHealthFinding(staleIssue!)).toMatchObject({
375+
checkId: "core/doctor/plugin-registry",
376+
severity: "warning",
377+
path: managed.packageDir,
378+
target: "google-meet",
379+
});
380+
expect(pluginRegistryIssueToRepairEffect(staleIssue!)).toEqual({
381+
kind: "package",
382+
action: "would-remove-stale-managed-npm-bundled-plugin",
383+
target: managed.packageDir,
384+
dryRunSafe: false,
385+
});
386+
});
387+
388+
it("maps stale local bundled install records to structured findings", async () => {
389+
const stateDir = makeTempDir();
390+
const bundledDir = path.join(stateDir, "current", "dist", "extensions", "discord");
391+
const staleDir = path.join(stateDir, "old-checkout", "dist", "extensions", "discord");
392+
fs.mkdirSync(bundledDir, { recursive: true });
393+
fs.mkdirSync(staleDir, { recursive: true });
394+
createCandidate(staleDir, "discord");
395+
await writePersistedInstalledPluginIndex(
396+
createCurrentIndexWithPathRecord({
397+
pluginId: "discord",
398+
installPath: staleDir,
399+
version: "2026.5.4-beta.3",
400+
}),
401+
{ stateDir },
402+
);
403+
404+
const issues = await detectPluginRegistryHealthIssues({
405+
stateDir,
406+
candidates: [
407+
createBundledCandidate({
408+
rootDir: bundledDir,
409+
id: "discord",
410+
packageName: "@openclaw/discord",
411+
version: "2026.5.20-beta.1",
412+
}),
413+
],
414+
env: hermeticEnv(),
415+
config: {
416+
plugins: {
417+
allow: ["discord"],
418+
entries: {
419+
discord: {
420+
enabled: true,
421+
config: {},
422+
},
423+
},
424+
},
425+
},
426+
prompter: { shouldRepair: false },
427+
});
428+
429+
const staleIssue = issues.find(
430+
(issue) => issue.kind === "stale-local-bundled-plugin-install-record",
431+
);
432+
expect(staleIssue).toEqual({
433+
kind: "stale-local-bundled-plugin-install-record",
434+
pluginId: "discord",
435+
stalePath: staleDir,
436+
});
437+
expect(pluginRegistryIssueToHealthFinding(staleIssue!)).toMatchObject({
438+
checkId: "core/doctor/plugin-registry",
439+
severity: "warning",
440+
path: staleDir,
441+
target: "discord",
442+
});
443+
});
444+
294445
it("refreshes an existing registry during repair", async () => {
295446
const stateDir = makeTempDir();
296447
const pluginDir = path.join(stateDir, "plugins", "demo");

src/commands/doctor-plugin-registry.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
55
import { note } from "../../packages/terminal-core/src/note.js";
66
import { formatCliCommand } from "../cli/command-format.js";
77
import type { OpenClawConfig } from "../config/types.openclaw.js";
8+
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
89
import { saveJsonFile } from "../infra/json-file.js";
910
import { tryReadJsonSync } from "../infra/json-files.js";
1011
import type { BundledPluginSource } from "../plugins/bundled-sources.js";
@@ -18,6 +19,7 @@ import { hasRetainedManagedNpmInstallMarker } from "../plugins/managed-npm-reten
1819
import { listManagedPluginNpmRootsSync } from "../plugins/npm-project-roots.js";
1920
import {
2021
auditOpenClawPeerDependenciesInManagedNpmRoot,
22+
type OpenClawPeerLinkAuditIssue,
2123
relinkOpenClawPeerDependenciesInManagedNpmRoot,
2224
} from "../plugins/plugin-peer-link.js";
2325
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
@@ -34,6 +36,8 @@ import {
3436
type PluginRegistryInstallMigrationParams,
3537
} from "./doctor/shared/plugin-registry-migration.js";
3638

39+
const PLUGIN_REGISTRY_CHECK_ID = "core/doctor/plugin-registry";
40+
3741
type PluginRegistryDoctorRepairParams = Omit<PluginRegistryInstallMigrationParams, "config"> &
3842
InstalledPluginIndexRecordStoreOptions & {
3943
config: OpenClawConfig;
@@ -53,6 +57,31 @@ type PluginRegistryDoctorNoteLogger = {
5357
warn: (message: string) => void;
5458
};
5559

60+
export type PluginRegistryHealthIssue =
61+
| {
62+
kind: "registry-missing-or-stale";
63+
path: string;
64+
}
65+
| {
66+
kind: "stale-managed-npm-bundled-plugin";
67+
pluginId: string;
68+
packageName: string;
69+
packageDir: string;
70+
npmRoot: string;
71+
version?: string;
72+
}
73+
| {
74+
kind: "stale-local-bundled-plugin-install-record";
75+
pluginId: string;
76+
stalePath: string;
77+
}
78+
| {
79+
kind: "managed-npm-openclaw-peer-link";
80+
packageName: string;
81+
packageDir: string;
82+
reason: string;
83+
};
84+
5685
function readJsonObject(filePath: string): Record<string, unknown> | null {
5786
const parsed = tryReadJsonSync(filePath);
5887
return isRecord(parsed) ? parsed : null;
@@ -390,6 +419,137 @@ async function loadInstallRecordsWithoutPluginIds(
390419
return records;
391420
}
392421

422+
async function listManagedNpmOpenClawPeerLinkIssues(
423+
params: PluginRegistryDoctorRepairParams,
424+
): Promise<OpenClawPeerLinkAuditIssue[]> {
425+
const audits = await Promise.all(
426+
listManagedPluginNpmRoots(params).map((npmRoot) =>
427+
auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot }),
428+
),
429+
);
430+
return audits.flatMap((audit) => audit.issues);
431+
}
432+
433+
export async function detectPluginRegistryHealthIssues(
434+
params: PluginRegistryDoctorRepairParams,
435+
): Promise<PluginRegistryHealthIssue[]> {
436+
const preflight = preflightPluginRegistryInstallMigration(params);
437+
const issues: PluginRegistryHealthIssue[] = [];
438+
if (preflight.action === "migrate") {
439+
issues.push({
440+
kind: "registry-missing-or-stale",
441+
path: preflight.filePath,
442+
});
443+
}
444+
for (const plugin of listStaleManagedNpmBundledPlugins(params)) {
445+
issues.push({
446+
kind: "stale-managed-npm-bundled-plugin",
447+
pluginId: plugin.pluginId,
448+
packageName: plugin.packageName,
449+
packageDir: plugin.packageDir,
450+
npmRoot: plugin.npmRoot,
451+
...(plugin.version ? { version: plugin.version } : {}),
452+
});
453+
}
454+
for (const record of await listStaleLocalBundledPluginInstallRecordShadows(params)) {
455+
issues.push({
456+
kind: "stale-local-bundled-plugin-install-record",
457+
pluginId: record.pluginId,
458+
stalePath: record.stalePath,
459+
});
460+
}
461+
for (const issue of await listManagedNpmOpenClawPeerLinkIssues(params)) {
462+
issues.push({
463+
kind: "managed-npm-openclaw-peer-link",
464+
packageName: issue.packageName,
465+
packageDir: issue.packageDir,
466+
reason: issue.reason,
467+
});
468+
}
469+
return issues;
470+
}
471+
472+
export function pluginRegistryIssueToHealthFinding(
473+
issue: PluginRegistryHealthIssue,
474+
): HealthFinding {
475+
switch (issue.kind) {
476+
case "registry-missing-or-stale":
477+
return {
478+
checkId: PLUGIN_REGISTRY_CHECK_ID,
479+
severity: "warning",
480+
message: "Persisted plugin registry is missing or stale.",
481+
path: issue.path,
482+
fixHint: "Run `openclaw doctor --fix` to rebuild the plugin registry from enabled plugins.",
483+
};
484+
case "stale-managed-npm-bundled-plugin":
485+
return {
486+
checkId: PLUGIN_REGISTRY_CHECK_ID,
487+
severity: "warning",
488+
message: `Managed npm package ${issue.packageName}${
489+
issue.version ? `@${issue.version}` : ""
490+
} shadows bundled plugin ${issue.pluginId}.`,
491+
path: issue.packageDir,
492+
target: issue.pluginId,
493+
fixHint:
494+
"Run `openclaw doctor --fix` to remove stale managed npm packages and rebuild the plugin registry.",
495+
};
496+
case "stale-local-bundled-plugin-install-record":
497+
return {
498+
checkId: PLUGIN_REGISTRY_CHECK_ID,
499+
severity: "warning",
500+
message: `Local install record for bundled plugin ${issue.pluginId} points at a stale path.`,
501+
path: issue.stalePath,
502+
target: issue.pluginId,
503+
fixHint:
504+
"Run `openclaw doctor --fix` to remove stale local install records and rebuild the plugin registry.",
505+
};
506+
case "managed-npm-openclaw-peer-link":
507+
return {
508+
checkId: PLUGIN_REGISTRY_CHECK_ID,
509+
severity: "warning",
510+
message: `Managed npm package ${issue.packageName} has a broken OpenClaw peer link: ${issue.reason}.`,
511+
path: issue.packageDir,
512+
target: issue.packageName,
513+
fixHint: "Run `openclaw doctor --fix` to relink managed npm plugin packages.",
514+
};
515+
}
516+
}
517+
518+
export function pluginRegistryIssueToRepairEffect(
519+
issue: PluginRegistryHealthIssue,
520+
): HealthRepairEffect {
521+
switch (issue.kind) {
522+
case "registry-missing-or-stale":
523+
return {
524+
kind: "state",
525+
action: "would-rebuild-plugin-registry",
526+
target: issue.path,
527+
dryRunSafe: false,
528+
};
529+
case "stale-managed-npm-bundled-plugin":
530+
return {
531+
kind: "package",
532+
action: "would-remove-stale-managed-npm-bundled-plugin",
533+
target: issue.packageDir,
534+
dryRunSafe: false,
535+
};
536+
case "stale-local-bundled-plugin-install-record":
537+
return {
538+
kind: "state",
539+
action: "would-remove-stale-local-bundled-plugin-install-record",
540+
target: issue.pluginId,
541+
dryRunSafe: false,
542+
};
543+
case "managed-npm-openclaw-peer-link":
544+
return {
545+
kind: "package",
546+
action: "would-relink-managed-npm-openclaw-peer",
547+
target: issue.packageDir,
548+
dryRunSafe: false,
549+
};
550+
}
551+
}
552+
393553
/**
394554
* Runs plugin registry doctor repairs and refreshes the persisted plugin index when needed.
395555
*

src/flows/doctor-health-contributions.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,7 @@ describe("doctor health contributions", () => {
10511051
expect(contributionIds).toContain("core/doctor/config-audit-scrub");
10521052
expect(contributionIds).toContain("core/doctor/session-transcripts");
10531053
expect(contributionIds).toContain("core/doctor/session-snapshots");
1054+
expect(contributionIds).toContain("core/doctor/plugin-registry");
10541055
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
10551056
});
10561057

0 commit comments

Comments
 (0)