mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(channels): generate catalog from package manifests (#118097)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user