#!/usr/bin/env zx /** * bundle-openclaw-plugins.mjs * * Build a self-contained mirror of OpenClaw third-party plugins for packaging. * Current plugins: * - @tencent-weixin/openclaw-weixin -> build/openclaw-plugins/openclaw-weixin * - @larksuite/openclaw-lark -> build/openclaw-plugins/openclaw-lark * * The output plugin directory contains: * - plugin runtime files (dist/index.js or index.js, openclaw.plugin.json, package.json, ...) * - plugin runtime node_modules/ (flattened direct + transitive deps) */ import 'zx/globals'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const OUTPUT_ROOT = path.join(ROOT, 'build', 'openclaw-plugins'); const NODE_MODULES = path.join(ROOT, 'node_modules'); const STATIC_PLUGIN_ROOT = path.join(ROOT, 'resources', 'openclaw-plugins'); // On Windows, pnpm virtual store paths can exceed MAX_PATH (260 chars). // Adding \\?\ prefix bypasses the limit for Win32 fs calls. // Node.js 18.17+ also handles this transparently when LongPathsEnabled=1, // but this is an extra safety net for build machines where the registry key // may not be set yet. function normWin(p) { if (process.platform !== 'win32') return p; if (p.startsWith('\\\\?\\')) return p; return '\\\\?\\' + p.replace(/\//g, '\\'); } const PLUGINS = [ { npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' }, { npmName: '@larksuite/openclaw-lark', pluginId: 'openclaw-lark' }, ]; function readJsonFile(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } function writeJsonFile(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } function normalizeEntryPath(entry) { if (typeof entry !== 'string') return null; const trimmed = entry.trim(); if (!trimmed || path.isAbsolute(trimmed)) return null; return trimmed.replace(/^\.\//, ''); } function toPackageEntry(entry) { return entry.startsWith('.') ? entry : `./${entry}`; } function isJavaScriptEntry(entry) { return /\.(?:cjs|mjs|js)$/i.test(entry); } function entryExists(pluginDir, entry) { const normalized = normalizeEntryPath(entry); return Boolean(normalized) && fs.existsSync(path.join(pluginDir, normalized)); } function collectRuntimeEntryHints(pkg) { const hints = []; const extensions = pkg.openclaw?.extensions; if (Array.isArray(extensions)) hints.push(...extensions); if (typeof pkg.main === 'string') hints.push(pkg.main); if (typeof pkg.module === 'string') hints.push(pkg.module); hints.push('./dist/index.js', './index.js'); return [...new Set(hints.map(normalizeEntryPath).filter(Boolean))]; } function findExistingRuntimeEntry(pluginDir, pkg) { for (const hint of collectRuntimeEntryHints(pkg)) { if (isJavaScriptEntry(hint) && fs.existsSync(path.join(pluginDir, hint))) { return hint; } } return null; } function patchRuntimeEntryHints(pluginDir) { const pkgJsonPath = path.join(pluginDir, 'package.json'); if (!fs.existsSync(pkgJsonPath)) return null; const pkg = readJsonFile(pkgJsonPath); let modified = false; const extensions = pkg.openclaw?.extensions; if (Array.isArray(extensions)) { const patchedExtensions = extensions.map((entry) => { const normalized = normalizeEntryPath(entry); if (!normalized?.endsWith('.ts')) return entry; const jsEntry = `dist/${normalized.replace(/\.ts$/i, '.js')}`; return fs.existsSync(path.join(pluginDir, jsEntry)) ? toPackageEntry(jsEntry) : entry; }); if (JSON.stringify(patchedExtensions) !== JSON.stringify(extensions)) { pkg.openclaw.extensions = patchedExtensions; modified = true; } } const existingRuntimeEntry = findExistingRuntimeEntry(pluginDir, pkg); if (existingRuntimeEntry) { if (typeof pkg.main !== 'string' || !entryExists(pluginDir, pkg.main)) { pkg.main = toPackageEntry(existingRuntimeEntry); modified = true; } if (typeof pkg.module === 'string' && !entryExists(pluginDir, pkg.module)) { pkg.module = toPackageEntry(existingRuntimeEntry); modified = true; } } if (modified) { writeJsonFile(pkgJsonPath, pkg); } return existingRuntimeEntry; } function patchManifestChannelConfigs(pluginDir) { const manifestPath = path.join(pluginDir, 'openclaw.plugin.json'); if (!fs.existsSync(manifestPath)) return; const manifest = readJsonFile(manifestPath); if (manifest.channelConfigs || !Array.isArray(manifest.channels)) return; const schema = { type: 'object' }; manifest.channelConfigs = Object.fromEntries( manifest.channels .filter((channelId) => typeof channelId === 'string' && channelId.trim().length > 0) .map((channelId) => [channelId, { schema }]), ); writeJsonFile(manifestPath, manifest); } function collectTypeScriptFiles(pluginDir) { const result = []; const skipDirs = new Set(['node_modules', 'dist', '.git']); function walk(currentDir) { for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { if (skipDirs.has(entry.name)) continue; const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { walk(fullPath); continue; } if (!entry.isFile()) continue; if (!entry.name.endsWith('.ts')) continue; if (entry.name.endsWith('.d.ts') || entry.name.endsWith('.test.ts')) continue; result.push(fullPath); } } walk(pluginDir); return result; } function compileTypeScriptPluginIfNeeded(pluginDir, pluginId) { const pkgJsonPath = path.join(pluginDir, 'package.json'); if (!fs.existsSync(pkgJsonPath)) return; const pkg = readJsonFile(pkgJsonPath); const extensionEntries = Array.isArray(pkg.openclaw?.extensions) ? pkg.openclaw.extensions : []; const hasTypeScriptEntry = extensionEntries.some((entry) => normalizeEntryPath(entry)?.endsWith('.ts')); if (!hasTypeScriptEntry) { const runtimeEntry = patchRuntimeEntryHints(pluginDir); if (runtimeEntry) { echo` 🔗 Runtime entry: ${runtimeEntry}`; } return; } const tsFiles = collectTypeScriptFiles(pluginDir); if (tsFiles.length === 0) { throw new Error(`Plugin ${pluginId} declares TypeScript entries but no .ts source files were found.`); } const distDir = path.join(pluginDir, 'dist'); fs.rmSync(distDir, { recursive: true, force: true }); for (const sourcePath of tsFiles) { const source = fs.readFileSync(sourcePath, 'utf8'); const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022, esModuleInterop: true, importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, sourceMap: false, inlineSources: false, }, fileName: sourcePath, reportDiagnostics: true, }); const diagnostics = output.diagnostics ?? []; const blocking = diagnostics.filter((diag) => diag.category === ts.DiagnosticCategory.Error); if (blocking.length > 0) { const message = blocking .map((diag) => ts.flattenDiagnosticMessageText(diag.messageText, '\n')) .join('\n'); throw new Error(`Failed to transpile ${path.relative(pluginDir, sourcePath)}:\n${message}`); } const rel = path.relative(pluginDir, sourcePath).replace(/\.ts$/i, '.js'); const outputPath = path.join(distDir, rel); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, output.outputText, 'utf8'); } patchRuntimeEntryHints(pluginDir); const runtimeEntry = findExistingRuntimeEntry(pluginDir, readJsonFile(pkgJsonPath)); if (!runtimeEntry) { throw new Error(`Plugin ${pluginId} did not produce a loadable JavaScript runtime entry.`); } echo` 🛠️ Compiled ${tsFiles.length} TypeScript files -> dist/ (${runtimeEntry})`; } function getVirtualStoreNodeModules(realPkgPath) { let dir = realPkgPath; while (dir !== path.dirname(dir)) { if (path.basename(dir) === 'node_modules') return dir; dir = path.dirname(dir); } return null; } function listPackages(nodeModulesDir) { const result = []; const nDir = normWin(nodeModulesDir); if (!fs.existsSync(nDir)) return result; for (const entry of fs.readdirSync(nDir)) { if (entry === '.bin') continue; // Use original (non-normWin) path so callers can call // getVirtualStoreNodeModules() on fullPath correctly. const entryPath = path.join(nodeModulesDir, entry); if (entry.startsWith('@')) { let scopeEntries = []; try { scopeEntries = fs.readdirSync(normWin(entryPath)); } catch { continue; } for (const sub of scopeEntries) { result.push({ name: `${entry}/${sub}`, fullPath: path.join(entryPath, sub), }); } } else { result.push({ name: entry, fullPath: entryPath }); } } return result; } function bundleOnePlugin({ npmName, pluginId }) { const pkgPath = path.join(NODE_MODULES, ...npmName.split('/')); if (!fs.existsSync(pkgPath)) { throw new Error(`Missing dependency "${npmName}". Run pnpm install first.`); } const realPluginPath = fs.realpathSync(pkgPath); const outputDir = path.join(OUTPUT_ROOT, pluginId); echo`📦 Bundling plugin ${npmName} -> ${outputDir}`; if (fs.existsSync(outputDir)) { fs.rmSync(outputDir, { recursive: true, force: true }); } fs.mkdirSync(outputDir, { recursive: true }); // 1) Copy plugin package itself fs.cpSync(realPluginPath, outputDir, { recursive: true, dereference: true }); // 2) Collect transitive deps from pnpm virtual store const collected = new Map(); const queue = []; const rootVirtualNM = getVirtualStoreNodeModules(realPluginPath); if (!rootVirtualNM) { throw new Error(`Cannot resolve virtual store node_modules for ${npmName}`); } queue.push({ nodeModulesDir: rootVirtualNM, skipPkg: npmName }); // Skip peerDependencies — they're provided by the host openclaw gateway. const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']); const SKIP_SCOPES = ['@types/']; try { const pluginPkg = JSON.parse(fs.readFileSync(path.join(outputDir, 'package.json'), 'utf8')); for (const peer of Object.keys(pluginPkg.peerDependencies || {})) { SKIP_PACKAGES.add(peer); } } catch { /* ignore */ } while (queue.length > 0) { const { nodeModulesDir, skipPkg } = queue.shift(); for (const { name, fullPath } of listPackages(nodeModulesDir)) { if (name === skipPkg) continue; if (SKIP_PACKAGES.has(name) || SKIP_SCOPES.some((s) => name.startsWith(s))) continue; let realPath; try { realPath = fs.realpathSync(fullPath); } catch { continue; } if (collected.has(realPath)) continue; collected.set(realPath, name); const depVirtualNM = getVirtualStoreNodeModules(realPath); if (depVirtualNM && depVirtualNM !== nodeModulesDir) { queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name }); } } } // 3) Copy flattened deps into plugin/node_modules const outputNodeModules = path.join(outputDir, 'node_modules'); fs.mkdirSync(outputNodeModules, { recursive: true }); let copiedCount = 0; let skippedDupes = 0; const copiedNames = new Set(); for (const [realPath, pkgName] of collected) { if (copiedNames.has(pkgName)) { skippedDupes++; continue; } copiedNames.add(pkgName); const dest = path.join(outputNodeModules, pkgName); try { fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true }); fs.cpSync(normWin(realPath), normWin(dest), { recursive: true, dereference: true }); copiedCount++; } catch (err) { echo` ⚠️ Skipped ${pkgName}: ${err.message}`; } } const manifestPath = path.join(outputDir, 'openclaw.plugin.json'); if (!fs.existsSync(manifestPath)) { throw new Error(`Missing openclaw.plugin.json in bundled plugin output: ${pluginId}`); } // 4) Patch plugin ID mismatch: some npm packages hardcode a different ID in // their JS output than what openclaw.plugin.json declares. The Gateway // validates that these match, so we fix it post-copy. patchManifestChannelConfigs(outputDir); compileTypeScriptPluginIfNeeded(outputDir, pluginId); patchPluginId(outputDir, pluginId); echo` ✅ ${pluginId}: copied ${copiedCount} deps (skipped dupes: ${skippedDupes})`; } /** * Patch plugin entry JS files so the exported `id` matches openclaw.plugin.json. * Kept as a safety hook for future bundled plugins that may ship mismatched IDs. */ function patchPluginId(pluginDir, expectedId) { const manifestPath = path.join(pluginDir, 'openclaw.plugin.json'); if (!fs.existsSync(manifestPath)) return; const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifestId = manifest.id; if (manifestId !== expectedId) { echo` ⚠️ Manifest ID "${manifestId}" doesn't match expected "${expectedId}", skipping patch`; return; } // Read the package.json to find the main entry point const pkgJsonPath = path.join(pluginDir, 'package.json'); if (!fs.existsSync(pkgJsonPath)) return; const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); const extensionEntries = Array.isArray(pkg.openclaw?.extensions) ? pkg.openclaw.extensions : []; const entryFiles = [...new Set([pkg.main, pkg.module, ...extensionEntries].filter(Boolean))]; // Known ID mismatches to patch. Keys are the wrong ID found in compiled JS, // values are the correct ID (must match openclaw.plugin.json). const ID_FIXES = {}; for (const entry of entryFiles) { const entryPath = path.join(pluginDir, entry); if (!fs.existsSync(entryPath)) continue; let content = fs.readFileSync(entryPath, 'utf8'); let patched = false; for (const [wrongId, correctId] of Object.entries(ID_FIXES)) { if (correctId !== expectedId) continue; const pattern = new RegExp(`(\\bid\\s*:\\s*)(["'])${wrongId.replace(/-/g, '\\-')}\\2`, 'g'); const replaced = content.replace(pattern, `$1$2${correctId}$2`); if (replaced !== content) { content = replaced; patched = true; echo` 🩹 Patching plugin ID in ${entry}: "${wrongId}" → "${correctId}"`; } } if (patched) { fs.writeFileSync(entryPath, content, 'utf8'); } } } echo`📦 Bundling OpenClaw plugin mirrors...`; fs.mkdirSync(OUTPUT_ROOT, { recursive: true }); for (const plugin of PLUGINS) { bundleOnePlugin(plugin); } if (fs.existsSync(STATIC_PLUGIN_ROOT)) { for (const entry of fs.readdirSync(STATIC_PLUGIN_ROOT, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const sourceDir = path.join(STATIC_PLUGIN_ROOT, entry.name); const outputDir = path.join(OUTPUT_ROOT, entry.name); const manifestPath = path.join(sourceDir, 'openclaw.plugin.json'); if (!fs.existsSync(manifestPath)) continue; echo`📦 Copying static plugin ${entry.name} -> ${outputDir}`; fs.rmSync(outputDir, { recursive: true, force: true }); fs.cpSync(sourceDir, outputDir, { recursive: true, dereference: true }); } } echo`✅ Plugin mirrors ready: ${OUTPUT_ROOT}`;