/** * after-pack.cjs * * electron-builder afterPack hook. * * Problem: electron-builder respects .gitignore when copying extraResources. * Since .gitignore contains "node_modules/", the openclaw bundle's * node_modules directory is silently skipped during the extraResources copy. * * Solution: This hook runs AFTER electron-builder finishes packing. It manually * copies build/openclaw/node_modules/ into the output resources directory, * bypassing electron-builder's glob filtering entirely. * * Additionally it performs two rounds of cleanup: * 1. General cleanup — removes dev artifacts (type defs, source maps, docs, * test dirs) from both the openclaw root and its node_modules. * 2. Platform-specific cleanup — strips native binaries for non-target * platforms (koffi multi-platform prebuilds, @napi-rs/canvas, @img/sharp, * @mariozechner/clipboard). */ const { cpSync, existsSync, readdirSync, rmSync, statSync } = require('fs'); const { join } = require('path'); // ── Arch helpers ───────────────────────────────────────────────────────────── // electron-builder Arch enum: 0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal const ARCH_MAP = { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }; function resolveArch(archEnum) { return ARCH_MAP[archEnum] || 'x64'; } // ── General cleanup ────────────────────────────────────────────────────────── function cleanupUnnecessaryFiles(dir) { let removedCount = 0; const REMOVE_DIRS = new Set([ 'test', 'tests', '__tests__', '.github', '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 walk(currentDir) { let entries; try { entries = readdirSync(currentDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = join(currentDir, entry.name); if (entry.isDirectory()) { if (REMOVE_DIRS.has(entry.name)) { try { rmSync(fullPath, { recursive: true, force: true }); removedCount++; } catch { /* */ } } else { walk(fullPath); } } else if (entry.isFile()) { const name = entry.name; if (REMOVE_FILE_NAMES.has(name) || REMOVE_FILE_EXTS.some(e => name.endsWith(e))) { try { rmSync(fullPath, { force: true }); removedCount++; } catch { /* */ } } } } } walk(dir); return removedCount; } // ── Platform-specific: koffi ───────────────────────────────────────────────── // koffi ships 18 platform pre-builds under koffi/build/koffi/{platform}_{arch}/. // We only need the one matching the target. function cleanupKoffi(nodeModulesDir, platform, arch) { const koffiDir = join(nodeModulesDir, 'koffi', 'build', 'koffi'); if (!existsSync(koffiDir)) return 0; const keepTarget = `${platform}_${arch}`; let removed = 0; for (const entry of readdirSync(koffiDir)) { if (entry !== keepTarget) { try { rmSync(join(koffiDir, entry), { recursive: true, force: true }); removed++; } catch { /* */ } } } return removed; } // ── Platform-specific: scoped native packages ──────────────────────────────── // Packages like @napi-rs/canvas-darwin-arm64, @img/sharp-linux-x64, etc. // Only the variant matching the target platform should survive. const PLATFORM_NATIVE_SCOPES = { '@napi-rs': /^canvas-(darwin|linux|win32)-(x64|arm64)/, '@img': /^sharp(?:-libvips)?-(darwin|linux|win32)-(x64|arm64)/, '@mariozechner': /^clipboard-(darwin|linux|win32)-(x64|arm64|universal)/, }; function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) { let removed = 0; for (const [scope, pattern] of Object.entries(PLATFORM_NATIVE_SCOPES)) { const scopeDir = join(nodeModulesDir, scope); if (!existsSync(scopeDir)) continue; for (const entry of readdirSync(scopeDir)) { const match = entry.match(pattern); if (!match) continue; // not a platform-specific package, leave it const pkgPlatform = match[1]; const pkgArch = match[2]; const isMatch = pkgPlatform === platform && (pkgArch === arch || pkgArch === 'universal'); if (!isMatch) { try { rmSync(join(scopeDir, entry), { recursive: true, force: true }); removed++; } catch { /* */ } } } } return removed; } // ── Main hook ──────────────────────────────────────────────────────────────── exports.default = async function afterPack(context) { const appOutDir = context.appOutDir; const platform = context.electronPlatformName; // 'win32' | 'darwin' | 'linux' const arch = resolveArch(context.arch); console.log(`[after-pack] Target: ${platform}/${arch}`); const src = join(__dirname, '..', 'build', 'openclaw', 'node_modules'); let resourcesDir; if (platform === 'darwin') { const appName = context.packager.appInfo.productFilename; resourcesDir = join(appOutDir, `${appName}.app`, 'Contents', 'Resources'); } else { resourcesDir = join(appOutDir, 'resources'); } const openclawRoot = join(resourcesDir, 'openclaw'); const dest = join(openclawRoot, 'node_modules'); const pluginsSrcRoot = join(__dirname, '..', 'build', 'openclaw-plugins'); const pluginsDestRoot = join(resourcesDir, 'openclaw-plugins'); if (!existsSync(src)) { console.warn('[after-pack] ⚠️ build/openclaw/node_modules not found. Run bundle-openclaw first.'); return; } // 1. Copy node_modules (electron-builder skips it due to .gitignore) const depCount = readdirSync(src, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name !== '.bin') .length; console.log(`[after-pack] Copying ${depCount} openclaw dependencies to ${dest} ...`); cpSync(src, dest, { recursive: true }); console.log('[after-pack] ✅ openclaw node_modules copied.'); // 1.1 Copy plugin node_modules (also skipped due to .gitignore) if (existsSync(pluginsSrcRoot) && existsSync(pluginsDestRoot)) { const pluginDirs = readdirSync(pluginsSrcRoot, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); for (const pluginId of pluginDirs) { const pluginSrcNM = join(pluginsSrcRoot, pluginId, 'node_modules'); const pluginDestRoot = join(pluginsDestRoot, pluginId); const pluginDestNM = join(pluginDestRoot, 'node_modules'); if (!existsSync(pluginSrcNM) || !existsSync(pluginDestRoot)) continue; console.log(`[after-pack] Copying plugin deps for ${pluginId} -> ${pluginDestNM}`); cpSync(pluginSrcNM, pluginDestNM, { recursive: true }); // Apply the same cleanup strategy for plugin bundles. cleanupUnnecessaryFiles(pluginDestRoot); cleanupKoffi(pluginDestNM, platform, arch); cleanupNativePlatformPackages(pluginDestNM, platform, arch); } console.log('[after-pack] ✅ openclaw plugin node_modules copied.'); } // 2. General cleanup on the full openclaw directory (not just node_modules) console.log('[after-pack] 🧹 Cleaning up unnecessary files ...'); const removedRoot = cleanupUnnecessaryFiles(openclawRoot); console.log(`[after-pack] ✅ Removed ${removedRoot} unnecessary files/directories.`); // 3. Platform-specific: strip koffi non-target platform binaries const koffiRemoved = cleanupKoffi(dest, platform, arch); if (koffiRemoved > 0) { console.log(`[after-pack] ✅ koffi: removed ${koffiRemoved} non-target platform binaries (kept ${platform}_${arch}).`); } // 4. Platform-specific: strip wrong-platform native packages const nativeRemoved = cleanupNativePlatformPackages(dest, platform, arch); if (nativeRemoved > 0) { console.log(`[after-pack] ✅ Removed ${nativeRemoved} non-target native platform packages.`); } };