|
| 1 | +import { spawnSync } from "node:child_process"; |
| 2 | +import fs from "node:fs"; |
| 3 | +import path from "node:path"; |
| 4 | +import { pathToFileURL } from "node:url"; |
| 5 | +import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; |
| 6 | + |
| 7 | +const GENERATED_BY = "scripts/generate-bundled-plugin-metadata.mjs"; |
| 8 | +const DEFAULT_OUTPUT_PATH = "src/plugins/bundled-plugin-metadata.generated.ts"; |
| 9 | +const MANIFEST_KEY = "openclaw"; |
| 10 | +const FORMATTER_CWD = path.resolve(import.meta.dirname, ".."); |
| 11 | +const CANONICAL_PACKAGE_ID_ALIASES = { |
| 12 | + "elevenlabs-speech": "elevenlabs", |
| 13 | + "microsoft-speech": "microsoft", |
| 14 | + "ollama-provider": "ollama", |
| 15 | + "sglang-provider": "sglang", |
| 16 | + "vllm-provider": "vllm", |
| 17 | +}; |
| 18 | + |
| 19 | +function readIfExists(filePath) { |
| 20 | + try { |
| 21 | + return fs.readFileSync(filePath, "utf8"); |
| 22 | + } catch { |
| 23 | + return null; |
| 24 | + } |
| 25 | +} |
| 26 | + |
| 27 | +function rewriteEntryToBuiltPath(entry) { |
| 28 | + if (typeof entry !== "string" || entry.trim().length === 0) { |
| 29 | + return undefined; |
| 30 | + } |
| 31 | + const normalized = entry.replace(/^\.\//u, ""); |
| 32 | + return normalized.replace(/\.[^.]+$/u, ".js"); |
| 33 | +} |
| 34 | + |
| 35 | +function deriveIdHint({ filePath, packageName, hasMultipleExtensions }) { |
| 36 | + const base = path.basename(filePath, path.extname(filePath)); |
| 37 | + const rawPackageName = packageName?.trim(); |
| 38 | + if (!rawPackageName) { |
| 39 | + return base; |
| 40 | + } |
| 41 | + |
| 42 | + const unscoped = rawPackageName.includes("/") |
| 43 | + ? (rawPackageName.split("/").pop() ?? rawPackageName) |
| 44 | + : rawPackageName; |
| 45 | + const canonicalPackageId = CANONICAL_PACKAGE_ID_ALIASES[unscoped] ?? unscoped; |
| 46 | + const normalizedPackageId = |
| 47 | + canonicalPackageId.endsWith("-provider") && canonicalPackageId.length > "-provider".length |
| 48 | + ? canonicalPackageId.slice(0, -"-provider".length) |
| 49 | + : canonicalPackageId; |
| 50 | + |
| 51 | + if (!hasMultipleExtensions) { |
| 52 | + return normalizedPackageId; |
| 53 | + } |
| 54 | + return `${normalizedPackageId}/${base}`; |
| 55 | +} |
| 56 | + |
| 57 | +function normalizeStringList(values) { |
| 58 | + if (!Array.isArray(values)) { |
| 59 | + return undefined; |
| 60 | + } |
| 61 | + const normalized = values.map((value) => String(value).trim()).filter(Boolean); |
| 62 | + return normalized.length > 0 ? normalized : undefined; |
| 63 | +} |
| 64 | + |
| 65 | +function normalizeObject(value) { |
| 66 | + if (!value || typeof value !== "object" || Array.isArray(value)) { |
| 67 | + return undefined; |
| 68 | + } |
| 69 | + return value; |
| 70 | +} |
| 71 | + |
| 72 | +function normalizePackageManifest(raw) { |
| 73 | + const packageManifest = normalizeObject(raw?.[MANIFEST_KEY]); |
| 74 | + if (!packageManifest) { |
| 75 | + return undefined; |
| 76 | + } |
| 77 | + const normalized = { |
| 78 | + ...(Array.isArray(packageManifest.extensions) |
| 79 | + ? { extensions: packageManifest.extensions.map((entry) => String(entry).trim()) } |
| 80 | + : {}), |
| 81 | + ...(typeof packageManifest.setupEntry === "string" |
| 82 | + ? { setupEntry: packageManifest.setupEntry.trim() } |
| 83 | + : {}), |
| 84 | + ...(normalizeObject(packageManifest.channel) ? { channel: packageManifest.channel } : {}), |
| 85 | + ...(normalizeObject(packageManifest.install) ? { install: packageManifest.install } : {}), |
| 86 | + ...(normalizeObject(packageManifest.startup) ? { startup: packageManifest.startup } : {}), |
| 87 | + }; |
| 88 | + return Object.keys(normalized).length > 0 ? normalized : undefined; |
| 89 | +} |
| 90 | + |
| 91 | +function normalizePluginManifest(raw) { |
| 92 | + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { |
| 93 | + return null; |
| 94 | + } |
| 95 | + if (typeof raw.id !== "string" || !raw.id.trim()) { |
| 96 | + return null; |
| 97 | + } |
| 98 | + if ( |
| 99 | + !raw.configSchema || |
| 100 | + typeof raw.configSchema !== "object" || |
| 101 | + Array.isArray(raw.configSchema) |
| 102 | + ) { |
| 103 | + return null; |
| 104 | + } |
| 105 | + |
| 106 | + return { |
| 107 | + id: raw.id.trim(), |
| 108 | + configSchema: raw.configSchema, |
| 109 | + ...(raw.enabledByDefault === true ? { enabledByDefault: true } : {}), |
| 110 | + ...(typeof raw.kind === "string" ? { kind: raw.kind.trim() } : {}), |
| 111 | + ...(normalizeStringList(raw.channels) ? { channels: normalizeStringList(raw.channels) } : {}), |
| 112 | + ...(normalizeStringList(raw.providers) |
| 113 | + ? { providers: normalizeStringList(raw.providers) } |
| 114 | + : {}), |
| 115 | + ...(normalizeObject(raw.providerAuthEnvVars) |
| 116 | + ? { providerAuthEnvVars: raw.providerAuthEnvVars } |
| 117 | + : {}), |
| 118 | + ...(Array.isArray(raw.providerAuthChoices) |
| 119 | + ? { providerAuthChoices: raw.providerAuthChoices } |
| 120 | + : {}), |
| 121 | + ...(normalizeStringList(raw.skills) ? { skills: normalizeStringList(raw.skills) } : {}), |
| 122 | + ...(typeof raw.name === "string" ? { name: raw.name.trim() } : {}), |
| 123 | + ...(typeof raw.description === "string" ? { description: raw.description.trim() } : {}), |
| 124 | + ...(typeof raw.version === "string" ? { version: raw.version.trim() } : {}), |
| 125 | + ...(normalizeObject(raw.uiHints) ? { uiHints: raw.uiHints } : {}), |
| 126 | + }; |
| 127 | +} |
| 128 | + |
| 129 | +function formatTypeScriptModule(source, { outputPath }) { |
| 130 | + const formatter = spawnSync( |
| 131 | + process.platform === "win32" ? "pnpm.cmd" : "pnpm", |
| 132 | + ["exec", "oxfmt", "--stdin-filepath", outputPath], |
| 133 | + { |
| 134 | + cwd: FORMATTER_CWD, |
| 135 | + input: source, |
| 136 | + encoding: "utf8", |
| 137 | + }, |
| 138 | + ); |
| 139 | + if (formatter.status !== 0) { |
| 140 | + const details = |
| 141 | + formatter.stderr?.trim() || formatter.stdout?.trim() || "unknown formatter failure"; |
| 142 | + throw new Error(`failed to format generated bundled plugin metadata: ${details}`); |
| 143 | + } |
| 144 | + return formatter.stdout; |
| 145 | +} |
| 146 | + |
| 147 | +export function collectBundledPluginMetadata(params = {}) { |
| 148 | + const repoRoot = path.resolve(params.repoRoot ?? process.cwd()); |
| 149 | + const extensionsRoot = path.join(repoRoot, "extensions"); |
| 150 | + if (!fs.existsSync(extensionsRoot)) { |
| 151 | + return []; |
| 152 | + } |
| 153 | + |
| 154 | + const entries = []; |
| 155 | + for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { |
| 156 | + if (!dirent.isDirectory()) { |
| 157 | + continue; |
| 158 | + } |
| 159 | + |
| 160 | + const pluginDir = path.join(extensionsRoot, dirent.name); |
| 161 | + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); |
| 162 | + const packageJsonPath = path.join(pluginDir, "package.json"); |
| 163 | + if (!fs.existsSync(manifestPath) || !fs.existsSync(packageJsonPath)) { |
| 164 | + continue; |
| 165 | + } |
| 166 | + |
| 167 | + const manifest = normalizePluginManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8"))); |
| 168 | + if (!manifest) { |
| 169 | + continue; |
| 170 | + } |
| 171 | + |
| 172 | + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); |
| 173 | + const packageManifest = normalizePackageManifest(packageJson); |
| 174 | + const extensions = Array.isArray(packageManifest?.extensions) |
| 175 | + ? packageManifest.extensions.filter((entry) => typeof entry === "string" && entry.trim()) |
| 176 | + : []; |
| 177 | + if (extensions.length === 0) { |
| 178 | + continue; |
| 179 | + } |
| 180 | + |
| 181 | + const sourceEntry = extensions[0]; |
| 182 | + const builtEntry = rewriteEntryToBuiltPath(sourceEntry); |
| 183 | + if (!builtEntry) { |
| 184 | + continue; |
| 185 | + } |
| 186 | + const setupEntry = |
| 187 | + typeof packageManifest?.setupEntry === "string" && |
| 188 | + packageManifest.setupEntry.trim().length > 0 |
| 189 | + ? { |
| 190 | + source: packageManifest.setupEntry.trim(), |
| 191 | + built: rewriteEntryToBuiltPath(packageManifest.setupEntry.trim()), |
| 192 | + } |
| 193 | + : undefined; |
| 194 | + |
| 195 | + entries.push({ |
| 196 | + dirName: dirent.name, |
| 197 | + idHint: deriveIdHint({ |
| 198 | + filePath: sourceEntry, |
| 199 | + packageName: typeof packageJson.name === "string" ? packageJson.name : undefined, |
| 200 | + hasMultipleExtensions: extensions.length > 1, |
| 201 | + }), |
| 202 | + source: { |
| 203 | + source: sourceEntry, |
| 204 | + built: builtEntry, |
| 205 | + }, |
| 206 | + ...(setupEntry?.built |
| 207 | + ? { setupSource: { source: setupEntry.source, built: setupEntry.built } } |
| 208 | + : {}), |
| 209 | + ...(typeof packageJson.name === "string" ? { packageName: packageJson.name.trim() } : {}), |
| 210 | + ...(typeof packageJson.version === "string" |
| 211 | + ? { packageVersion: packageJson.version.trim() } |
| 212 | + : {}), |
| 213 | + ...(typeof packageJson.description === "string" |
| 214 | + ? { packageDescription: packageJson.description.trim() } |
| 215 | + : {}), |
| 216 | + ...(packageManifest ? { packageManifest } : {}), |
| 217 | + manifest, |
| 218 | + }); |
| 219 | + } |
| 220 | + |
| 221 | + return entries.toSorted((left, right) => left.dirName.localeCompare(right.dirName)); |
| 222 | +} |
| 223 | + |
| 224 | +export function renderBundledPluginMetadataModule(entries) { |
| 225 | + return `// Auto-generated by ${GENERATED_BY}. Do not edit directly. |
| 226 | +
|
| 227 | +export const GENERATED_BUNDLED_PLUGIN_METADATA = ${JSON.stringify(entries, null, 2)} as const; |
| 228 | +`; |
| 229 | +} |
| 230 | + |
| 231 | +export function writeBundledPluginMetadataModule(params = {}) { |
| 232 | + const repoRoot = path.resolve(params.repoRoot ?? process.cwd()); |
| 233 | + const outputPath = path.resolve(repoRoot, params.outputPath ?? DEFAULT_OUTPUT_PATH); |
| 234 | + const next = formatTypeScriptModule( |
| 235 | + renderBundledPluginMetadataModule(collectBundledPluginMetadata({ repoRoot })), |
| 236 | + { outputPath }, |
| 237 | + ); |
| 238 | + const current = readIfExists(outputPath); |
| 239 | + const changed = current !== next; |
| 240 | + |
| 241 | + if (params.check) { |
| 242 | + return { |
| 243 | + changed, |
| 244 | + wrote: false, |
| 245 | + outputPath, |
| 246 | + }; |
| 247 | + } |
| 248 | + |
| 249 | + return { |
| 250 | + changed, |
| 251 | + wrote: writeTextFileIfChanged(outputPath, next), |
| 252 | + outputPath, |
| 253 | + }; |
| 254 | +} |
| 255 | + |
| 256 | +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { |
| 257 | + const result = writeBundledPluginMetadataModule({ |
| 258 | + check: process.argv.includes("--check"), |
| 259 | + }); |
| 260 | + |
| 261 | + if (result.changed) { |
| 262 | + if (process.argv.includes("--check")) { |
| 263 | + console.error( |
| 264 | + `[bundled-plugin-metadata] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`, |
| 265 | + ); |
| 266 | + process.exitCode = 1; |
| 267 | + } else { |
| 268 | + console.log( |
| 269 | + `[bundled-plugin-metadata] wrote ${path.relative(process.cwd(), result.outputPath)}`, |
| 270 | + ); |
| 271 | + } |
| 272 | + } |
| 273 | +} |
0 commit comments