#!/usr/bin/env zx /** * bundle-openclaw.mjs * * Bundles the openclaw npm package with ALL its dependencies (including * transitive ones) into a self-contained directory (build/openclaw/) for * electron-builder to pick up. * * pnpm uses a content-addressable virtual store with symlinks. A naive copy * of node_modules/openclaw/ will miss runtime dependencies entirely. Even * copying only direct siblings misses transitive deps (e.g. @clack/prompts * depends on @clack/core which lives in a separate virtual store entry). * * This script performs a recursive BFS through pnpm's virtual store to * collect every transitive dependency into a flat node_modules structure. */ import 'zx/globals'; const ROOT = path.resolve(__dirname, '..'); const OUTPUT = path.join(ROOT, 'build', 'openclaw'); const NODE_MODULES = path.join(ROOT, 'node_modules'); echo`๐Ÿ“ฆ Bundling openclaw for electron-builder...`; // 1. Resolve the real path of node_modules/openclaw (follows pnpm symlink) const openclawLink = path.join(NODE_MODULES, 'openclaw'); if (!fs.existsSync(openclawLink)) { echo`โŒ node_modules/openclaw not found. Run pnpm install first.`; process.exit(1); } const openclawReal = fs.realpathSync(openclawLink); echo` openclaw resolved: ${openclawReal}`; // 2. Clean and create output directory if (fs.existsSync(OUTPUT)) { fs.rmSync(OUTPUT, { recursive: true }); } fs.mkdirSync(OUTPUT, { recursive: true }); // 3. Copy openclaw package itself to OUTPUT root echo` Copying openclaw package...`; fs.cpSync(openclawReal, OUTPUT, { recursive: true, dereference: true }); // 4. Recursively collect ALL transitive dependencies via pnpm virtual store BFS // // pnpm structure example: // .pnpm/openclaw@ver/node_modules/ // openclaw/ <- real files // chalk/ <- symlink -> .pnpm/chalk@ver/node_modules/chalk // @clack/prompts/ <- symlink -> .pnpm/@clack+prompts@ver/node_modules/@clack/prompts // // .pnpm/@clack+prompts@ver/node_modules/ // @clack/prompts/ <- real files // @clack/core/ <- symlink (transitive dep, NOT in openclaw's siblings!) // // We BFS from openclaw's virtual store node_modules, following each symlink // to discover the target's own virtual store node_modules and its deps. const collected = new Map(); // realPath -> packageName (for deduplication) const queue = []; // BFS queue of virtual-store node_modules dirs to visit /** * Given a real path of a package, find the containing virtual-store node_modules. * e.g. .pnpm/chalk@5.4.1/node_modules/chalk -> .pnpm/chalk@5.4.1/node_modules * e.g. .pnpm/@clack+core@0.4.1/node_modules/@clack/core -> .pnpm/@clack+core@0.4.1/node_modules */ 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; } /** * List all package entries in a virtual-store node_modules directory. * Handles both regular packages (chalk) and scoped packages (@clack/prompts). * Returns array of { name, fullPath }. */ function listPackages(nodeModulesDir) { const result = []; if (!fs.existsSync(nodeModulesDir)) return result; for (const entry of fs.readdirSync(nodeModulesDir)) { if (entry === '.bin') continue; const entryPath = path.join(nodeModulesDir, entry); const stat = fs.lstatSync(entryPath); if (entry.startsWith('@')) { // Scoped package: read sub-entries if (stat.isDirectory() || stat.isSymbolicLink()) { const resolvedScope = stat.isSymbolicLink() ? fs.realpathSync(entryPath) : entryPath; // Check if this is actually a scoped directory or a package try { const scopeEntries = fs.readdirSync(entryPath); for (const sub of scopeEntries) { result.push({ name: `${entry}/${sub}`, fullPath: path.join(entryPath, sub), }); } } catch { // Not a directory, skip } } } else { result.push({ name: entry, fullPath: entryPath }); } } return result; } // Start BFS from openclaw's virtual store node_modules const openclawVirtualNM = getVirtualStoreNodeModules(openclawReal); if (!openclawVirtualNM) { echo`โŒ Could not determine pnpm virtual store for openclaw`; process.exit(1); } echo` Virtual store root: ${openclawVirtualNM}`; queue.push({ nodeModulesDir: openclawVirtualNM, skipPkg: 'openclaw' }); const SKIP_PACKAGES = new Set([ 'typescript', '@playwright/test', ]); const SKIP_SCOPES = ['@cloudflare/', '@types/']; let skippedDevCount = 0; while (queue.length > 0) { const { nodeModulesDir, skipPkg } = queue.shift(); const packages = listPackages(nodeModulesDir); for (const { name, fullPath } of packages) { // Skip the package that owns this virtual store entry (it's the package itself, not a dep) if (name === skipPkg) continue; if (SKIP_PACKAGES.has(name) || SKIP_SCOPES.some(s => name.startsWith(s))) { skippedDevCount++; continue; } let realPath; try { realPath = fs.realpathSync(fullPath); } catch { continue; // broken symlink, skip } if (collected.has(realPath)) continue; // already visited collected.set(realPath, name); // Find this package's own virtual store node_modules to discover ITS deps const depVirtualNM = getVirtualStoreNodeModules(realPath); if (depVirtualNM && depVirtualNM !== nodeModulesDir) { // Determine the package's "self name" in its own virtual store // For scoped: @clack/core -> skip "@clack/core" when scanning queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name }); } } } echo` Found ${collected.size} total packages (direct + transitive)`; echo` Skipped ${skippedDevCount} dev-only package references`; // 5. Copy all collected packages into OUTPUT/node_modules/ (flat structure) // // IMPORTANT: BFS guarantees direct deps are encountered before transitive deps. // When the same package name appears at different versions (e.g. chalk@5 from // openclaw directly, chalk@4 from a transitive dep), we keep the FIRST one // (direct dep version) and skip later duplicates. This prevents version // conflicts like CJS chalk@4 overwriting ESM chalk@5. const outputNodeModules = path.join(OUTPUT, 'node_modules'); fs.mkdirSync(outputNodeModules, { recursive: true }); const copiedNames = new Set(); // Track package names already copied let copiedCount = 0; let skippedDupes = 0; for (const [realPath, pkgName] of collected) { if (copiedNames.has(pkgName)) { skippedDupes++; continue; // Keep the first version (closer to openclaw in dep tree) } copiedNames.add(pkgName); const dest = path.join(outputNodeModules, pkgName); try { // Ensure parent directory exists (for scoped packages like @clack/core) fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.cpSync(realPath, dest, { recursive: true, dereference: true }); copiedCount++; } catch (err) { echo` โš ๏ธ Skipped ${pkgName}: ${err.message}`; } } // 6. Clean up the bundle to reduce package size // // This removes platform-agnostic waste: dev artifacts, docs, source maps, // type definitions, test directories, and known large unused subdirectories. // Platform-specific cleanup (e.g. koffi binaries) is handled in after-pack.cjs // which has access to the target platform/arch context. function getDirSize(dir) { let total = 0; try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) total += getDirSize(p); else if (entry.isFile()) total += fs.statSync(p).size; } } catch { /* ignore */ } return total; } function formatSize(bytes) { if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`; if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}M`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}K`; return `${bytes}B`; } function rmSafe(target) { try { const stat = fs.lstatSync(target); if (stat.isDirectory()) fs.rmSync(target, { recursive: true, force: true }); else fs.rmSync(target, { force: true }); return true; } catch { return false; } } function cleanupBundle(outputDir) { let removedCount = 0; const nm = path.join(outputDir, 'node_modules'); const ext = path.join(outputDir, 'extensions'); // --- openclaw root junk --- for (const name of ['CHANGELOG.md', 'README.md']) { if (rmSafe(path.join(outputDir, name))) removedCount++; } // docs/ is kept โ€” contains prompt templates and other runtime-used prompts // --- extensions: clean junk from source, aggressively clean nested node_modules --- // Extension source (.ts files) are runtime entry points โ€” must be preserved. // Only nested node_modules/ inside extensions get the aggressive cleanup. if (fs.existsSync(ext)) { const JUNK_EXTS = new Set(['.prose', '.ignored_openclaw', '.keep']); const NM_REMOVE_DIRS = new Set([ 'test', 'tests', '__tests__', '.github', 'docs', 'examples', 'example', ]); const NM_REMOVE_FILE_EXTS = ['.d.ts', '.d.ts.map', '.js.map', '.mjs.map', '.ts.map', '.markdown']; const NM_REMOVE_FILE_NAMES = new Set([ '.DS_Store', 'README.md', 'CHANGELOG.md', 'LICENSE.md', 'CONTRIBUTING.md', 'tsconfig.json', '.npmignore', '.eslintrc', '.prettierrc', '.editorconfig', ]); function walkExt(dir, insideNodeModules) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (insideNodeModules && NM_REMOVE_DIRS.has(entry.name)) { if (rmSafe(full)) removedCount++; } else { walkExt(full, insideNodeModules || entry.name === 'node_modules'); } } else if (entry.isFile()) { if (insideNodeModules) { const name = entry.name; if (NM_REMOVE_FILE_NAMES.has(name) || NM_REMOVE_FILE_EXTS.some(e => name.endsWith(e))) { if (rmSafe(full)) removedCount++; } } else { if (JUNK_EXTS.has(path.extname(entry.name)) || entry.name.endsWith('.md')) { if (rmSafe(full)) removedCount++; } } } } } walkExt(ext, false); } // --- node_modules: remove unnecessary file types and directories --- if (fs.existsSync(nm)) { const REMOVE_DIRS = new Set([ 'test', 'tests', '__tests__', '.github', 'docs', 'examples', 'example', ]); const REMOVE_FILE_EXTS = ['.d.ts', '.d.ts.map', '.js.map', '.mjs.map', '.ts.map', '.markdown']; const REMOVE_FILE_NAMES = new Set([ '.DS_Store', 'README.md', 'CHANGELOG.md', 'LICENSE.md', 'CONTRIBUTING.md', 'tsconfig.json', '.npmignore', '.eslintrc', '.prettierrc', '.editorconfig', ]); function walkClean(dir) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (REMOVE_DIRS.has(entry.name)) { if (rmSafe(full)) removedCount++; } else { walkClean(full); } } else if (entry.isFile()) { const name = entry.name; if (REMOVE_FILE_NAMES.has(name) || REMOVE_FILE_EXTS.some(e => name.endsWith(e))) { if (rmSafe(full)) removedCount++; } } } } walkClean(nm); } // --- known large unused subdirectories --- const LARGE_REMOVALS = [ 'node_modules/pdfjs-dist/legacy', 'node_modules/pdfjs-dist/types', 'node_modules/node-llama-cpp/llama', 'node_modules/koffi/src', 'node_modules/koffi/vendor', 'node_modules/koffi/doc', ]; for (const rel of LARGE_REMOVALS) { if (rmSafe(path.join(outputDir, rel))) removedCount++; } return removedCount; } echo``; echo`๐Ÿงน Cleaning up bundle (removing dev artifacts, docs, source maps, type defs)...`; const sizeBefore = getDirSize(OUTPUT); const cleanedCount = cleanupBundle(OUTPUT); const sizeAfter = getDirSize(OUTPUT); echo` Removed ${cleanedCount} files/directories`; echo` Size: ${formatSize(sizeBefore)} โ†’ ${formatSize(sizeAfter)} (saved ${formatSize(sizeBefore - sizeAfter)})`; // 7. Verify the bundle const entryExists = fs.existsSync(path.join(OUTPUT, 'openclaw.mjs')); const distExists = fs.existsSync(path.join(OUTPUT, 'dist', 'entry.js')); echo``; echo`โœ… Bundle complete: ${OUTPUT}`; echo` Unique packages copied: ${copiedCount}`; echo` Dev-only packages skipped: ${skippedDevCount}`; echo` Duplicate versions skipped: ${skippedDupes}`; echo` Total discovered: ${collected.size}`; echo` openclaw.mjs: ${entryExists ? 'โœ“' : 'โœ—'}`; echo` dist/entry.js: ${distExists ? 'โœ“' : 'โœ—'}`; if (!entryExists || !distExists) { echo`โŒ Bundle verification failed!`; process.exit(1); }