diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index ed45cb96a08..5abd73f54ba 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -78,6 +78,40 @@ describe("plugins cli list", () => { expect(runtimeLogs).toContain("No plugin issues detected."); }); + it("reports config-selected plugin source shadowing in doctor output", async () => { + buildPluginDiagnosticsReport.mockReturnValue({ + plugins: [ + createPluginRecord({ + id: "discord", + origin: "config", + source: "/tmp/openclaw-upstream/extensions/discord/index.ts", + status: "error", + error: "Cannot find module 'chalk'", + }), + ], + diagnostics: [ + { + level: "warn", + pluginId: "discord", + source: "/tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts", + message: + "duplicate plugin id resolved by explicit config-selected plugin; global plugin will be overridden by config plugin (/tmp/openclaw-upstream/extensions/discord/index.ts)", + }, + ], + }); + + await runPluginsCommand(["plugins", "doctor"]); + + const output = runtimeLogs.join("\n"); + expect(output).toContain("Plugin source shadowing:"); + expect(output).toContain( + "discord: duplicate plugin id resolved by explicit config-selected plugin", + ); + expect(output).toContain("active: /tmp/openclaw-upstream/extensions/discord/index.ts"); + expect(output).toContain("shadowed: /tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts"); + expect(output).toContain("openclaw plugins registry --refresh"); + }); + it("reports persisted plugin registry state without refreshing", async () => { inspectPluginRegistry.mockResolvedValue({ state: "stale", diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 631271189e5..10eded74098 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -5,6 +5,7 @@ import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trac import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { shortenHomeInString } from "../utils.js"; import type { PluginInspectOptions } from "./plugins-inspect-command.js"; import type { PluginsListOptions } from "./plugins-list-command.js"; import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; @@ -62,6 +63,14 @@ function matchesPluginId(plugin: { id: string }, id: string) { return plugin.id === id; } +function isConfigSelectedShadowDiagnostic(entry: { level?: string; message?: string }): boolean { + return ( + entry.level === "warn" && + typeof entry.message === "string" && + entry.message.includes("duplicate plugin id resolved by explicit config-selected plugin") + ); +} + export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -336,9 +345,15 @@ export function registerPluginsCli(program: Command) { const report = buildPluginDiagnosticsReport({ effectiveOnly: true }); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((d) => d.level === "error"); + const shadowed = report.diagnostics.filter(isConfigSelectedShadowDiagnostic); const compatibility = buildPluginCompatibilityNotices({ report }); - if (errors.length === 0 && diags.length === 0 && compatibility.length === 0) { + if ( + errors.length === 0 && + diags.length === 0 && + shadowed.length === 0 && + compatibility.length === 0 + ) { defaultRuntime.log("No plugin issues detected."); return; } @@ -361,6 +376,31 @@ export function registerPluginsCli(program: Command) { lines.push(`- ${target}${diag.message}`); } } + if (shadowed.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(theme.warn("Plugin source shadowing:")); + for (const diag of shadowed) { + const active = report.plugins.find((plugin) => plugin.id === diag.pluginId); + const target = diag.pluginId ? `${diag.pluginId}: ` : ""; + lines.push(`- ${target}${diag.message}`); + if (active) { + lines.push(` active: ${shortenHomeInString(active.source)} (${active.origin})`); + if (active.status === "error") { + lines.push(` active status: error${active.error ? `: ${active.error}` : ""}`); + } + } + if (diag.source) { + lines.push(` shadowed: ${shortenHomeInString(diag.source)}`); + } + lines.push(" repair:"); + lines.push(" openclaw plugins inspect " + (diag.pluginId ?? "")); + lines.push(" edit or remove the config-selected plugin source"); + lines.push(" openclaw plugins registry --refresh"); + lines.push(" openclaw gateway restart --force"); + } + } if (compatibility.length > 0) { if (lines.length > 0) { lines.push(""); diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index 9d01a58863f..4ff4334e3ed 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { applyExclusiveSlotSelection, buildPluginDiagnosticsReport, + buildPluginSnapshotReport, clearPluginRegistryLoadCache, enablePluginInConfig, loadPluginManifestRegistry, @@ -124,6 +125,64 @@ describe("persistPluginInstall", () => { ).toBe(true); }); + it("warns when an installed npm plugin remains shadowed by a config-selected source", async () => { + const { persistPluginInstall } = await import("./plugins-install-persist.js"); + const baseConfig = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledConfig = { + plugins: { + entries: { + discord: { enabled: true }, + }, + }, + } as OpenClawConfig; + enablePluginInConfig.mockReturnValue({ config: enabledConfig }); + buildPluginSnapshotReport.mockReturnValue({ + plugins: [ + { + id: "discord", + origin: "config", + source: "/tmp/openclaw-upstream/extensions/discord/index.ts", + status: "error", + }, + ], + diagnostics: [], + }); + + const next = await persistPluginInstall({ + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, + pluginId: "discord", + install: { + source: "npm", + spec: "@openclaw/discord", + installPath: "/tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts", + }, + }); + + expect(next).toEqual(enabledConfig); + expect(buildPluginSnapshotReport).toHaveBeenCalledWith({ + config: enabledConfig, + effectiveOnly: true, + onlyPluginIds: ["discord"], + }); + expect(runtimeLogs.join("\n")).toContain( + 'Warning: installed plugin "discord" is not the active source', + ); + expect(runtimeLogs.join("\n")).toContain( + "active config source: /tmp/openclaw-upstream/extensions/discord/index.ts", + ); + expect(runtimeLogs.join("\n")).toContain( + "installed npm source: /tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts", + ); + expect(runtimeLogs.join("\n")).toContain("openclaw plugins doctor"); + }); + it("invalidates runtime cache even when registry refresh fails", async () => { const { persistPluginInstall } = await import("./plugins-install-persist.js"); const baseConfig = { diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index a9d2775a750..b032388ee5d 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -9,8 +9,10 @@ import { } from "../plugins/installed-plugin-index-records.js"; import type { PluginInstallUpdate } from "../plugins/installs.js"; import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; +import { buildPluginSnapshotReport } from "../plugins/status.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { theme } from "../terminal/theme.js"; +import { shortenHomePath } from "../utils.js"; import { applySlotSelectionForPlugin, enableInternalHookEntries, @@ -58,6 +60,41 @@ export type ConfigSnapshotForInstallPersist = { baseHash: string | undefined; }; +function logShadowedNpmInstallWarning(params: { + config: OpenClawConfig; + pluginId: string; + install: Omit; + runtime: RuntimeEnv; +}): void { + if (params.install.source !== "npm") { + return; + } + const installedSource = params.install.installPath ?? params.install.sourcePath; + if (!installedSource) { + return; + } + const report = buildPluginSnapshotReport({ + config: params.config, + effectiveOnly: true, + onlyPluginIds: [params.pluginId], + }); + const active = report.plugins.find((plugin) => plugin.id === params.pluginId); + if (!active || active.origin !== "config" || active.source === installedSource) { + return; + } + + params.runtime.log( + theme.warn( + [ + `Warning: installed plugin "${params.pluginId}" is not the active source because a config-selected plugin with the same id is currently selected:`, + ` active config source: ${shortenHomePath(active.source)}`, + ` installed npm source: ${shortenHomePath(installedSource)}`, + "Run `openclaw plugins doctor` for repair options.", + ].join("\n"), + ), + ); +} + export async function persistPluginInstall(params: { snapshot: ConfigSnapshotForInstallPersist; pluginId: string; @@ -127,6 +164,12 @@ export async function persistPluginInstall(params: { runtime.log(theme.warn(params.warningMessage)); } runtime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`); + logShadowedNpmInstallWarning({ + config: next, + pluginId: params.pluginId, + install: params.install, + runtime, + }); runtime.log("Restart the gateway to load plugins."); return next; }