fix(channels list): surface catalog channels that are installed on disk but not yet configured

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.
This commit is contained in:
sliverp
2026-05-06 19:37:40 +08:00
parent a69f798de6
commit f5edc4e52c
2 changed files with 71 additions and 12 deletions

View File

@@ -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<string, { origin: string; installed: boolean }>;
};
expect(payload.chat.wecom).toMatchObject({
origin: "available",
installed: true,
});
},
);
});

View File

@@ -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),
}),
);
}
}