mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
84c7d45f15
* refactor(qqbot): remove bundled extension source Mechanical deletion half of the #107295 squashed rebase; the catalog repoint and host integration land in the follow-up commit. Co-authored-by: sliverp <870080352@qq.com> * refactor(qqbot): install plugin from Tencent package Squashed rebase of #107295 onto current main. Repoints the official external channel catalog at @tencent-connect/openclaw-qqbot@2.0.1 and adapts onboarding, doctor migrations, secrets, build guards, and tests. Documents the known limitation that the external package does not support structured SecretRef clientSecret values; operators move those to QQBOT_CLIENT_SECRET or clientSecretFile before upgrading. Co-authored-by: sliverp <870080352@qq.com> * fix(doctor): reuse shared hasOwnKey record helper The rebased QQBot migration carried its own hasOwnKey export, colliding with the one main now ships in legacy-config-record-shared.ts. Co-authored-by: sliverp <870080352@qq.com> * fix(plugins): carry catalog integrity through the update bridge The externalized-bundled-plugin bridge dropped the official catalog's expectedIntegrity pin, so bundled-user updates installed the external npm package without integrity verification. The bridge now carries the pin for the catalog's exact npm spec and both bridge install calls pass it through; update-channel spec overrides intentionally skip the pin since it only covers the pinned version. Co-authored-by: sliverp <870080352@qq.com> * chore(plugin-sdk): refresh per-entrypoint API baselines The QQBot compat export and bundled-type removal shift 26 entrypoint closure hashes in the new split baseline layout. Co-authored-by: sliverp <870080352@qq.com> * refactor(qqbot): drop helper reintroduced during rebase Main's coercion consolidation added this file after the deletion commit's base; its only consumers were the removed qqbot sources. Co-authored-by: sliverp <870080352@qq.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
723 lines
26 KiB
TypeScript
723 lines
26 KiB
TypeScript
// Builds the generated official channel catalog from publishable channel plugins.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { parse as parseYaml } from "yaml";
|
|
import officialExternalChannelSeed from "./lib/official-external-channel-seed.json" with { type: "json" };
|
|
import { collectExcludedPackagedExtensionDirs } from "./lib/packaged-extension-dirs.mts";
|
|
import { isRecord, trimString } from "./lib/record-shared.mjs";
|
|
import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs";
|
|
|
|
type CatalogParams = { repoRoot?: string; cwd?: string };
|
|
type CatalogInstall = Partial<
|
|
Record<"clawhubSpec" | "npmSpec" | "localPath" | "minHostVersion" | "expectedIntegrity", string>
|
|
> & {
|
|
defaultChoice?: "clawhub" | "npm" | "local";
|
|
allowInvalidConfigRecovery?: boolean;
|
|
};
|
|
type CatalogEntry = Partial<Record<"version" | "description" | "source" | "kind", string>> & {
|
|
name: string;
|
|
openclaw: {
|
|
plugin?: Record<string, unknown>;
|
|
catalog?: Record<string, unknown>;
|
|
contracts?: Record<string, string[] | undefined>;
|
|
channel: Record<string, unknown>;
|
|
channelHostConfig?: Record<string, unknown>;
|
|
channelConfigs?: Record<string, { schema?: unknown }>;
|
|
providerEndpoints?: Array<Record<string, unknown>>;
|
|
install: CatalogInstall;
|
|
};
|
|
};
|
|
type CatalogOwnerEntry = { entry: CatalogEntry; owner: string };
|
|
type RepositoryPackageJson = { dirName: string; packageJson: unknown; pluginManifest?: unknown };
|
|
type ChannelDocsSource = "official" | "external" | "bundled" | "built-in";
|
|
type ChannelDocsEntry = { id: string; docsPath: string; source: ChannelDocsSource };
|
|
type CompleteChannelDocsEntry = ChannelDocsEntry & { label: string; summary: string };
|
|
|
|
/** 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";
|
|
export const OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH = "docs/channels/index.md";
|
|
const OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH = "docs/docs.json";
|
|
|
|
/**
|
|
* Generated official channel catalog path in dist.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = "dist/channel-catalog.json";
|
|
|
|
const OFFICIAL_CHANNEL_DOCS_START_MARKER = "<!-- BEGIN GENERATED: official channel catalog -->";
|
|
const OFFICIAL_CHANNEL_DOCS_END_MARKER = "<!-- END GENERATED: official channel catalog -->";
|
|
const WEBCHAT_DOCS_ENTRY = {
|
|
id: "webchat",
|
|
docsPath: "/web/webchat",
|
|
source: "built-in",
|
|
} satisfies ChannelDocsEntry;
|
|
|
|
function readRepositoryPackageJsons(repoRoot: string) {
|
|
const extensionsRoot = path.join(repoRoot, "extensions");
|
|
if (!fs.existsSync(extensionsRoot)) {
|
|
return [];
|
|
}
|
|
|
|
const packageJsons: RepositoryPackageJson[] = [];
|
|
const extensionDirectories = fs
|
|
.readdirSync(extensionsRoot, { withFileTypes: true })
|
|
.filter((dirent) => dirent.isDirectory())
|
|
.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
for (const dirent of extensionDirectories) {
|
|
const packageJsonPath = path.join(extensionsRoot, dirent.name, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
continue;
|
|
}
|
|
try {
|
|
const pluginManifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json");
|
|
packageJsons.push({
|
|
dirName: dirent.name,
|
|
packageJson: JSON.parse(fs.readFileSync(packageJsonPath, "utf8")),
|
|
pluginManifest: fs.existsSync(pluginManifestPath)
|
|
? JSON.parse(fs.readFileSync(pluginManifestPath, "utf8"))
|
|
: undefined,
|
|
});
|
|
} catch {
|
|
// Invalid package metadata must not prevent unrelated channels from being generated.
|
|
}
|
|
}
|
|
return packageJsons;
|
|
}
|
|
|
|
function readExcludedPackagedExtensionDirs(repoRoot: string) {
|
|
const packageJsonPath = path.join(repoRoot, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
return new Set();
|
|
}
|
|
const rootPackageJson: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
const files = isRecord(rootPackageJson) ? rootPackageJson.files : undefined;
|
|
return collectExcludedPackagedExtensionDirs({ files: Array.isArray(files) ? files : undefined });
|
|
}
|
|
|
|
function toCatalogInstall(value: unknown, packageName: string): CatalogInstall | null {
|
|
const install = isRecord(value) ? value : {};
|
|
const clawhubSpec = trimString(install.clawhubSpec);
|
|
const npmSpec = trimString(install.npmSpec) || packageName;
|
|
if (!clawhubSpec && !npmSpec) {
|
|
return null;
|
|
}
|
|
const rawDefaultChoice = trimString(install.defaultChoice);
|
|
const defaultChoice =
|
|
rawDefaultChoice === "clawhub" || rawDefaultChoice === "npm" || rawDefaultChoice === "local"
|
|
? rawDefaultChoice
|
|
: undefined;
|
|
const minHostVersion = trimString(install.minHostVersion);
|
|
const expectedIntegrity = trimString(install.expectedIntegrity);
|
|
return {
|
|
...(clawhubSpec ? { clawhubSpec } : {}),
|
|
...(npmSpec ? { npmSpec } : {}),
|
|
...(defaultChoice ? { defaultChoice } : {}),
|
|
...(minHostVersion ? { minHostVersion } : {}),
|
|
...(expectedIntegrity ? { expectedIntegrity } : {}),
|
|
...(install.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}),
|
|
};
|
|
}
|
|
|
|
function toCatalogContracts(value: unknown) {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
const contracts: Record<string, string[] | undefined> = {};
|
|
for (const [name, members] of Object.entries(value)) {
|
|
if (
|
|
members === undefined ||
|
|
(Array.isArray(members) && members.every((item) => typeof item === "string"))
|
|
) {
|
|
contracts[name] = members;
|
|
}
|
|
}
|
|
return contracts;
|
|
}
|
|
|
|
function toCatalogChannelConfigs(value: unknown) {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
const channelConfigs: Record<string, { schema?: unknown }> = {};
|
|
for (const [channelId, config] of Object.entries(value)) {
|
|
if (isRecord(config)) {
|
|
channelConfigs[channelId] = config;
|
|
}
|
|
}
|
|
return channelConfigs;
|
|
}
|
|
|
|
function toCatalogProviderEndpoints(value: unknown) {
|
|
return Array.isArray(value) ? value.filter(isRecord) : null;
|
|
}
|
|
|
|
function toCatalogManifestFields(value: unknown) {
|
|
const manifest = isRecord(value) ? value : {};
|
|
const catalog = isRecord(manifest.catalog) ? manifest.catalog : null;
|
|
const contracts = toCatalogContracts(manifest.contracts);
|
|
const channelConfigs = toCatalogChannelConfigs(manifest.channelConfigs);
|
|
const providerEndpoints = toCatalogProviderEndpoints(manifest.providerEndpoints);
|
|
return {
|
|
...(catalog ? { catalog } : {}),
|
|
...(contracts ? { contracts } : {}),
|
|
...(channelConfigs ? { channelConfigs } : {}),
|
|
...(providerEndpoints ? { providerEndpoints } : {}),
|
|
};
|
|
}
|
|
|
|
function buildCatalogEntry(packageJson: unknown, pluginManifest: unknown): CatalogEntry | null {
|
|
if (!isRecord(packageJson)) {
|
|
return null;
|
|
}
|
|
const packageName = trimString(packageJson.name);
|
|
const manifest = isRecord(packageJson.openclaw) ? packageJson.openclaw : null;
|
|
const release = manifest && isRecord(manifest.release) ? manifest.release : null;
|
|
const channel = manifest && isRecord(manifest.channel) ? manifest.channel : null;
|
|
if (!packageName || !channel || release?.publishToNpm !== true) {
|
|
return null;
|
|
}
|
|
const install = toCatalogInstall(manifest?.install, packageName);
|
|
if (!install) {
|
|
return null;
|
|
}
|
|
const version = trimString(packageJson.version);
|
|
const description = trimString(packageJson.description);
|
|
return {
|
|
name: packageName,
|
|
...(version ? { version } : {}),
|
|
...(description ? { description } : {}),
|
|
source: "official",
|
|
kind: "channel",
|
|
openclaw: {
|
|
...toCatalogManifestFields(pluginManifest),
|
|
channel,
|
|
install,
|
|
},
|
|
};
|
|
}
|
|
|
|
function getCatalogChannelId(entry: CatalogEntry) {
|
|
return trimString(entry.openclaw.channel.id) || trimString(entry.name);
|
|
}
|
|
|
|
function getCatalogChannelKey(entry: CatalogEntry) {
|
|
return getCatalogChannelId(entry).toLowerCase();
|
|
}
|
|
|
|
function setUniqueCatalogEntry(
|
|
entriesByChannelId: Map<string, CatalogOwnerEntry>,
|
|
entry: CatalogEntry,
|
|
owner: string,
|
|
) {
|
|
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 });
|
|
}
|
|
|
|
function stripSeedOnlyDocsMetadata(entry: CatalogEntry): CatalogEntry {
|
|
const hostConfig = isRecord(entry.openclaw.channelHostConfig)
|
|
? entry.openclaw.channelHostConfig
|
|
: null;
|
|
if (!hostConfig || !("docsInventory" in hostConfig)) {
|
|
return entry;
|
|
}
|
|
const runtimeHostConfig = { ...hostConfig };
|
|
delete runtimeHostConfig.docsInventory;
|
|
return {
|
|
...entry,
|
|
openclaw: {
|
|
...entry.openclaw,
|
|
channelHostConfig: runtimeHostConfig,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Collects publishable channel catalog entries from bundled and external channels.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function buildOfficialChannelCatalog(params: CatalogParams = {}): {
|
|
entries: CatalogEntry[];
|
|
} {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const seedEntriesByChannelId = new Map<string, CatalogOwnerEntry>();
|
|
for (const entry of Array.isArray(officialExternalChannelSeed.entries)
|
|
? officialExternalChannelSeed.entries
|
|
: []) {
|
|
const defaultChoice = entry.openclaw.install.defaultChoice;
|
|
if (defaultChoice !== "clawhub" && defaultChoice !== "npm" && defaultChoice !== "local") {
|
|
throw new Error(`invalid install choice for official channel seed package "${entry.name}"`);
|
|
}
|
|
const channelConfigs = toCatalogChannelConfigs(entry.openclaw.channelConfigs);
|
|
if (!channelConfigs) {
|
|
throw new Error(`invalid channel configs for official channel seed package "${entry.name}"`);
|
|
}
|
|
const catalogEntry = {
|
|
...entry,
|
|
openclaw: {
|
|
...entry.openclaw,
|
|
channelConfigs,
|
|
install: { ...entry.openclaw.install, defaultChoice },
|
|
},
|
|
} satisfies CatalogEntry;
|
|
setUniqueCatalogEntry(
|
|
seedEntriesByChannelId,
|
|
stripSeedOnlyDocsMetadata(catalogEntry),
|
|
`scripts/lib/official-external-channel-seed.json package "${trimString(entry.name)}"`,
|
|
);
|
|
}
|
|
|
|
const repositoryEntriesByChannelId = new Map<string, CatalogOwnerEntry>();
|
|
for (const { dirName, packageJson, pluginManifest } of readRepositoryPackageJsons(repoRoot)) {
|
|
const entry = buildCatalogEntry(packageJson, pluginManifest);
|
|
if (entry) {
|
|
setUniqueCatalogEntry(
|
|
repositoryEntriesByChannelId,
|
|
entry,
|
|
`extensions/${dirName}/package.json`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
return leftId.localeCompare(rightId);
|
|
});
|
|
|
|
return { entries };
|
|
}
|
|
|
|
function renderOfficialChannelCatalog(params: CatalogParams = {}) {
|
|
return `${JSON.stringify(buildOfficialChannelCatalog(params), null, 2)}\n`;
|
|
}
|
|
|
|
export function writeOfficialChannelCatalog(params: CatalogParams = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
|
|
return writeTextFileIfChanged(outputPath, renderOfficialChannelCatalog({ repoRoot }));
|
|
}
|
|
|
|
export function writeOfficialChannelCatalogSource(params: CatalogParams = {}) {
|
|
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: CatalogParams = {}) {
|
|
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 toChannelDocsEntry(
|
|
entry: {
|
|
source?: string;
|
|
openclaw: {
|
|
channel: Record<string, unknown>;
|
|
channelHostConfig?: Record<string, unknown>;
|
|
};
|
|
},
|
|
sourceOverride?: ChannelDocsSource,
|
|
) {
|
|
const channel = isRecord(entry.openclaw.channel) ? entry.openclaw.channel : null;
|
|
const hostConfig = isRecord(entry.openclaw.channelHostConfig)
|
|
? entry.openclaw.channelHostConfig
|
|
: null;
|
|
const exposure = channel && isRecord(channel.exposure) ? channel.exposure : null;
|
|
if (!channel || exposure?.docs === false) {
|
|
return null;
|
|
}
|
|
const id = trimString(channel.id);
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
const docsPath = trimString(channel.docsPath) || `/channels/${id}`;
|
|
const source = sourceOverride ?? (trimString(hostConfig?.docsSource) || trimString(entry.source));
|
|
return {
|
|
id,
|
|
docsPath,
|
|
source:
|
|
source === "external" || source === "bundled" || source === "built-in" ? source : "official",
|
|
} satisfies ChannelDocsEntry;
|
|
}
|
|
|
|
function readChannelDocsFrontmatter(repoRoot: string, entry: ChannelDocsEntry) {
|
|
const route = entry.docsPath.split(/[?#]/u, 1)[0]?.replace(/^\/+/u, "");
|
|
if (!route) {
|
|
throw new Error(`channel ${entry.id} has an invalid docs route: ${entry.docsPath}`);
|
|
}
|
|
const candidates = [
|
|
path.join(repoRoot, "docs", `${route}.md`),
|
|
path.join(repoRoot, "docs", `${route}.mdx`),
|
|
];
|
|
const docsPath = candidates.find((candidate) => fs.existsSync(candidate));
|
|
if (!docsPath) {
|
|
throw new Error(`channel ${entry.id} docs route does not resolve: ${entry.docsPath}`);
|
|
}
|
|
const content = fs.readFileSync(docsPath, "utf8");
|
|
const frontmatterMatch = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(content);
|
|
if (!frontmatterMatch) {
|
|
throw new Error(`${path.relative(repoRoot, docsPath)} is missing YAML frontmatter`);
|
|
}
|
|
const frontmatter: unknown = parseYaml(frontmatterMatch[1] ?? "");
|
|
const label = trimString(isRecord(frontmatter) ? frontmatter.title : undefined);
|
|
const summary = trimString(isRecord(frontmatter) ? frontmatter.summary : undefined);
|
|
if (!label || !summary) {
|
|
throw new Error(`${path.relative(repoRoot, docsPath)} must define title and summary`);
|
|
}
|
|
return { ...entry, label, summary };
|
|
}
|
|
|
|
function isInstallableChannelManifest(manifest: Record<string, unknown>) {
|
|
const release = isRecord(manifest.release) ? manifest.release : null;
|
|
const install = isRecord(manifest.install) ? manifest.install : null;
|
|
return (
|
|
release?.publishToNpm === true ||
|
|
release?.publishToClawHub === true ||
|
|
Boolean(trimString(install?.npmSpec) || trimString(install?.clawhubSpec))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds the public docs projection from the install catalog plus bundled channel manifests.
|
|
* @internal Directly tested script implementation detail.
|
|
*/
|
|
export function buildOfficialChannelDocsCatalog(params: CatalogParams = {}): {
|
|
entries: CompleteChannelDocsEntry[];
|
|
} {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const entriesByChannelId = new Map<string, ChannelDocsEntry>();
|
|
const excludedPackagedExtensionDirs = readExcludedPackagedExtensionDirs(repoRoot);
|
|
|
|
for (const entry of buildOfficialChannelCatalog({ repoRoot }).entries) {
|
|
const docsEntry = toChannelDocsEntry(entry);
|
|
if (docsEntry) {
|
|
entriesByChannelId.set(docsEntry.id, docsEntry);
|
|
}
|
|
}
|
|
|
|
for (const { dirName, packageJson } of readRepositoryPackageJsons(repoRoot)) {
|
|
const manifest =
|
|
isRecord(packageJson) && isRecord(packageJson.openclaw) ? packageJson.openclaw : {};
|
|
const channel = isRecord(manifest.channel) ? manifest.channel : null;
|
|
if (!channel) {
|
|
continue;
|
|
}
|
|
const exposure = isRecord(channel.exposure) ? channel.exposure : null;
|
|
const isCoreBundled = !excludedPackagedExtensionDirs.has(dirName);
|
|
const isInstallable = isInstallableChannelManifest(manifest);
|
|
if (!isCoreBundled && !isInstallable) {
|
|
if (exposure?.docs === false) {
|
|
continue;
|
|
}
|
|
throw new Error(
|
|
`docs-visible channel ${trimString(channel.id) || dirName} is neither bundled nor installable`,
|
|
);
|
|
}
|
|
const docsEntry = toChannelDocsEntry(
|
|
{ openclaw: { channel } },
|
|
isCoreBundled ? "bundled" : "official",
|
|
);
|
|
if (docsEntry) {
|
|
entriesByChannelId.set(docsEntry.id, docsEntry);
|
|
}
|
|
}
|
|
|
|
entriesByChannelId.set(WEBCHAT_DOCS_ENTRY.id, WEBCHAT_DOCS_ENTRY);
|
|
const entries = [...entriesByChannelId.values()].map((entry) =>
|
|
readChannelDocsFrontmatter(repoRoot, entry),
|
|
);
|
|
entries.sort(
|
|
(left, right) =>
|
|
left.label.localeCompare(right.label, "en") || left.id.localeCompare(right.id, "en"),
|
|
);
|
|
return { entries };
|
|
}
|
|
|
|
function renderChannelDocsSummary(entry: CompleteChannelDocsEntry) {
|
|
const summary = entry.summary.replace(/[.!?]+$/u, "");
|
|
const normalizedSummary = summary
|
|
? `${summary.slice(0, 1).toUpperCase()}${summary.slice(1)}`
|
|
: `${entry.label} messaging for OpenClaw`;
|
|
const sourceLabel =
|
|
entry.source === "external"
|
|
? "external plugin"
|
|
: entry.source === "bundled"
|
|
? "bundled plugin"
|
|
: entry.source === "built-in"
|
|
? "included in core"
|
|
: "official plugin";
|
|
return `- [${entry.label}](${entry.docsPath}) - ${normalizedSummary} (${sourceLabel}).`;
|
|
}
|
|
|
|
function renderOfficialChannelDocsBlock(params: CatalogParams = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const lines = buildOfficialChannelDocsCatalog({ repoRoot }).entries.map(renderChannelDocsSummary);
|
|
return [
|
|
OFFICIAL_CHANNEL_DOCS_START_MARKER,
|
|
"<!-- Generated by `pnpm channels:catalog:gen`. Edit manifests/seed for membership and routes; edit page frontmatter for public names and summaries. -->",
|
|
"",
|
|
...lines,
|
|
"",
|
|
OFFICIAL_CHANNEL_DOCS_END_MARKER,
|
|
].join("\n");
|
|
}
|
|
|
|
function replaceOfficialChannelDocsBlock(current: string, block: string) {
|
|
const startIndex = current.indexOf(OFFICIAL_CHANNEL_DOCS_START_MARKER);
|
|
const endIndex = current.indexOf(OFFICIAL_CHANNEL_DOCS_END_MARKER);
|
|
const startCount = current.split(OFFICIAL_CHANNEL_DOCS_START_MARKER).length - 1;
|
|
const endCount = current.split(OFFICIAL_CHANNEL_DOCS_END_MARKER).length - 1;
|
|
if (
|
|
startCount !== 1 ||
|
|
endCount !== 1 ||
|
|
startIndex === -1 ||
|
|
endIndex === -1 ||
|
|
endIndex < startIndex
|
|
) {
|
|
throw new Error(
|
|
`${OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH} must contain exactly one generated channel marker pair`,
|
|
);
|
|
}
|
|
const afterEndIndex = endIndex + OFFICIAL_CHANNEL_DOCS_END_MARKER.length;
|
|
return `${current.slice(0, startIndex)}${block}${current.slice(afterEndIndex)}`;
|
|
}
|
|
|
|
export function renderOfficialChannelDocsIndex(params: CatalogParams = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH);
|
|
const current = fs.readFileSync(outputPath, "utf8");
|
|
return replaceOfficialChannelDocsBlock(current, renderOfficialChannelDocsBlock({ repoRoot }));
|
|
}
|
|
|
|
export function writeOfficialChannelDocsIndex(params: CatalogParams = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH);
|
|
return writeTextFileIfChanged(outputPath, renderOfficialChannelDocsIndex({ repoRoot }));
|
|
}
|
|
|
|
export function checkOfficialChannelDocsIndex(params: CatalogParams = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const outputPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH);
|
|
if (!fs.existsSync(outputPath)) {
|
|
return false;
|
|
}
|
|
const current = fs.readFileSync(outputPath, "utf8");
|
|
try {
|
|
return current === renderOfficialChannelDocsIndex({ repoRoot });
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function collectDocsNavPageCounts(node: unknown, counts: Map<string, number> = new Map()) {
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) {
|
|
collectDocsNavPageCounts(item, counts);
|
|
}
|
|
return counts;
|
|
}
|
|
if (!isRecord(node)) {
|
|
return counts;
|
|
}
|
|
if (Array.isArray(node.pages)) {
|
|
for (const page of node.pages) {
|
|
if (typeof page === "string") {
|
|
const route = page.replace(/^\/+/u, "");
|
|
counts.set(route, (counts.get(route) ?? 0) + 1);
|
|
} else {
|
|
collectDocsNavPageCounts(page, counts);
|
|
}
|
|
}
|
|
}
|
|
for (const value of Object.values(node)) {
|
|
if (value !== node.pages) {
|
|
collectDocsNavPageCounts(value, counts);
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function findEnglishChannelsTab(docsConfig: unknown) {
|
|
const navigation =
|
|
isRecord(docsConfig) && isRecord(docsConfig.navigation) ? docsConfig.navigation : null;
|
|
const languages = Array.isArray(navigation?.languages) ? navigation.languages : [];
|
|
const english = languages.find(
|
|
(language) => isRecord(language) && trimString(language.language) === "en",
|
|
);
|
|
const tabs = isRecord(english) && Array.isArray(english.tabs) ? english.tabs : [];
|
|
return tabs.find((tab) => isRecord(tab) && trimString(tab.tab) === "Channels") ?? null;
|
|
}
|
|
|
|
function readDocsNavCounts(repoRoot: string) {
|
|
const navPath = path.join(repoRoot, OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH);
|
|
const docsConfig: unknown = JSON.parse(fs.readFileSync(navPath, "utf8"));
|
|
return {
|
|
all: collectDocsNavPageCounts(docsConfig),
|
|
channels: collectDocsNavPageCounts(findEnglishChannelsTab(docsConfig)),
|
|
};
|
|
}
|
|
|
|
function buildHiddenChannelDocsRoutes(repoRoot: string) {
|
|
const channelsById = new Map<string, Record<string, unknown>>();
|
|
for (const entry of Array.isArray(officialExternalChannelSeed.entries)
|
|
? officialExternalChannelSeed.entries
|
|
: []) {
|
|
const channel = isRecord(entry?.openclaw?.channel) ? entry.openclaw.channel : null;
|
|
const channelId = trimString(channel?.id);
|
|
if (channelId && channel) {
|
|
channelsById.set(channelId, channel);
|
|
}
|
|
}
|
|
for (const { packageJson } of readRepositoryPackageJsons(repoRoot)) {
|
|
const manifest =
|
|
isRecord(packageJson) && isRecord(packageJson.openclaw) ? packageJson.openclaw : {};
|
|
const channel = isRecord(manifest.channel) ? manifest.channel : null;
|
|
const channelId = trimString(channel?.id);
|
|
if (channelId && channel) {
|
|
channelsById.set(channelId, channel);
|
|
}
|
|
}
|
|
|
|
const routes = new Set<string>();
|
|
for (const channel of channelsById.values()) {
|
|
const exposure = isRecord(channel.exposure) ? channel.exposure : null;
|
|
const docsPath = trimString(channel.docsPath);
|
|
if (exposure?.docs !== false || !docsPath.startsWith("/")) {
|
|
continue;
|
|
}
|
|
const route = docsPath.split("#", 1)[0]?.replace(/^\/+/u, "");
|
|
if (route) {
|
|
routes.add(route);
|
|
}
|
|
}
|
|
return routes;
|
|
}
|
|
|
|
export function findMissingOfficialChannelDocsNavRoutes(params: CatalogParams = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const navCounts = readDocsNavCounts(repoRoot);
|
|
const missing = new Set<string>();
|
|
for (const entry of buildOfficialChannelDocsCatalog({ repoRoot }).entries) {
|
|
if (!entry.docsPath.startsWith("/")) {
|
|
continue;
|
|
}
|
|
const route = entry.docsPath.split("#", 1)[0]?.replace(/^\/+/u, "");
|
|
const counts = route?.startsWith("channels/") ? navCounts.channels : navCounts.all;
|
|
if (route && (counts.get(route) ?? 0) === 0) {
|
|
missing.add(route);
|
|
}
|
|
}
|
|
return [...missing].toSorted((left, right) => left.localeCompare(right, "en"));
|
|
}
|
|
|
|
export function findUnexpectedOfficialChannelDocsNavRoutes(params: CatalogParams = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const navCounts = readDocsNavCounts(repoRoot).all;
|
|
return [...buildHiddenChannelDocsRoutes(repoRoot)]
|
|
.filter((route) => (navCounts.get(route) ?? 0) > 0)
|
|
.toSorted((left, right) => left.localeCompare(right, "en"));
|
|
}
|
|
|
|
export function findDuplicateOfficialChannelDocsNavRoutes(params: CatalogParams = {}) {
|
|
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
|
const navCounts = readDocsNavCounts(repoRoot).all;
|
|
const duplicateRoutes = new Set<string>();
|
|
for (const entry of buildOfficialChannelDocsCatalog({ repoRoot }).entries) {
|
|
const route = entry.docsPath.split("#", 1)[0]?.replace(/^\/+/u, "");
|
|
if (route && (navCounts.get(route) ?? 0) > 1) {
|
|
duplicateRoutes.add(route);
|
|
}
|
|
}
|
|
return [...duplicateRoutes].toSorted((left, right) => left.localeCompare(right, "en"));
|
|
}
|
|
|
|
function reportOfficialChannelDocsNavIssues(params: CatalogParams = {}) {
|
|
const issueGroups = [
|
|
{
|
|
label: "is missing channel routes",
|
|
routes: findMissingOfficialChannelDocsNavRoutes(params),
|
|
},
|
|
{
|
|
label: "exposes hidden channel routes",
|
|
routes: findUnexpectedOfficialChannelDocsNavRoutes(params),
|
|
},
|
|
{
|
|
label: "duplicates channel routes",
|
|
routes: findDuplicateOfficialChannelDocsNavRoutes(params),
|
|
},
|
|
];
|
|
let failed = false;
|
|
for (const issue of issueGroups) {
|
|
if (issue.routes.length === 0) {
|
|
continue;
|
|
}
|
|
console.error(
|
|
`${OFFICIAL_CHANNEL_DOCS_NAV_RELATIVE_PATH} ${issue.label}: ${issue.routes.join(", ")}`,
|
|
);
|
|
failed = true;
|
|
}
|
|
return failed;
|
|
}
|
|
|
|
function main(argv: string[] = 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();
|
|
writeOfficialChannelDocsIndex();
|
|
if (reportOfficialChannelDocsNavIssues()) {
|
|
process.exitCode = 1;
|
|
}
|
|
return;
|
|
}
|
|
let failed = false;
|
|
if (!checkOfficialChannelCatalogSource()) {
|
|
console.error(
|
|
`${OFFICIAL_CHANNEL_CATALOG_SOURCE_RELATIVE_PATH} is stale. Run \`pnpm channels:catalog:gen\`.`,
|
|
);
|
|
failed = true;
|
|
}
|
|
if (!checkOfficialChannelDocsIndex()) {
|
|
console.error(
|
|
`${OFFICIAL_CHANNEL_DOCS_INDEX_RELATIVE_PATH} is stale. Run \`pnpm channels:catalog:gen\`.`,
|
|
);
|
|
failed = true;
|
|
}
|
|
if (reportOfficialChannelDocsNavIssues()) {
|
|
failed = true;
|
|
}
|
|
if (failed) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
main();
|
|
}
|