444 lines
15 KiB
JavaScript
444 lines
15 KiB
JavaScript
#!/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}`;
|