From f5edc4e52c4d1c104cf5394ad9e75f96a221d698 Mon Sep 17 00:00:00 2001 From: sliverp <870080352@qq.com> Date: Wed, 6 May 2026 19:37:40 +0800 Subject: [PATCH] fix(channels list): surface catalog channels that are installed on disk but not yet configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `--all` path filtered catalog entries with `!installedByChannelId.get(entry.id)` before rendering them as catalog-only rows. That assumed "catalog entry not already rendered as a plugin row" implied "not installed", which is wrong: an external channel plugin package can be installed on disk (`isCatalogChannelInstalled` returns true) while the read-only channel loader still declines to surface a plugin object for it — the loader only activates channels that appear in user config, so a plugin that is installed but never configured ended up in neither bucket and silently dropped out of `channels list --all`. Operator-facing symptom: `pnpm openclaw channels list --all` omitted WeCom (and any other catalog channel in the same state) even though its npm package was present on disk and its catalog entry existed, while rendering every other uninstalled catalog channel as expected. Fix: drop the `installed` filter from `catalogOnlyLines` so every catalog entry that is not already represented by a plugin row is rendered, and let the row itself carry the real installed/not-installed tag. Two renderings now land in the catalog-only bucket: - Not installed — rendered as `not installed, not configured, disabled` (installable row). - Installed but unconfigured — rendered as `installed, not configured, disabled` (ready-to-configure row). The JSON `origin` for this case becomes `available`, matching the existing origin for bundled plugins that are installed but unconfigured, so downstream tooling sees a consistent "you could configure this now" signal regardless of whether the plugin came from bundled sources or from the catalog. Regression test added under the WeCom scenario. --- src/commands/channels.list.test.ts | 43 ++++++++++++++++++++++++++++++ src/commands/channels/list.ts | 40 ++++++++++++++++++--------- 2 files changed, 71 insertions(+), 12 deletions(-) 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), + }), + ); } }