import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; function dedupe(values: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const value of values) { if (!value || seen.has(value)) { continue; } seen.add(value); out.push(value); } return out; } const scriptPath = fileURLToPath(import.meta.url); const scriptDir = path.dirname(scriptPath); const rootDir = path.resolve(scriptDir, ".."); const distDir = path.join(rootDir, "dist"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const extensionsDir = path.join(rootDir, "extensions"); const CORE_CHANNEL_ORDER = [ "telegram", "whatsapp", "discord", "irc", "googlechat", "slack", "signal", "imessage", ] as const; type ExtensionChannelEntry = { id: string; order: number; label: string; }; type BundledChannelCatalog = { ids: string[]; signature: string; }; function resolveRootHelpBundleIdentity( distDirOverride: string = distDir, ): { bundleName: string; signature: string } | null { const bundleName = readdirSync(distDirOverride).find( (entry) => entry.startsWith("root-help-") && entry.endsWith(".js"), ); if (!bundleName) { return null; } const bundlePath = path.join(distDirOverride, bundleName); const raw = readFileSync(bundlePath, "utf8"); return { bundleName, signature: createHash("sha1").update(raw).digest("hex"), }; } export function readBundledChannelCatalog( extensionsDirOverride: string = extensionsDir, ): BundledChannelCatalog { const entries: ExtensionChannelEntry[] = []; const signature = createHash("sha1"); for (const dirEntry of readdirSync(extensionsDirOverride, { withFileTypes: true })) { if (!dirEntry.isDirectory()) { continue; } const packageJsonPath = path.join(extensionsDirOverride, dirEntry.name, "package.json"); try { const raw = readFileSync(packageJsonPath, "utf8"); signature.update(`${dirEntry.name}\0${raw}\0`); const parsed = JSON.parse(raw) as { openclaw?: { channel?: { id?: unknown; order?: unknown; label?: unknown; }; }; }; const id = parsed.openclaw?.channel?.id; if (typeof id !== "string" || !id.trim()) { continue; } const orderRaw = parsed.openclaw?.channel?.order; const labelRaw = parsed.openclaw?.channel?.label; entries.push({ id: id.trim(), order: typeof orderRaw === "number" ? orderRaw : 999, label: typeof labelRaw === "string" ? labelRaw : id.trim(), }); } catch { // Ignore malformed or missing extension package manifests. } } return { ids: entries .toSorted((a, b) => a.order === b.order ? a.label.localeCompare(b.label) : a.order - b.order, ) .map((entry) => entry.id), signature: signature.digest("hex"), }; } export function readBundledChannelCatalogIds( extensionsDirOverride: string = extensionsDir, ): string[] { return readBundledChannelCatalog(extensionsDirOverride).ids; } export async function renderBundledRootHelpText( _distDirOverride: string = distDir, ): Promise { const bundleIdentity = resolveRootHelpBundleIdentity(_distDirOverride); if (!bundleIdentity) { throw new Error("No root-help bundle found in dist; cannot write CLI startup metadata."); } const moduleUrl = pathToFileURL(path.join(_distDirOverride, bundleIdentity.bundleName)).href; const inlineModule = [ `const mod = await import(${JSON.stringify(moduleUrl)});`, "if (typeof mod.outputRootHelp !== 'function') {", ` throw new Error(${JSON.stringify(`Bundle ${bundleIdentity.bundleName} does not export outputRootHelp.`)});`, "}", "await mod.outputRootHelp();", "process.exit(0);", ].join("\n"); const result = spawnSync(process.execPath, ["--input-type=module", "--eval", inlineModule], { cwd: _distDirOverride, encoding: "utf8", timeout: 30_000, }); if (result.error) { throw result.error; } if (result.status !== 0) { const stderr = result.stderr?.trim(); throw new Error( `Failed to render bundled root help from ${bundleIdentity.bundleName}` + (stderr ? `: ${stderr}` : result.signal ? `: terminated by ${result.signal}` : ""), ); } return result.stdout ?? ""; } async function renderSourceRootHelpText(): Promise { const module = await import("../src/cli/program/root-help.ts"); return module.renderRootHelpText({ pluginSdkResolution: "src" }); } export async function writeCliStartupMetadata(options?: { distDir?: string; outputPath?: string; extensionsDir?: string; }): Promise { const resolvedDistDir = options?.distDir ?? distDir; const resolvedOutputPath = options?.outputPath ?? outputPath; const resolvedExtensionsDir = options?.extensionsDir ?? extensionsDir; const channelCatalog = readBundledChannelCatalog(resolvedExtensionsDir); const bundleIdentity = resolveRootHelpBundleIdentity(resolvedDistDir); const channelOptions = dedupe([...CORE_CHANNEL_ORDER, ...channelCatalog.ids]); try { const existing = JSON.parse(readFileSync(resolvedOutputPath, "utf8")) as { rootHelpBundleSignature?: unknown; channelCatalogSignature?: unknown; }; if ( bundleIdentity && existing.rootHelpBundleSignature === bundleIdentity.signature && existing.channelCatalogSignature === channelCatalog.signature ) { return; } } catch { // Missing or malformed existing metadata means we should regenerate it. } let rootHelpText: string; try { rootHelpText = await renderBundledRootHelpText(resolvedDistDir); } catch { rootHelpText = await renderSourceRootHelpText(); } mkdirSync(resolvedDistDir, { recursive: true }); writeFileSync( resolvedOutputPath, `${JSON.stringify( { generatedBy: "scripts/write-cli-startup-metadata.ts", channelOptions, channelCatalogSignature: channelCatalog.signature, rootHelpBundleSignature: bundleIdentity?.signature ?? null, rootHelpText, }, null, 2, )}\n`, "utf8", ); } if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { await writeCliStartupMetadata(); process.exit(0); }