Skip to content

Commit 8dc07b8

Browse files
committed
fix(channels): preserve external catalog overrides
1 parent b84a130 commit 8dc07b8

File tree

2 files changed

+171
-7
lines changed

2 files changed

+171
-7
lines changed

src/channels/plugins/catalog.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ const ORIGIN_PRIORITY: Record<PluginOrigin, number> = {
5252
bundled: 3,
5353
};
5454

55+
const EXTERNAL_CATALOG_PRIORITY = ORIGIN_PRIORITY.bundled ?? 99;
56+
const FALLBACK_CATALOG_PRIORITY = EXTERNAL_CATALOG_PRIORITY + 1;
57+
5558
type ExternalCatalogEntry = {
5659
name?: string;
5760
version?: string;
@@ -149,12 +152,10 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
149152
path.join(packageRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH),
150153
);
151154

152-
try {
155+
if (process.execPath) {
153156
const execDir = path.dirname(process.execPath);
154157
candidates.push(path.join(execDir, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH));
155158
candidates.push(path.join(execDir, "channel-catalog.json"));
156-
} catch {
157-
// ignore
158159
}
159160

160161
return candidates.filter((entry, index, all) => entry && all.indexOf(entry) === index);
@@ -393,15 +394,15 @@ export function listChannelPluginCatalogEntries(
393394
}
394395

395396
for (const entry of loadBundledMetadataCatalogEntries(options)) {
396-
const priority = ORIGIN_PRIORITY.bundled ?? 99;
397+
const priority = FALLBACK_CATALOG_PRIORITY;
397398
const existing = resolved.get(entry.id);
398399
if (!existing || priority < existing.priority) {
399400
resolved.set(entry.id, { entry, priority });
400401
}
401402
}
402403

403404
for (const entry of loadOfficialCatalogEntries(options)) {
404-
const priority = ORIGIN_PRIORITY.bundled ?? 99;
405+
const priority = FALLBACK_CATALOG_PRIORITY;
405406
const existing = resolved.get(entry.id);
406407
if (!existing || priority < existing.priority) {
407408
resolved.set(entry.id, { entry, priority });
@@ -412,8 +413,12 @@ export function listChannelPluginCatalogEntries(
412413
.map((entry) => buildExternalCatalogEntry(entry))
413414
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
414415
for (const entry of externalEntries) {
415-
if (!resolved.has(entry.id)) {
416-
resolved.set(entry.id, { entry, priority: 99 });
416+
// External catalogs are the supported override seam for shipped fallback
417+
// metadata, but discovered plugins should still win when they are present.
418+
const priority = EXTERNAL_CATALOG_PRIORITY;
419+
const existing = resolved.get(entry.id);
420+
if (!existing || priority < existing.priority) {
421+
resolved.set(entry.id, { entry, priority });
417422
}
418423
}
419424

src/channels/plugins/plugins-core.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,165 @@ describe("channel plugin catalog", () => {
365365
expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp");
366366
expect(entry?.pluginId).toBeUndefined();
367367
});
368+
369+
it("lets external catalogs override shipped fallback channel metadata", () => {
370+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-fallback-catalog-"));
371+
const bundledDir = path.join(dir, "dist", "extensions", "whatsapp");
372+
const officialCatalogPath = path.join(dir, "channel-catalog.json");
373+
const externalCatalogPath = path.join(dir, "catalog.json");
374+
fs.mkdirSync(bundledDir, { recursive: true });
375+
fs.writeFileSync(
376+
path.join(bundledDir, "package.json"),
377+
JSON.stringify({
378+
name: "@openclaw/whatsapp",
379+
openclaw: {
380+
channel: {
381+
id: "whatsapp",
382+
label: "WhatsApp Bundled",
383+
selectionLabel: "WhatsApp Bundled",
384+
docsPath: "/channels/whatsapp",
385+
blurb: "bundled fallback",
386+
},
387+
install: {
388+
npmSpec: "@openclaw/whatsapp",
389+
},
390+
},
391+
}),
392+
"utf8",
393+
);
394+
fs.writeFileSync(
395+
officialCatalogPath,
396+
JSON.stringify({
397+
entries: [
398+
{
399+
name: "@openclaw/whatsapp",
400+
openclaw: {
401+
channel: {
402+
id: "whatsapp",
403+
label: "WhatsApp Official",
404+
selectionLabel: "WhatsApp Official",
405+
docsPath: "/channels/whatsapp",
406+
blurb: "official fallback",
407+
},
408+
install: {
409+
npmSpec: "@openclaw/whatsapp",
410+
},
411+
},
412+
},
413+
],
414+
}),
415+
"utf8",
416+
);
417+
fs.writeFileSync(
418+
externalCatalogPath,
419+
JSON.stringify({
420+
entries: [
421+
{
422+
name: "@vendor/whatsapp-fork",
423+
openclaw: {
424+
channel: {
425+
id: "whatsapp",
426+
label: "WhatsApp Fork",
427+
selectionLabel: "WhatsApp Fork",
428+
docsPath: "/channels/whatsapp",
429+
blurb: "external override",
430+
},
431+
install: {
432+
npmSpec: "@vendor/whatsapp-fork",
433+
},
434+
},
435+
},
436+
],
437+
}),
438+
"utf8",
439+
);
440+
441+
const entry = listChannelPluginCatalogEntries({
442+
catalogPaths: [externalCatalogPath],
443+
officialCatalogPaths: [officialCatalogPath],
444+
env: {
445+
...process.env,
446+
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(dir, "dist", "extensions"),
447+
},
448+
}).find((item) => item.id === "whatsapp");
449+
450+
expect(entry?.install.npmSpec).toBe("@vendor/whatsapp-fork");
451+
expect(entry?.meta.label).toBe("WhatsApp Fork");
452+
expect(entry?.pluginId).toBeUndefined();
453+
});
454+
455+
it("keeps discovered plugins ahead of external catalog overrides", () => {
456+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-"));
457+
const pluginDir = path.join(stateDir, "extensions", "demo-channel-plugin");
458+
const catalogPath = path.join(stateDir, "catalog.json");
459+
fs.mkdirSync(pluginDir, { recursive: true });
460+
fs.writeFileSync(
461+
path.join(pluginDir, "package.json"),
462+
JSON.stringify({
463+
name: "@vendor/demo-channel-plugin",
464+
openclaw: {
465+
extensions: ["./index.js"],
466+
channel: {
467+
id: "demo-channel",
468+
label: "Demo Channel Runtime",
469+
selectionLabel: "Demo Channel Runtime",
470+
docsPath: "/channels/demo-channel",
471+
blurb: "discovered plugin",
472+
},
473+
install: {
474+
npmSpec: "@vendor/demo-channel-plugin",
475+
},
476+
},
477+
}),
478+
"utf8",
479+
);
480+
fs.writeFileSync(
481+
path.join(pluginDir, "openclaw.plugin.json"),
482+
JSON.stringify({
483+
id: "@vendor/demo-channel-runtime",
484+
configSchema: {},
485+
}),
486+
"utf8",
487+
);
488+
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf8");
489+
fs.writeFileSync(
490+
catalogPath,
491+
JSON.stringify({
492+
entries: [
493+
{
494+
name: "@vendor/demo-channel-catalog",
495+
openclaw: {
496+
channel: {
497+
id: "demo-channel",
498+
label: "Demo Channel Catalog",
499+
selectionLabel: "Demo Channel Catalog",
500+
docsPath: "/channels/demo-channel",
501+
blurb: "external catalog",
502+
},
503+
install: {
504+
npmSpec: "@vendor/demo-channel-catalog",
505+
},
506+
},
507+
},
508+
],
509+
}),
510+
"utf8",
511+
);
512+
513+
const entry = listChannelPluginCatalogEntries({
514+
catalogPaths: [catalogPath],
515+
env: {
516+
...process.env,
517+
OPENCLAW_STATE_DIR: stateDir,
518+
CLAWDBOT_STATE_DIR: undefined,
519+
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
520+
},
521+
}).find((item) => item.id === "demo-channel");
522+
523+
expect(entry?.install.npmSpec).toBe("@vendor/demo-channel-plugin");
524+
expect(entry?.meta.label).toBe("Demo Channel Runtime");
525+
expect(entry?.pluginId).toBe("@vendor/demo-channel-runtime");
526+
});
368527
});
369528

370529
const emptyRegistry = createTestRegistry([]);

0 commit comments

Comments
 (0)