mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:00:50 +00:00
fix(plugins): forward install records to channel catalog registry
`listChannelCatalogEntries` invoked `discoverOpenClawPlugins` without forwarding `installRecords`, so npm-installed channel plugins recorded in `~/.openclaw/plugins/installs.json` were absent from the CLI channel catalog. `openclaw channels add --channel <id>` and `openclaw channels login --channel <id>` therefore reported "Unsupported channel" / "Unknown channel" for any third-party plugin even when its ledger entry was healthy. Bundled plugins (qqbot, telegram, etc.) reach the same catalog via the stock discovery path, which is why they were unaffected. Lazy-load the persisted ledger via `loadInstalledPluginIndexInstallRecordsSync` when the caller does not specify `origin === "bundled"`, and forward the records to discovery. Bundled-only callers continue to skip the disk read; callers that already loaded records (e.g. tests, batch flows) can pass them explicitly. Reader failures fall back silently to "no install records", preserving prior behaviour. Also register `@tencent-weixin/openclaw-weixin` in `scripts/lib/official-external-channel-catalog.json` so the channel appears in onboarding flows that consult the catalog directly. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
George Zhang
parent
7188e4f4ad
commit
4f7498d8d2
@@ -82,6 +82,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "@tencent-weixin/openclaw-weixin",
|
||||
"description": "OpenClaw Weixin channel plugin by the Tencent Weixin team.",
|
||||
"source": "external",
|
||||
"kind": "channel",
|
||||
"openclaw": {
|
||||
"plugin": {
|
||||
"id": "openclaw-weixin",
|
||||
"label": "Weixin"
|
||||
},
|
||||
"channel": {
|
||||
"id": "openclaw-weixin",
|
||||
"label": "Weixin",
|
||||
"selectionLabel": "Weixin(微信)",
|
||||
"detailLabel": "Weixin",
|
||||
"docsPath": "/plugins/community#weixin",
|
||||
"docsLabel": "weixin",
|
||||
"blurb": "Personal WeChat messaging via QR-code login.",
|
||||
"aliases": ["weixin", "wechat", "微信"],
|
||||
"order": 75
|
||||
},
|
||||
"channelConfigs": {
|
||||
"openclaw-weixin": {
|
||||
"label": "Weixin",
|
||||
"description": "Personal WeChat conversation channel.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@tencent-weixin/openclaw-weixin",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.3.22"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
|
||||
116
src/plugins/channel-catalog-registry.test.ts
Normal file
116
src/plugins/channel-catalog-registry.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import type { PluginCandidate, PluginDiscoveryResult } from "./discovery.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("./discovery.js");
|
||||
vi.doUnmock("./installed-plugin-index-records.js");
|
||||
});
|
||||
|
||||
const ENV: NodeJS.ProcessEnv = { HOME: "/tmp/openclaw-test-home" };
|
||||
|
||||
const RECORDS: Record<string, PluginInstallRecord> = {
|
||||
weixin: {
|
||||
source: "npm",
|
||||
spec: "@tencent-weixin/openclaw-weixin@2.3.7",
|
||||
installPath:
|
||||
"/tmp/openclaw-test-home/.openclaw/npm/node_modules/@tencent-weixin/openclaw-weixin",
|
||||
} as PluginInstallRecord,
|
||||
};
|
||||
|
||||
function emptyDiscoveryResult(): PluginDiscoveryResult {
|
||||
return {
|
||||
candidates: [] as PluginCandidate[],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function loadWithMocks(params: {
|
||||
loadRecords?: (env: NodeJS.ProcessEnv | undefined) => Record<string, PluginInstallRecord>;
|
||||
}): Promise<{
|
||||
module: typeof import("./channel-catalog-registry.js");
|
||||
discoverSpy: ReturnType<typeof vi.fn>;
|
||||
loadRecordsSpy: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
vi.resetModules();
|
||||
const discoverSpy = vi.fn(() => emptyDiscoveryResult());
|
||||
const loadRecordsSpy = vi.fn((opts: { env?: NodeJS.ProcessEnv } = {}) => {
|
||||
return params.loadRecords ? params.loadRecords(opts.env) : RECORDS;
|
||||
});
|
||||
|
||||
vi.doMock("./discovery.js", () => ({ discoverOpenClawPlugins: discoverSpy }));
|
||||
vi.doMock("./installed-plugin-index-records.js", () => ({
|
||||
loadInstalledPluginIndexInstallRecordsSync: loadRecordsSpy,
|
||||
}));
|
||||
|
||||
const module = await import("./channel-catalog-registry.js");
|
||||
return { module, discoverSpy, loadRecordsSpy };
|
||||
}
|
||||
|
||||
describe("listChannelCatalogEntries", () => {
|
||||
it("forwards lazily loaded install records to discovery when origin is unspecified", async () => {
|
||||
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
|
||||
|
||||
module.listChannelCatalogEntries({ env: ENV });
|
||||
|
||||
expect(loadRecordsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(loadRecordsSpy).toHaveBeenCalledWith({ env: ENV });
|
||||
expect(discoverSpy).toHaveBeenCalledTimes(1);
|
||||
expect(discoverSpy.mock.calls[0][0]).toMatchObject({
|
||||
env: ENV,
|
||||
installRecords: RECORDS,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips ledger lookup when origin is 'bundled' and omits installRecords", async () => {
|
||||
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
|
||||
|
||||
module.listChannelCatalogEntries({ origin: "bundled", env: ENV });
|
||||
|
||||
expect(loadRecordsSpy).not.toHaveBeenCalled();
|
||||
expect(discoverSpy).toHaveBeenCalledTimes(1);
|
||||
expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords");
|
||||
});
|
||||
|
||||
it("uses caller-supplied install records verbatim and does not load the ledger", async () => {
|
||||
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
|
||||
const supplied: Record<string, PluginInstallRecord> = {
|
||||
slack: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/slack@1.0.0",
|
||||
} as PluginInstallRecord,
|
||||
};
|
||||
|
||||
module.listChannelCatalogEntries({ env: ENV, installRecords: supplied });
|
||||
|
||||
expect(loadRecordsSpy).not.toHaveBeenCalled();
|
||||
expect(discoverSpy.mock.calls[0][0]).toMatchObject({ installRecords: supplied });
|
||||
});
|
||||
|
||||
it("omits installRecords from discovery when the ledger is empty", async () => {
|
||||
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({
|
||||
loadRecords: () => ({}),
|
||||
});
|
||||
|
||||
module.listChannelCatalogEntries({ env: ENV });
|
||||
|
||||
expect(loadRecordsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords");
|
||||
});
|
||||
|
||||
it("treats ledger read errors as a soft fallback (no installRecords propagated)", async () => {
|
||||
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({
|
||||
loadRecords: () => {
|
||||
throw new Error("simulated reader failure");
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => module.listChannelCatalogEntries({ env: ENV })).not.toThrow();
|
||||
|
||||
expect(loadRecordsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(discoverSpy).toHaveBeenCalledTimes(1);
|
||||
expect(discoverSpy.mock.calls[0][0]).not.toHaveProperty("installRecords");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js";
|
||||
import {
|
||||
loadPluginManifest,
|
||||
type PluginPackageChannel,
|
||||
@@ -21,11 +23,20 @@ export function listChannelCatalogEntries(
|
||||
origin?: PluginOrigin;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/**
|
||||
* Optional override. When omitted and `origin !== "bundled"`, the persisted
|
||||
* plugin install ledger is loaded synchronously so that npm-installed
|
||||
* channels stored outside the discovery roots are visible to the catalog.
|
||||
* Bundled-only callers skip the load to avoid the disk read.
|
||||
*/
|
||||
installRecords?: Record<string, PluginInstallRecord>;
|
||||
} = {},
|
||||
): PluginChannelCatalogEntry[] {
|
||||
const installRecords = resolveInstallRecords(params);
|
||||
return discoverOpenClawPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
...(installRecords && Object.keys(installRecords).length > 0 ? { installRecords } : {}),
|
||||
}).candidates.flatMap((candidate) => {
|
||||
if (params.origin && candidate.origin !== params.origin) {
|
||||
return [];
|
||||
@@ -53,3 +64,21 @@ export function listChannelCatalogEntries(
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function resolveInstallRecords(params: {
|
||||
origin?: PluginOrigin;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
installRecords?: Record<string, PluginInstallRecord>;
|
||||
}): Record<string, PluginInstallRecord> | undefined {
|
||||
if (params.installRecords) {
|
||||
return params.installRecords;
|
||||
}
|
||||
if (params.origin === "bundled") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return loadInstalledPluginIndexInstallRecordsSync(params.env ? { env: params.env } : {});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user