fix(channels): generate catalog from package manifests (#118097)

This commit is contained in:
Vincent Koc
2026-08-03 23:17:09 +08:00
committed by GitHub
parent bbd95cc410
commit 7fabb55648
17 changed files with 2893 additions and 843 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
"detailLabel": "Signal REST",
"docsPath": "/channels/signal",
"docsLabel": "signal",
"blurb": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").",
"blurb": "signal-cli linked device with additional setup for the local REST bridge.",
"systemImage": "antenna.radiowaves.left.and.right",
"markdownCapable": true,
"setup": {
+2
View File
@@ -1466,6 +1466,8 @@
"build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && node scripts/run-with-env.mjs OPENCLAW_PLUGIN_SDK_CANONICAL_DTS=1 -- node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs",
"canvas:a2ui:native:check": "node scripts/sync-native-a2ui.mjs --check",
"channels:catalog:check": "node scripts/write-official-channel-catalog.mjs --check",
"channels:catalog:gen": "node scripts/write-official-channel-catalog.mjs --write",
"changed:lanes": "node scripts/changed-lanes.mjs",
"check": "node scripts/check.mjs",
"check:architecture": "pnpm check:import-cycles && pnpm check:madge-import-cycles && pnpm check:deprecated-api-usage && pnpm check:deprecated-jsdoc && pnpm db:kysely:check && pnpm lint:kysely && pnpm check:database-first-legacy-stores",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,164 @@
{
"entries": [
{
"name": "@wecom/wecom-openclaw-plugin",
"description": "OpenClaw WeCom channel plugin by the Tencent WeCom team.",
"source": "external",
"kind": "channel",
"openclaw": {
"plugin": {
"id": "wecom-openclaw-plugin",
"label": "WeCom"
},
"contracts": {
"tools": ["wecom_mcp"]
},
"channel": {
"id": "wecom",
"label": "WeCom",
"selectionLabel": "WeCom(企业微信)",
"detailLabel": "WeCom",
"docsPath": "/plugins/community#wecom",
"docsLabel": "wecom",
"blurb": "Enterprise messaging and documents, scheduling, task tools.",
"aliases": ["qywx", "wework", "enterprise-wechat"],
"order": 45
},
"channelConfigs": {
"wecom": {
"label": "WeCom",
"description": "Enterprise WeChat conversation channel.",
"schema": {
"type": "object",
"additionalProperties": true
}
}
},
"install": {
"npmSpec": "@wecom/wecom-openclaw-plugin@2026.5.7",
"defaultChoice": "npm",
"expectedIntegrity": "sha512-TCkP9as00WfEhgFWG8YL/rcmaWGIshAki2HQh83nTRccGfVBCoGjrEboTTqq3yDmK9koWTV11zi8u8A4dNtvug=="
}
}
},
{
"name": "openclaw-plugin-yuanbao",
"description": "OpenClaw Yuanbao channel plugin by the Tencent Yuanbao team.",
"source": "external",
"kind": "channel",
"openclaw": {
"plugin": {
"id": "openclaw-plugin-yuanbao",
"label": "Yuanbao"
},
"contracts": {
"tools": ["query_group_info", "query_session_members", "yuanbao_remind"]
},
"channel": {
"id": "yuanbao",
"label": "Yuanbao",
"selectionLabel": "Yuanbao (元宝)",
"detailLabel": "Yuanbao",
"docsPath": "/plugins/community#yuanbao",
"docsLabel": "yuanbao",
"blurb": "Tencent Yuanbao AI assistant conversation channel.",
"aliases": ["yuanbao", "yb", "tencent-yuanbao", "元宝"],
"order": 85
},
"channelConfigs": {
"yuanbao": {
"label": "Yuanbao",
"description": "Tencent Yuanbao AI assistant channel.",
"schema": {
"type": "object",
"additionalProperties": true
}
}
},
"install": {
"npmSpec": "openclaw-plugin-yuanbao@2.15.0",
"defaultChoice": "npm",
"expectedIntegrity": "sha512-3GD+mf3EjTSUTOAREjTHAyp/deXdpgqB+q+xE0b19Qtat4ADhUV1mHDwFkVCRqTCBY5ATFKtKcipoDejqFj/+w=="
}
}
},
{
"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": "/channels/wechat",
"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@2.4.6",
"defaultChoice": "npm",
"expectedIntegrity": "sha512-qw9k3PLTiMWGNjjsknHgcTManH1w4j+Ji1ArWIaYLKCq3aFRsVwcqnPi127bvOoVMJGW4dbyJ8NECEMgoO+iRw==",
"minHostVersion": ">=2026.5.12"
}
}
},
{
"name": "@zalo-platforms/openclaw-zaloclawbot",
"description": "OpenClaw Zalo ClawBot channel plugin by the Zalo Platforms team.",
"source": "external",
"kind": "channel",
"openclaw": {
"plugin": {
"id": "openclaw-zaloclawbot",
"label": "Zalo ClawBot"
},
"channel": {
"id": "openclaw-zaloclawbot",
"label": "Zalo ClawBot",
"selectionLabel": "Zalo ClawBot (QR)",
"detailLabel": "Zalo ClawBot",
"docsPath": "/channels/zaloclawbot",
"docsLabel": "zaloclawbot",
"blurb": "Personal Zalo assistant bot via QR-code login — owner-bound, no setup.",
"aliases": ["zaloclawbot", "zalo-clawbot"],
"order": 82
},
"channelConfigs": {
"openclaw-zaloclawbot": {
"label": "Zalo ClawBot",
"description": "Personal Zalo assistant — QR-onboarded, owner-bound.",
"schema": {
"type": "object",
"additionalProperties": true
}
}
},
"install": {
"npmSpec": "@zalo-platforms/openclaw-zaloclawbot@0.1.4",
"defaultChoice": "npm",
"expectedIntegrity": "sha512-5IxZriHJYACLLGqkCPPsTP9tas62kXEOFqTFAFMdunAM3SPhIJwVFRp0WvoP/m7L2PX85weD0g8LOtxM93VDYg==",
"minHostVersion": ">=2026.4.10"
}
}
}
]
}
+8
View File
@@ -24,6 +24,14 @@ const releaseTasks = [
fix: nodeCommand("--import", "tsx", "scripts/sync-plugin-versions.ts"),
check: nodeCommand("--import", "tsx", "scripts/sync-plugin-versions.ts", "--check"),
},
{
id: "channel-catalog",
name: "official channel catalog",
scopes: ["plugins", "version"],
fix: pnpmCommand("channels:catalog:gen"),
fixAfter: ["plugin-versions"],
check: pnpmCommand("channels:catalog:check"),
},
{
id: "npm-package-locks",
name: "npm package locks",
+1 -1
View File
@@ -2276,7 +2276,7 @@ const SEMANTIC_TOOLING_TARGET_PATTERNS = [
],
[/^scripts\/lib\/plugin-sdk-entries\.mjs$/u, pluginSdkEntryOwners],
[
/^scripts\/lib\/official-external-channel-catalog\.json$/u,
/^scripts\/lib\/official-external-channel-(?:catalog|seed)\.json$/u,
[
"src/plugins/official-external-plugin-catalog.test.ts",
releaseCheck,
+16 -1
View File
@@ -1,3 +1,4 @@
export const OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH: "scripts/lib/official-external-channel-catalog.json";
export const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH: "dist/channel-catalog.json";
export function buildOfficialChannelCatalog(params?: { repoRoot?: string; cwd?: string }): {
@@ -6,9 +7,14 @@ export function buildOfficialChannelCatalog(params?: { repoRoot?: string; cwd?:
version?: string;
description?: string;
source?: string;
kind?: string;
openclaw: {
plugin?: Record<string, unknown>;
catalog?: Record<string, unknown>;
contracts?: Record<string, string[] | undefined>;
channel: Record<string, unknown>;
channelConfigs?: Record<string, { schema?: unknown }>;
providerEndpoints?: Array<Record<string, unknown>>;
install: {
clawhubSpec?: string;
npmSpec?: string;
@@ -22,4 +28,13 @@ export function buildOfficialChannelCatalog(params?: { repoRoot?: string; cwd?:
}>;
};
export function writeOfficialChannelCatalog(params?: { repoRoot?: string; cwd?: string }): void;
export function renderOfficialChannelCatalog(params?: { repoRoot?: string; cwd?: string }): string;
export function writeOfficialChannelCatalog(params?: { repoRoot?: string; cwd?: string }): boolean;
export function writeOfficialChannelCatalogSource(params?: {
repoRoot?: string;
cwd?: string;
}): boolean;
export function checkOfficialChannelCatalogSource(params?: {
repoRoot?: string;
cwd?: string;
}): boolean;
+132 -29
View File
@@ -2,10 +2,14 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import officialExternalChannelCatalog from "./lib/official-external-channel-catalog.json" with { type: "json" };
import officialExternalChannelSeed from "./lib/official-external-channel-seed.json" with { type: "json" };
import { isRecord, trimString } from "./lib/record-shared.mjs";
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
/** Generated official channel catalog committed for source and packaged runtime consumers. */
export const OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH =
"scripts/lib/official-external-channel-catalog.json";
/**
* Generated official channel catalog path in dist.
* @internal Directly tested script implementation detail.
@@ -34,7 +38,23 @@ function toCatalogInstall(value, packageName) {
};
}
function buildCatalogEntry(packageJson) {
function toCatalogManifestFields(value) {
const manifest = isRecord(value) ? value : {};
const catalog = isRecord(manifest.catalog) ? manifest.catalog : null;
const contracts = isRecord(manifest.contracts) ? manifest.contracts : null;
const channelConfigs = isRecord(manifest.channelConfigs) ? manifest.channelConfigs : null;
const providerEndpoints = Array.isArray(manifest.providerEndpoints)
? manifest.providerEndpoints
: null;
return {
...(catalog ? { catalog } : {}),
...(contracts ? { contracts } : {}),
...(channelConfigs ? { channelConfigs } : {}),
...(providerEndpoints ? { providerEndpoints } : {}),
};
}
function buildCatalogEntry(packageJson, pluginManifest) {
if (!isRecord(packageJson)) {
return null;
}
@@ -55,7 +75,10 @@ function buildCatalogEntry(packageJson) {
name: packageName,
...(version ? { version } : {}),
...(description ? { description } : {}),
source: "official",
kind: "channel",
openclaw: {
...toCatalogManifestFields(pluginManifest),
channel,
install,
},
@@ -66,6 +89,25 @@ function getCatalogChannelId(entry) {
return trimString(entry?.openclaw?.channel?.id) || trimString(entry?.name);
}
function getCatalogChannelKey(entry) {
return getCatalogChannelId(entry).toLowerCase();
}
function setUniqueCatalogEntry(entriesByChannelId, entry, owner) {
const channelId = getCatalogChannelId(entry);
const channelKey = getCatalogChannelKey(entry);
if (!channelKey) {
throw new Error(`official channel catalog entry from ${owner} is missing a channel id`);
}
const existing = entriesByChannelId.get(channelKey);
if (existing) {
throw new Error(
`duplicate official channel id "${channelId}" from ${existing.owner} and ${owner}`,
);
}
entriesByChannelId.set(channelKey, { entry, owner });
}
/**
* Collects publishable channel catalog entries from bundled and external channels.
* @internal Directly tested script implementation detail.
@@ -73,36 +115,61 @@ function getCatalogChannelId(entry) {
export function buildOfficialChannelCatalog(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const extensionsRoot = path.join(repoRoot, "extensions");
const entries = Array.isArray(officialExternalChannelCatalog.entries)
? [...officialExternalChannelCatalog.entries]
: [];
if (!fs.existsSync(extensionsRoot)) {
return { entries };
const seedEntriesByChannelId = new Map();
for (const entry of Array.isArray(officialExternalChannelSeed.entries)
? officialExternalChannelSeed.entries
: []) {
setUniqueCatalogEntry(
seedEntriesByChannelId,
entry,
`scripts/lib/official-external-channel-seed.json package "${trimString(entry?.name)}"`,
);
}
for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const packageJsonPath = path.join(extensionsRoot, dirent.name, "package.json");
if (!fs.existsSync(packageJsonPath)) {
continue;
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
const entry = buildCatalogEntry(packageJson);
const channelId = entry ? getCatalogChannelId(entry) : "";
const alreadyPresent = channelId
? entries.some((existing) => getCatalogChannelId(existing) === channelId)
: false;
if (entry && !alreadyPresent) {
entries.push(entry);
const repositoryEntriesByChannelId = new Map();
if (fs.existsSync(extensionsRoot)) {
const extensionDirectories = fs
.readdirSync(extensionsRoot, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.toSorted((left, right) => left.name.localeCompare(right.name));
for (const dirent of extensionDirectories) {
if (!dirent.isDirectory()) {
continue;
}
const packageJsonPath = path.join(extensionsRoot, dirent.name, "package.json");
if (!fs.existsSync(packageJsonPath)) {
continue;
}
let packageJson;
let pluginManifest;
try {
packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
const pluginManifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json");
pluginManifest = fs.existsSync(pluginManifestPath)
? JSON.parse(fs.readFileSync(pluginManifestPath, "utf8"))
: undefined;
} catch {
// Ignore invalid package metadata and keep generating the rest of the catalog.
continue;
}
const entry = buildCatalogEntry(packageJson, pluginManifest);
if (entry) {
setUniqueCatalogEntry(
repositoryEntriesByChannelId,
entry,
`extensions/${dirent.name}/package.json`,
);
}
} catch {
// Ignore invalid package metadata and keep generating the rest of the catalog.
}
}
// Repository packages deliberately replace same-id external seeds when a
// channel moves in tree. Duplicates within either ownership class are errors.
const entriesByChannelId = new Map(seedEntriesByChannelId);
for (const [channelId, entry] of repositoryEntriesByChannelId) {
entriesByChannelId.set(channelId, entry);
}
const entries = [...entriesByChannelId.values()].map(({ entry }) => entry);
entries.sort((left, right) => {
const leftId = trimString(left.openclaw?.channel?.id) || left.name;
const rightId = trimString(right.openclaw?.channel?.id) || right.name;
@@ -112,13 +179,49 @@ export function buildOfficialChannelCatalog(params = {}) {
return { entries };
}
export function renderOfficialChannelCatalog(params = {}) {
return `${JSON.stringify(buildOfficialChannelCatalog(params), null, 2)}\n`;
}
export function writeOfficialChannelCatalog(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
const catalog = buildOfficialChannelCatalog({ repoRoot });
writeTextFileIfChanged(outputPath, `${JSON.stringify(catalog, null, 2)}\n`);
return writeTextFileIfChanged(outputPath, renderOfficialChannelCatalog({ repoRoot }));
}
export function writeOfficialChannelCatalogSource(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH);
return writeTextFileIfChanged(outputPath, renderOfficialChannelCatalog({ repoRoot }));
}
export function checkOfficialChannelCatalogSource(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH);
const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : "";
return current === renderOfficialChannelCatalog({ repoRoot });
}
function main(argv = process.argv.slice(2)) {
const write = argv.includes("--write");
const check = argv.includes("--check");
if (write === check) {
console.error("usage: node scripts/write-official-channel-catalog.mjs --write|--check");
process.exitCode = 2;
return;
}
if (write) {
writeOfficialChannelCatalogSource();
return;
}
if (!checkOfficialChannelCatalogSource()) {
console.error(
`${OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH} is stale. Run \`pnpm channels:catalog:gen\`.`,
);
process.exitCode = 1;
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
writeOfficialChannelCatalog();
main();
}
File diff suppressed because one or more lines are too long
+32
View File
@@ -1812,6 +1812,38 @@ describe("loadPluginManifestRegistry", () => {
expectNoRegistryDiagnosticContains(registry, "without channelConfigs metadata");
});
it("hydrates Slack channel config metadata for lagging npm manifests", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "slack",
channels: ["slack"],
configSchema: { type: "object" },
});
const registry = loadRegistry([
createPluginCandidate({
idHint: "slack",
rootDir: dir,
origin: "global",
packageName: "@openclaw/slack",
}),
]);
const slackConfig = expectRecordFields(
registry.plugins[0]?.channelConfigs?.slack,
"slack config",
{
label: "Slack",
description: "Slack channel, DM, command, and app event integration.",
},
);
expectRecordFields(slackConfig.schema, "slack schema", {
type: "object",
additionalProperties: true,
});
expectNoRegistryDiagnosticContains(registry, "without channelConfigs metadata");
});
it("hydrates and overlays official external catalog curation metadata", () => {
const dir = makeTempDir();
writeManifest(dir, {
@@ -14,6 +14,7 @@ import {
getOfficialExternalPluginCatalogEntry,
getOfficialExternalPluginCatalogManifest,
isOfficialExternalPluginCatalogFeed,
listOfficialExternalChannelEnvVars,
listOfficialExternalPluginCatalogEntries,
loadConfiguredHostedOfficialExternalPluginCatalogEntries,
resolveOfficialExternalProviderContractPluginIds,
@@ -2245,6 +2246,15 @@ describe("official external plugin catalog", () => {
});
});
it("projects channel environment variables from generated configured-state metadata", () => {
const envVarsByChannel = new Map(
listOfficialExternalChannelEnvVars().map((entry) => [entry.channelId, entry.envVars]),
);
expect(envVarsByChannel.get("clickclack")).toEqual(["CLICKCLACK_BOT_TOKEN"]);
expect(envVarsByChannel.get("mattermost")).toEqual(["MATTERMOST_BOT_TOKEN", "MATTERMOST_URL"]);
});
it.each([
["teams-meetings", "@openclaw/teams-meetings", "teams_meetings", "teams"],
["zoom-meetings", "@openclaw/zoom-meetings", "zoom_meetings", "zoom"],
@@ -12,6 +12,7 @@ import type {
PluginManifestChannelConfig,
PluginManifestContracts,
PluginManifestProviderEndpoint,
PluginPackageChannel,
PluginPackageInstall,
} from "./manifest.js";
import { BUNDLED_OFFICIAL_EXTERNAL_PLUGIN_CATALOGS } from "./official-external-plugin-bundled-catalogs.js";
@@ -71,6 +72,11 @@ export type OfficialExternalWebSearchProvider = {
autoDetectOrder?: number;
};
type OfficialExternalCatalogChannel = PluginPackageChannel & {
/** Older hosted catalogs used a flat env list before configuredState became canonical. */
envVars?: readonly string[];
};
/** Manifest-like metadata stored in official external catalog entries. */
type OfficialExternalPluginCatalogManifest = {
plugin?: {
@@ -78,11 +84,7 @@ type OfficialExternalPluginCatalogManifest = {
label?: string;
};
catalog?: PluginManifestCatalog;
channel?: {
id?: string;
label?: string;
envVars?: readonly string[];
};
channel?: OfficialExternalCatalogChannel;
providers?: readonly OfficialExternalProviderCatalogProvider[];
/**
* Mirrors the plugin manifest's providerEndpoints so endpoint classification
@@ -1680,7 +1682,11 @@ export function listOfficialExternalChannelEnvVars(): Array<{
const channel = getOfficialExternalPluginCatalogManifest(entry)?.channel;
const channelId = normalizeOptionalString(channel?.id)?.toLowerCase();
const envVars = uniqueStrings(
(channel?.envVars ?? [])
[
...(channel?.envVars ?? []),
...(channel?.configuredState?.env?.allOf ?? []),
...(channel?.configuredState?.env?.anyOf ?? []),
]
.map((envVar) => normalizeOptionalString(envVar))
.filter((envVar): envVar is string => Boolean(envVar)),
);
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { hasOfficialExternalChannelTarget } from "./official-external-plugin-targets.js";
describe("official external channel targets", () => {
it("detects generated any-of channel environment metadata", () => {
expect(
hasOfficialExternalChannelTarget({
config: {},
env: { CLICKCLACK_BOT_TOKEN: "token" },
}),
).toBe(true);
});
it("treats any generated all-of variable as a potential repair target", () => {
expect(
hasOfficialExternalChannelTarget({
config: {},
env: { MATTERMOST_BOT_TOKEN: "token" },
}),
).toBe(true);
expect(
hasOfficialExternalChannelTarget({
config: {},
env: {},
}),
).toBe(false);
});
});
@@ -15,8 +15,19 @@ type StaticWebProvider = {
envVars?: readonly string[];
};
type StaticConfiguredState = {
env?: {
allOf?: readonly string[];
anyOf?: readonly string[];
};
};
type StaticManifest = {
channel?: { id?: string; envVars?: readonly string[] };
channel?: {
id?: string;
envVars?: readonly string[];
configuredState?: StaticConfiguredState;
};
contracts?: Record<string, readonly string[]>;
providers?: readonly StaticProvider[];
webSearchProviders?: readonly StaticWebProvider[];
@@ -38,6 +49,15 @@ function envHasAny(env: NodeJS.ProcessEnv, names: readonly string[] | undefined)
return names?.some((name) => Boolean(env[name]?.trim())) ?? false;
}
function envHasChannelCandidate(
env: NodeJS.ProcessEnv,
channel: StaticManifest["channel"],
): boolean {
const allOf = channel?.configuredState?.env?.allOf ?? [];
const anyOf = channel?.configuredState?.env?.anyOf ?? [];
return envHasAny(env, [...(channel?.envVars ?? []), ...allOf, ...anyOf]);
}
export function hasOfficialExternalProviderTarget(params: {
providerIds: Iterable<string>;
env: NodeJS.ProcessEnv;
@@ -101,7 +121,7 @@ export function hasOfficialExternalChannelTarget(params: {
const channelConfig = channels?.[channelId];
return (
(isRecord(channelConfig) && channelConfig.enabled !== false) ||
envHasAny(params.env, channel?.envVars)
envHasChannelCandidate(params.env, channel)
);
});
}
+270 -79
View File
@@ -5,8 +5,11 @@ import { bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it } from "vitest";
import {
buildOfficialChannelCatalog,
checkOfficialChannelCatalogSource,
OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH,
OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH,
writeOfficialChannelCatalog,
writeOfficialChannelCatalogSource,
} from "../scripts/write-official-channel-catalog.mjs";
import { describePluginInstallSource } from "../src/plugins/install-source-info.js";
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js";
@@ -60,7 +63,11 @@ function summarizeCatalogEntry(entry: OfficialChannelCatalogEntry) {
description: entry.description,
source: entry.source,
plugin: entry.openclaw?.plugin,
catalog: entry.openclaw?.catalog,
contracts: entry.openclaw?.contracts,
channel: entry.openclaw?.channel,
channelConfigs: entry.openclaw?.channelConfigs,
providerEndpoints: entry.openclaw?.providerEndpoints,
install: entry.openclaw?.install,
};
}
@@ -70,32 +77,69 @@ afterEach(() => {
});
describe("buildOfficialChannelCatalog", () => {
it("includes publishable official channel plugins and skips non-publishable entries", () => {
it("keeps the committed official catalog synchronized with repository manifests", () => {
expect(checkOfficialChannelCatalogSource({ repoRoot: process.cwd() })).toBe(true);
});
it("lets publishable package metadata override same-id seeds and skips non-publishable entries", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-");
writeJson(path.join(repoRoot, "extensions", "whatsapp", "package.json"), {
name: "@openclaw/whatsapp",
version: "2026.3.23",
description: "OpenClaw WhatsApp channel plugin",
writeJson(path.join(repoRoot, "extensions", "wecom", "package.json"), {
name: "@openclaw/wecom",
version: "2026.8.1",
description: "Repository-owned WeCom channel",
openclaw: {
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
id: "wecom",
label: "Repository WeCom",
selectionLabel: "Repository WeCom",
docsPath: "/channels/wecom",
blurb: "package metadata wins",
configuredState: {
env: {
anyOf: ["WECOM_BOT_TOKEN"],
},
},
},
install: {
clawhubSpec: "clawhub:@openclaw/whatsapp",
npmSpec: "@openclaw/whatsapp",
localPath: bundledPluginRoot("whatsapp"),
defaultChoice: "clawhub",
npmSpec: "@openclaw/wecom",
defaultChoice: "npm",
},
release: {
publishToNpm: true,
},
},
});
writeJson(path.join(repoRoot, "extensions", "wecom", "openclaw.plugin.json"), {
id: "wecom",
catalog: {
featured: true,
order: 45,
},
contracts: {
tools: ["wecom_tool"],
},
channelConfigs: {
wecom: {
label: "Repository WeCom",
description: "Repository WeCom channel.",
schema: {
type: "object",
additionalProperties: true,
},
},
},
providerEndpoints: [
{
endpointClass: "wecom",
hosts: ["api.example.com"],
},
],
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
});
writeJson(path.join(repoRoot, "extensions", "local-only", "package.json"), {
name: "@openclaw/local-only",
openclaw: {
@@ -119,32 +163,51 @@ describe("buildOfficialChannelCatalog", () => {
expect(
summarizeCatalogEntry(
findCatalogEntry(entries, (entry) => entry.name === "@wecom/wecom-openclaw-plugin"),
findCatalogEntry(entries, (entry) => entry.openclaw?.channel?.id === "wecom"),
),
).toEqual({
name: "@wecom/wecom-openclaw-plugin",
description: "OpenClaw WeCom channel plugin by the Tencent WeCom team.",
source: "external",
plugin: {
id: "wecom-openclaw-plugin",
label: "WeCom",
name: "@openclaw/wecom",
description: "Repository-owned WeCom channel",
source: "official",
plugin: undefined,
catalog: {
featured: true,
order: 45,
},
contracts: {
tools: ["wecom_tool"],
},
channel: {
id: "wecom",
label: "WeCom",
selectionLabel: "WeCom(企业微信)",
detailLabel: "WeCom",
docsLabel: "wecom",
docsPath: "/plugins/community#wecom",
blurb: "Enterprise messaging and documents, scheduling, task tools.",
order: 45,
aliases: ["qywx", "wework", "enterprise-wechat"],
label: "Repository WeCom",
selectionLabel: "Repository WeCom",
docsPath: "/channels/wecom",
blurb: "package metadata wins",
configuredState: {
env: {
anyOf: ["WECOM_BOT_TOKEN"],
},
},
},
channelConfigs: {
wecom: {
label: "Repository WeCom",
description: "Repository WeCom channel.",
schema: {
type: "object",
additionalProperties: true,
},
},
},
providerEndpoints: [
{
endpointClass: "wecom",
hosts: ["api.example.com"],
},
],
install: {
npmSpec: "@wecom/wecom-openclaw-plugin@2026.5.7",
npmSpec: "@openclaw/wecom",
defaultChoice: "npm",
expectedIntegrity:
"sha512-TCkP9as00WfEhgFWG8YL/rcmaWGIshAki2HQh83nTRccGfVBCoGjrEboTTqq3yDmK9koWTV11zi8u8A4dNtvug==",
},
});
expect(
@@ -159,6 +222,10 @@ describe("buildOfficialChannelCatalog", () => {
id: "openclaw-plugin-yuanbao",
label: "Yuanbao",
},
catalog: undefined,
contracts: {
tools: ["query_group_info", "query_session_members", "yuanbao_remind"],
},
channel: {
id: "yuanbao",
label: "Yuanbao",
@@ -170,6 +237,17 @@ describe("buildOfficialChannelCatalog", () => {
order: 85,
aliases: ["yuanbao", "yb", "tencent-yuanbao", "元宝"],
},
channelConfigs: {
yuanbao: {
label: "Yuanbao",
description: "Tencent Yuanbao AI assistant channel.",
schema: {
type: "object",
additionalProperties: true,
},
},
},
providerEndpoints: undefined,
install: {
npmSpec: "openclaw-plugin-yuanbao@2.15.0",
defaultChoice: "npm",
@@ -177,38 +255,91 @@ describe("buildOfficialChannelCatalog", () => {
"sha512-3GD+mf3EjTSUTOAREjTHAyp/deXdpgqB+q+xE0b19Qtat4ADhUV1mHDwFkVCRqTCBY5ATFKtKcipoDejqFj/+w==",
},
});
expect(
summarizeCatalogEntry(
findCatalogEntry(entries, (entry) => entry.name === "@openclaw/whatsapp"),
),
).toEqual({
name: "@openclaw/whatsapp",
description: "OpenClaw WhatsApp channel plugin",
source: "official",
plugin: undefined,
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsLabel: "whatsapp",
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
systemImage: "message",
cliAddOptions: [
{
flags: "--auth-dir <path>",
description: "WhatsApp auth directory override",
},
],
},
install: {
clawhubSpec: "clawhub:@openclaw/whatsapp",
npmSpec: "@openclaw/whatsapp",
defaultChoice: "clawhub",
minHostVersion: ">=2026.4.25",
},
expect(entries.some((entry) => entry.openclaw?.channel?.id === "local-only")).toBe(false);
});
it("preserves manifest-owned fallback metadata for externalized channel packages", () => {
const entries = buildOfficialChannelCatalog({ repoRoot: process.cwd() }).entries;
const slack = findCatalogEntry(entries, (entry) => entry.openclaw?.channel?.id === "slack");
const raft = findCatalogEntry(entries, (entry) => entry.openclaw?.channel?.id === "raft");
const clickclack = findCatalogEntry(
entries,
(entry) => entry.openclaw?.channel?.id === "clickclack",
);
expect(slack.openclaw.channelConfigs?.slack?.schema).toEqual({
type: "object",
additionalProperties: true,
});
expect(raft.openclaw.channelConfigs?.raft?.schema).toMatchObject({
type: "object",
additionalProperties: false,
});
expect(clickclack.openclaw.contracts?.tools).toEqual(["discussion"]);
});
it("rejects duplicate channel ids from repository packages", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-duplicate-");
for (const dirName of ["first", "second"]) {
writeJson(path.join(repoRoot, "extensions", dirName, "package.json"), {
name: `@openclaw/${dirName}`,
openclaw: {
channel: {
id: "duplicate",
label: dirName,
},
install: {
npmSpec: `@openclaw/${dirName}`,
},
release: {
publishToNpm: true,
},
},
});
}
expect(() => buildOfficialChannelCatalog({ repoRoot })).toThrow(
'duplicate official channel id "duplicate"',
);
});
it("keeps the hand-authored seed limited to out-of-tree external channels", () => {
const seed = JSON.parse(
fs.readFileSync(path.resolve("scripts/lib/official-external-channel-seed.json"), "utf8"),
) as {
entries: Array<{
name?: string;
source?: string;
openclaw?: { channel?: { id?: string } };
}>;
};
const publishableChannelIds = new Set(
fs.readdirSync(path.resolve("extensions")).flatMap((dirName) => {
const packageJsonPath = path.resolve("extensions", dirName, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return [];
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
openclaw?: {
channel?: { id?: string };
release?: { publishToNpm?: boolean };
};
};
const channelId = packageJson.openclaw?.channel?.id;
return channelId && packageJson.openclaw?.release?.publishToNpm === true ? [channelId] : [];
}),
);
const seedChannelIds = seed.entries.map((entry) => entry.openclaw?.channel?.id?.toLowerCase());
expect(seed.entries.every((entry) => entry.source === "external")).toBe(true);
expect(seed.entries.some((entry) => entry.name?.startsWith("@openclaw/"))).toBe(false);
expect(new Set(seedChannelIds).size).toBe(seedChannelIds.length);
expect(
seed.entries.some((entry) => {
const channelId = entry.openclaw?.channel?.id;
return channelId ? publishableChannelIds.has(channelId) : false;
}),
).toBe(false);
});
it("keeps third-party official external catalog npm sources exactly pinned", () => {
@@ -227,6 +358,24 @@ describe("buildOfficialChannelCatalog", () => {
it("allows official OpenClaw channel npm specs without integrity during launch", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-openclaw-policy-");
writeJson(path.join(repoRoot, "extensions", "twitch", "package.json"), {
name: "@openclaw/twitch",
openclaw: {
channel: {
id: "twitch",
label: "Twitch",
docsPath: "/channels/twitch",
},
install: {
npmSpec: "@openclaw/twitch",
defaultChoice: "npm",
minHostVersion: ">=2026.4.10",
},
release: {
publishToNpm: true,
},
},
});
const twitch = buildOfficialChannelCatalog({ repoRoot }).entries.find(
(entry) => entry.openclaw?.channel?.id === "twitch",
);
@@ -249,6 +398,27 @@ describe("buildOfficialChannelCatalog", () => {
it("keeps iMessage available for cold install after core package externalization", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-imessage-");
writeJson(path.join(repoRoot, "extensions", "imessage", "package.json"), {
name: "@openclaw/imessage",
openclaw: {
channel: {
id: "imessage",
label: "iMessage",
aliases: ["imsg"],
docsPath: "/channels/imessage",
},
install: {
clawhubSpec: "clawhub:@openclaw/imessage",
npmSpec: "@openclaw/imessage",
defaultChoice: "npm",
minHostVersion: ">=2026.7.2",
allowInvalidConfigRecovery: true,
},
release: {
publishToNpm: true,
},
},
});
const imessage = buildOfficialChannelCatalog({ repoRoot }).entries.find(
(entry) => entry.openclaw?.channel?.id === "imessage",
);
@@ -343,30 +513,22 @@ describe("buildOfficialChannelCatalog", () => {
);
expect(summarizeCatalogEntry(whatsappEntry)).toEqual({
name: "@openclaw/whatsapp",
description: "OpenClaw WhatsApp channel plugin",
description: undefined,
source: "official",
plugin: undefined,
catalog: undefined,
contracts: undefined,
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsLabel: "whatsapp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
systemImage: "message",
cliAddOptions: [
{
flags: "--auth-dir <path>",
description: "WhatsApp auth directory override",
},
],
blurb: "wa",
},
channelConfigs: undefined,
providerEndpoints: undefined,
install: {
clawhubSpec: "clawhub:@openclaw/whatsapp",
npmSpec: "@openclaw/whatsapp",
defaultChoice: "clawhub",
minHostVersion: ">=2026.4.25",
},
});
const whatsappEntries = entries.filter(
@@ -375,4 +537,33 @@ describe("buildOfficialChannelCatalog", () => {
);
expect(whatsappEntries).toHaveLength(1);
});
it("writes and checks the committed official catalog", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-source-");
writeJson(path.join(repoRoot, "extensions", "demo", "package.json"), {
name: "@openclaw/demo",
openclaw: {
channel: {
id: "demo",
label: "Demo",
docsPath: "/channels/demo",
},
install: {
npmSpec: "@openclaw/demo",
},
release: {
publishToNpm: true,
},
},
});
expect(checkOfficialChannelCatalogSource({ repoRoot })).toBe(false);
expect(writeOfficialChannelCatalogSource({ repoRoot })).toBe(true);
expect(checkOfficialChannelCatalogSource({ repoRoot })).toBe(true);
expect(writeOfficialChannelCatalogSource({ repoRoot })).toBe(false);
const sourcePath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH);
fs.writeFileSync(sourcePath, "{}\n", "utf8");
expect(checkOfficialChannelCatalogSource({ repoRoot })).toBe(false);
});
});
+9
View File
@@ -10,6 +10,7 @@ const CHECK_COMMANDS = [
"pnpm deps:root-ownership:check",
"node scripts/generate-npm-package-lock.mjs --all",
"node --import tsx scripts/sync-plugin-versions.ts --check",
"pnpm channels:catalog:check",
"node scripts/generate-plugin-inventory-doc.mjs --check",
"pnpm config:schema:check",
"pnpm config:channels:check",
@@ -22,6 +23,7 @@ const CHECK_COMMANDS = [
];
const FIX_COMMANDS = [
"node --import tsx scripts/sync-plugin-versions.ts",
"pnpm channels:catalog:gen",
"node scripts/generate-plugin-inventory-doc.mjs --write",
"pnpm config:schema:gen",
"pnpm config:channels:gen",
@@ -191,6 +193,9 @@ describe("scripts/release-preflight.mjs", () => {
expect(events.indexOf("end node --import tsx scripts/sync-plugin-versions.ts")).toBeLessThan(
events.indexOf("start pnpm plugin-sdk:sync-exports"),
);
expect(events.indexOf("end node --import tsx scripts/sync-plugin-versions.ts")).toBeLessThan(
events.indexOf("start pnpm channels:catalog:gen"),
);
expect(events.indexOf("end pnpm plugin-sdk:sync-exports")).toBeLessThan(
events.indexOf("start node scripts/generate-plugin-inventory-doc.mjs --write"),
);
@@ -205,9 +210,11 @@ describe("scripts/release-preflight.mjs", () => {
expect(readPnpmLog(fakePnpm.logPath).toSorted()).toEqual(
[
"node --import tsx scripts/sync-plugin-versions.ts",
"pnpm channels:catalog:gen",
"node scripts/generate-plugin-inventory-doc.mjs --write",
"pnpm ui:i18n:sync",
"node --import tsx scripts/sync-plugin-versions.ts --check",
"pnpm channels:catalog:check",
"node scripts/generate-npm-package-lock.mjs --all",
"node scripts/generate-plugin-inventory-doc.mjs --check",
"pnpm ui:i18n:check",
@@ -226,8 +233,10 @@ describe("scripts/release-preflight.mjs", () => {
expect(readPnpmLog(fakePnpm.logPath).toSorted()).toEqual(
[
"node --import tsx scripts/sync-plugin-versions.ts",
"pnpm channels:catalog:gen",
"node scripts/generate-plugin-inventory-doc.mjs --write",
"node --import tsx scripts/sync-plugin-versions.ts --check",
"pnpm channels:catalog:check",
"node scripts/generate-npm-package-lock.mjs --all",
"node scripts/generate-plugin-inventory-doc.mjs --check",
].toSorted(),
+5
View File
@@ -1670,6 +1670,11 @@ describe("scripts/test-projects changed-target routing", () => {
"test/release-check.test.ts",
"test/official-channel-catalog.test.ts",
],
"scripts/lib/official-external-channel-seed.json": [
"src/plugins/official-external-plugin-catalog.test.ts",
"test/release-check.test.ts",
"test/official-channel-catalog.test.ts",
],
"scripts/lib/official-external-plugin-catalog.json": [
"src/plugins/official-external-plugin-catalog.test.ts",
"test/release-check.test.ts",