/** * 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, mkdirSync, realpathSync } = require('fs'); const { join, dirname, basename } = require('path'); // On Windows, paths in pnpm's virtual store can exceed the default MAX_PATH // limit (260 chars). Node.js 18.17+ respects the system LongPathsEnabled // registry key, but as a safety net we normalize paths to use the \\?\ prefix // on Windows, which bypasses the limit unconditionally. function normWin(p) { if (process.platform !== 'win32') return p; if (p.startsWith('\\\\?\\')) return p; return '\\\\?\\' + p.replace(/\//g, '\\'); } // ── 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; } // ── Broken module patcher ───────────────────────────────────────────────────── // Some bundled packages have transpiled CJS that sets `module.exports = exports.default` // without ever assigning `exports.default`, leaving module.exports === undefined. // This causes `TypeError: Cannot convert undefined or null to object` in Node.js 22+ // ESM interop (translators.js hasOwnProperty call). We patch these after copying. const MODULE_PATCHES = { // node-domexception@1.0.0: index.js sets module.exports = undefined. // Node.js 18+ ships DOMException as a built-in; this shim re-exports it. 'node-domexception/index.js': [ "'use strict';", '// Shim: original transpiled file sets module.exports = exports.default (undefined).', '// Node.js 18+ has DOMException as a built-in global.', 'const dom = globalThis.DOMException ||', ' class DOMException extends Error {', " constructor(msg, name) { super(msg); this.name = name || 'Error'; }", ' };', 'module.exports = dom;', 'module.exports.DOMException = dom;', 'module.exports.default = dom;', ].join('\n') + '\n', }; function patchBrokenModules(nodeModulesDir) { const { writeFileSync } = require('fs'); let count = 0; for (const [rel, content] of Object.entries(MODULE_PATCHES)) { const target = join(nodeModulesDir, rel); if (existsSync(target)) { writeFileSync(target, content, 'utf8'); count++; } } if (count > 0) { console.log(`[after-pack] 🩹 Patched ${count} broken module(s) in ${nodeModulesDir}`); } } // ── Plugin bundler ─────────────────────────────────────────────────────────── // Bundles a single OpenClaw plugin (and its transitive deps) from node_modules // directly into the packaged resources directory. Mirrors the logic in // bundle-openclaw-plugins.mjs so the packaged app is self-contained even when // build/openclaw-plugins/ was not pre-generated. function getVirtualStoreNodeModules(realPkgPath) { let dir = realPkgPath; while (dir !== dirname(dir)) { if (basename(dir) === 'node_modules') return dir; dir = dirname(dir); } return null; } function listPkgs(nodeModulesDir) { const result = []; const nDir = normWin(nodeModulesDir); if (!existsSync(nDir)) return result; for (const entry of readdirSync(nDir)) { if (entry === '.bin') continue; // Use original (non-normWin) join for the logical path stored in result.fullPath, // so callers can still call getVirtualStoreNodeModules() on it correctly. const fullPath = join(nodeModulesDir, entry); if (entry.startsWith('@')) { let subs; try { subs = readdirSync(normWin(fullPath)); } catch { continue; } for (const sub of subs) { result.push({ name: `${entry}/${sub}`, fullPath: join(fullPath, sub) }); } } else { result.push({ name: entry, fullPath }); } } return result; } function bundlePlugin(nodeModulesRoot, npmName, destDir) { const pkgPath = join(nodeModulesRoot, ...npmName.split('/')); if (!existsSync(pkgPath)) { console.warn(`[after-pack] ⚠️ Plugin package not found: ${pkgPath}. Run pnpm install.`); return false; } let realPluginPath; try { realPluginPath = realpathSync(normWin(pkgPath)); } catch { realPluginPath = pkgPath; } // Copy plugin package itself if (existsSync(normWin(destDir))) rmSync(normWin(destDir), { recursive: true, force: true }); mkdirSync(normWin(destDir), { recursive: true }); cpSync(normWin(realPluginPath), normWin(destDir), { recursive: true, dereference: true }); // Collect transitive deps via pnpm virtual store BFS const collected = new Map(); const queue = []; const rootVirtualNM = getVirtualStoreNodeModules(realPluginPath); if (!rootVirtualNM) { console.warn(`[after-pack] ⚠️ Could not find virtual store for ${npmName}, skipping deps.`); return true; } queue.push({ nodeModulesDir: rootVirtualNM, skipPkg: npmName }); // Read peerDependencies from the plugin's package.json so we don't bundle // packages that are provided by the host environment (e.g. openclaw itself). const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']); const SKIP_SCOPES = ['@types/']; try { const pluginPkg = JSON.parse( require('fs').readFileSync(join(destDir, '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 listPkgs(nodeModulesDir)) { if (name === skipPkg) continue; if (SKIP_PACKAGES.has(name) || SKIP_SCOPES.some(s => name.startsWith(s))) continue; let rp; try { rp = realpathSync(normWin(fullPath)); } catch { continue; } if (collected.has(rp)) continue; collected.set(rp, name); const depVirtualNM = getVirtualStoreNodeModules(rp); if (depVirtualNM && depVirtualNM !== nodeModulesDir) { queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name }); } } } // Copy flattened deps into destDir/node_modules const destNM = join(destDir, 'node_modules'); mkdirSync(destNM, { recursive: true }); const copiedNames = new Set(); let count = 0; for (const [rp, pkgName] of collected) { if (copiedNames.has(pkgName)) continue; copiedNames.add(pkgName); const d = join(destNM, pkgName); try { mkdirSync(normWin(dirname(d)), { recursive: true }); cpSync(normWin(rp), normWin(d), { recursive: true, dereference: true }); count++; } catch (e) { console.warn(`[after-pack] Skipped dep ${pkgName}: ${e.message}`); } } console.log(`[after-pack] ✅ Plugin ${npmName}: copied ${count} deps to ${destDir}`); return true; } // ── 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 nodeModulesRoot = join(__dirname, '..', 'node_modules'); 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.'); // Patch broken modules whose CJS transpiled output sets module.exports = undefined, // causing TypeError in Node.js 22+ ESM interop. patchBrokenModules(dest); // 1.1 Bundle OpenClaw plugins directly from node_modules into packaged resources. // This is intentionally done in afterPack (not extraResources) because: // - electron-builder silently skips extraResources entries whose source // directory doesn't exist (build/openclaw-plugins/ may not be pre-generated) // - node_modules/ is excluded by .gitignore so the deps copy must be manual const BUNDLED_PLUGINS = [ { npmName: '@soimy/dingtalk', pluginId: 'dingtalk' }, { npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' } ]; mkdirSync(pluginsDestRoot, { recursive: true }); for (const { npmName, pluginId } of BUNDLED_PLUGINS) { const pluginDestDir = join(pluginsDestRoot, pluginId); console.log(`[after-pack] Bundling plugin ${npmName} -> ${pluginDestDir}`); const ok = bundlePlugin(nodeModulesRoot, npmName, pluginDestDir); if (ok) { const pluginNM = join(pluginDestDir, 'node_modules'); cleanupUnnecessaryFiles(pluginDestDir); if (existsSync(pluginNM)) { cleanupKoffi(pluginNM, platform, arch); cleanupNativePlatformPackages(pluginNM, platform, arch); } } } // 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.`); } };