diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 2cd5aa32767..69a256f622c 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -791,7 +791,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']); }); - it("reinstalls a known configured plugin when its recorded install path is missing", async () => { + it("reinstalls a known configured plugin from the catalog when its recorded install path is missing", async () => { const records = { discord: { source: "npm", @@ -809,6 +809,109 @@ describe("repairMissingConfiguredPluginInstalls", () => { ], diagnostics: [], }); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "discord", + pluginId: "discord", + meta: { label: "Discord" }, + install: { + npmSpec: "@openclaw/discord", + }, + }, + ]); + mocks.installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "discord", + targetDir: "/tmp/openclaw-plugins/discord", + version: "1.2.3", + npmResolution: { + name: "@openclaw/discord", + version: "1.2.3", + resolvedSpec: "@openclaw/discord@1.2.3", + integrity: "sha512-discord", + resolvedAt: "2026-05-01T00:00:00.000Z", + }, + }); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: false, + config: { + plugins: { + installs: records, + }, + }, + outcomes: [ + { + pluginId: "discord", + status: "skipped", + message: "No update applied.", + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + plugins: { + entries: { + discord: { enabled: true }, + }, + }, + channels: { + discord: { enabled: true }, + }, + }, + env: {}, + }); + + expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["discord"], + config: expect.objectContaining({ + plugins: expect.objectContaining({ installs: records }), + }), + }), + ); + expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@openclaw/discord", + expectedPluginId: "discord", + }), + ); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + expect.objectContaining({ + discord: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/discord" }), + }), + { env: {} }, + ); + expect(result.changes).toEqual([ + 'Installed missing configured plugin "discord" from @openclaw/discord.', + ]); + }); + + it("updates a known configured plugin when its installed manifest path still exists", async () => { + const records = { + discord: { + source: "npm", + spec: "@openclaw/discord", + installPath: process.cwd(), + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [ + { + id: "discord", + channels: ["discord"], + }, + ], + diagnostics: [ + { + pluginId: "discord", + message: "manifest without channelConfigs metadata", + }, + ], + }); mocks.updateNpmInstalledPlugins.mockResolvedValue({ changed: true, config: { @@ -817,7 +920,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { discord: { source: "npm", spec: "@openclaw/discord", - installPath: "/tmp/openclaw-plugins/discord", + installPath: process.cwd(), }, }, }, @@ -857,7 +960,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { ); expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( expect.objectContaining({ - discord: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/discord" }), + discord: expect.objectContaining({ installPath: process.cwd() }), }), { env: {} }, ); @@ -907,7 +1010,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { discord: { source: "npm", spec: "@openclaw/discord", - installPath: "/tmp/openclaw-plugins/discord", + installPath: process.cwd(), }, }, }, @@ -949,7 +1052,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { ); expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( expect.objectContaining({ - discord: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/discord" }), + discord: expect.objectContaining({ installPath: process.cwd() }), }), { env: {} }, ); @@ -999,7 +1102,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { brave: { source: "npm", spec: "@openclaw/brave-plugin@beta", - installPath: "/tmp/openclaw-plugins/brave", + installPath: process.cwd(), }, }, }, @@ -1038,7 +1141,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { ); expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( expect.objectContaining({ - brave: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/brave" }), + brave: expect.objectContaining({ installPath: process.cwd() }), }), { env: {} }, ); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 96e06bc7ebf..bed83f0f49d 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -495,9 +495,13 @@ async function repairMissingPluginInstalls(params: { } const missingPluginIds = new Set( - [...params.pluginIds].filter( - (pluginId) => !knownIds.has(pluginId) && !Object.hasOwn(nextRecords, pluginId), - ), + [...params.pluginIds].filter((pluginId) => { + const hasRecord = Object.hasOwn(nextRecords, pluginId); + return ( + (!knownIds.has(pluginId) && !hasRecord) || + (hasRecord && isInstalledRecordMissingOnDisk(nextRecords[pluginId], env)) + ); + }), ); for (const candidate of collectDownloadableInstallCandidates({ cfg: params.cfg, @@ -507,7 +511,13 @@ async function repairMissingPluginInstalls(params: { configuredChannelIds: params.channelIds, blockedPluginIds: params.blockedPluginIds, })) { - if (knownIds.has(candidate.pluginId) || Object.hasOwn(nextRecords, candidate.pluginId)) { + const hasUsableRecord = + Object.hasOwn(nextRecords, candidate.pluginId) && + !isInstalledRecordMissingOnDisk(nextRecords[candidate.pluginId], env); + if (knownIds.has(candidate.pluginId) && hasUsableRecord) { + continue; + } + if (hasUsableRecord) { continue; } const installed = await installCandidate({ candidate, records: nextRecords });