diff --git a/src/commands/channels.list.test.ts b/src/commands/channels.list.test.ts index 092c60f75fc..7159d216b5e 100644 --- a/src/commands/channels.list.test.ts +++ b/src/commands/channels.list.test.ts @@ -324,4 +324,47 @@ describe("channels list", () => { expect(payload.chat.discord).toMatchObject({ origin: "available", installed: true }); expect(payload.chat.qqbot).toMatchObject({ origin: "installable", installed: false }); }); + + it( + "--all still surfaces catalog channels that are installed on disk but have no " + + "plugin object loaded and no config entry (regression: WeCom-like channels " + + "disappearing when the read-only loader only surfaces configured channels)", + async () => { + const runtime = createTestRuntime(); + // Read-only loader returns nothing for wecom because the user has no + // configured wecom channel, so the loader never activates it. + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); + // But catalog knows about wecom, and isCatalogChannelInstalled sees + // the wecom npm package on disk. + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + createCatalogEntry("wecom", "WeCom"), + ]); + mocks.isCatalogChannelInstalled.mockReturnValue(true); + mocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await channelsListCommand({ usage: false, all: true }, runtime); + + const output = stripAnsi(runtime.log.mock.calls[0]?.[0] as string); + expect(output).toContain("WeCom"); + expect(output).toContain("installed"); + expect(output).not.toContain("not installed"); + expect(output).toContain("not configured"); + expect(output).toContain("disabled"); + + // JSON side: origin should be "available" (installed, but user has + // not written a config entry for it). + runtime.log.mockClear(); + await channelsListCommand({ json: true, usage: false, all: true }, runtime); + const payload = JSON.parse(runtime.log.mock.calls[0]?.[0] as string) as { + chat: Record; + }; + expect(payload.chat.wecom).toMatchObject({ + origin: "available", + installed: true, + }); + }, + ); }); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 4f5e56fbfe1..1ebacb08d69 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -101,12 +101,16 @@ function formatAccountLine(params: { return `- ${label}: ${bits.join(", ")}`; } -function formatCatalogOnlyLine(entry: ChannelPluginCatalogEntry): string { +function formatCatalogOnlyLine(params: { + entry: ChannelPluginCatalogEntry; + installed: boolean; +}): string { + const { entry, installed } = params; const channelText = theme.accent(entry.meta.label ?? entry.id); const bits: string[] = [ - formatInstalled(false), + formatInstalled(installed), formatConfigured(false), - theme.muted("not enabled"), + formatEnabled(false), ]; return `- ${channelText}: ${bits.join(", ")}`; } @@ -209,13 +213,19 @@ export async function channelsListCommand( }); } - // --all also surfaces catalog entries whose plugin package is not yet - // installed. These have no in-memory plugin object, so we render a - // catalog-only line that advertises the channel as installable. + // --all also surfaces catalog entries that are not already represented + // by a plugin row above. Two shapes land here: + // 1. Catalog plugin package is not yet installed on disk — rendered as + // `not installed, not configured, disabled` so the channel still + // appears in the listing as installable. + // 2. Catalog plugin package IS installed but the user has no config + // entry for the channel, AND the read-only loader did not surface + // a plugin object for it (because it only activates based on + // configured channels). These would otherwise silently disappear + // from the listing — render them as `installed, not configured, + // disabled` so operators can tell the plugin is ready to configure. const catalogOnlyLines: ChannelPluginCatalogEntry[] = showAll - ? catalogEntries - .filter((entry) => !renderedChannelIds.has(entry.id)) - .filter((entry) => !installedByChannelId.get(entry.id)) + ? catalogEntries.filter((entry) => !renderedChannelIds.has(entry.id)) : []; if (opts.json) { @@ -245,10 +255,11 @@ export async function channelsListCommand( } if (showAll) { for (const entry of catalogOnlyLines) { + const installed = isInstalled(entry.id); chat[entry.id] = { accounts: [], - installed: false, - origin: "installable", + installed, + origin: installed ? "available" : "installable", }; } } @@ -278,7 +289,12 @@ export async function channelsListCommand( ); } for (const entry of catalogOnlyLines) { - lines.push(formatCatalogOnlyLine(entry)); + lines.push( + formatCatalogOnlyLine({ + entry, + installed: isInstalled(entry.id), + }), + ); } }