@@ -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
370529const emptyRegistry = createTestRegistry ( [ ] ) ;
0 commit comments