Files
NianToB/scripts/bundle-openclaw.mjs
2026-05-12 19:44:44 +08:00

1305 lines
50 KiB
JavaScript

#!/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');
const YINIAN_RUNTIME_PATCH_MARKER_FILE = '.yinian-runtime-patch.json';
const YINIAN_RUNTIME_PATCH_MARKER = {
id: 'yinian-openclaw-runtime',
version: '2026-05-12-runtime-templates-selfref-v1',
patches: [
'bundled-npm-runner-env',
'optional-native-clipboard-removed',
'desktop-fast-chat-lightweight-tools',
'runtime-reference-templates-preserved',
'managed-openclaw-self-reference-required',
],
};
// On Windows, pnpm virtual store paths can exceed MAX_PATH (260 chars).
function normWin(p) {
if (process.platform !== 'win32') return p;
if (p.startsWith('\\\\?\\')) return p;
return '\\\\?\\' + p.replace(/\//g, '\\');
}
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 = [];
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('@')) {
try {
const scopeEntries = fs.readdirSync(normWin(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',
// @discordjs/opus is a native .node addon compiled for the system Node.js
// ABI. The Gateway runs inside Electron's utilityProcess which has a
// different ABI, so the binary fails with "Cannot find native binding".
// The package is optional — openclaw gracefully degrades when absent
// (only Discord voice features are affected; text chat works fine).
'@discordjs/opus',
]);
const SKIP_SCOPES = ['@cloudflare/', '@types/'];
let skippedDevCount = 0;
let skippedOptionalNativeCount = 0;
function isOptionalNativeClipboardPackage(name) {
return name === '@mariozechner/clipboard'
|| name.startsWith('@mariozechner/clipboard-');
}
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 (isOptionalNativeClipboardPackage(name)) {
skippedOptionalNativeCount++;
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`;
if (skippedOptionalNativeCount > 0) {
echo` Skipped ${skippedOptionalNativeCount} optional native clipboard package references`;
}
// 4b. Collect extra packages required by ClawX's Electron main process that are
// NOT deps of openclaw. These are resolved from openclaw's context at runtime
// (via createRequire from the openclaw directory) so they must live in the
// bundled openclaw/node_modules/.
//
// For each package we resolve it from the workspace's own node_modules,
// then BFS its transitive deps exactly like we did for openclaw above.
const OPENCLAW_RUNTIME_DEPS_PACKAGES = [
'@agentclientprotocol/claude-agent-acp',
'@agentclientprotocol/sdk',
'@anthropic-ai/sdk',
'@anthropic-ai/vertex-sdk',
'@aws-sdk/client-bedrock',
'@aws-sdk/client-bedrock-runtime',
'@aws-sdk/client-s3',
'@aws-sdk/credential-provider-node',
'@aws-sdk/s3-request-presigner',
'@aws/bedrock-token-generator',
'@azure/identity',
'@clack/prompts',
'@clawdbot/lobster',
'@discordjs/voice',
'@google/genai',
'@grammyjs/runner',
'@grammyjs/transformer-throttler',
'@homebridge/ciao',
'@lancedb/lancedb',
'@larksuiteoapi/node-sdk',
'@line/bot-sdk',
'@lydell/node-pty',
'@mariozechner/pi-agent-core',
'@mariozechner/pi-ai',
'@mariozechner/pi-coding-agent',
'@matrix-org/matrix-sdk-crypto-nodejs',
'@matrix-org/matrix-sdk-crypto-wasm',
'@microsoft/teams.api',
'@microsoft/teams.apps',
'@modelcontextprotocol/sdk',
'@mozilla/readability',
'@openai/codex',
'@opentelemetry/api',
'@opentelemetry/api-logs',
'@opentelemetry/exporter-logs-otlp-proto',
'@opentelemetry/exporter-metrics-otlp-proto',
'@opentelemetry/exporter-trace-otlp-proto',
'@opentelemetry/resources',
'@opentelemetry/sdk-logs',
'@opentelemetry/sdk-metrics',
'@opentelemetry/sdk-node',
'@opentelemetry/sdk-trace-base',
'@opentelemetry/semantic-conventions',
'@pierre/diffs',
'@pierre/theme',
'@slack/bolt',
'@slack/web-api',
'@tencent-connect/qqbot-connector',
'@twurple/api',
'@twurple/auth',
'@twurple/chat',
'@urbit/aura',
'@whiskeysockets/baileys',
'@zed-industries/codex-acp',
'acpx',
'ajv',
'chokidar',
'commander',
'croner',
'discord-api-types',
'dotenv',
'docx',
'express',
'fake-indexeddb',
'gaxios',
'global-agent',
'google-auth-library',
'grammy',
'https-proxy-agent',
'jiti',
'jimp',
'json5',
'jsonwebtoken',
'jszip',
'jwks-rsa',
'linkedom',
'markdown-it',
'matrix-js-sdk',
'minimatch',
'mpg123-decoder',
'music-metadata',
'node-edge-tts',
'nostr-tools',
'openai',
'openshell',
'opusscript',
'pdfjs-dist',
'playwright-core',
'pptxgenjs',
'react-icons',
'semver',
'sharp',
'silk-wasm',
'sqlite-vec',
'tar',
'tokenjuice',
'tslog',
'typebox',
'undici',
'web-push',
'ws',
'yaml',
'zca-js',
'zod',
];
const EXTRA_BUNDLED_PACKAGES = [
...OPENCLAW_RUNTIME_DEPS_PACKAGES,
'qrcode-terminal', // QR rendering is loaded from OpenClaw context by channel login flows
];
let extraCount = 0;
for (const pkgName of EXTRA_BUNDLED_PACKAGES) {
const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/'));
if (!fs.existsSync(pkgLink)) {
echo` ⚠️ Extra package ${pkgName} not found in workspace node_modules, skipping.`;
continue;
}
let pkgReal;
try { pkgReal = fs.realpathSync(pkgLink); } catch { continue; }
if (!collected.has(pkgReal)) {
collected.set(pkgReal, pkgName);
extraCount++;
// BFS this package's own transitive deps
const depVirtualNM = getVirtualStoreNodeModules(pkgReal);
if (depVirtualNM) {
const extraQueue = [{ nodeModulesDir: depVirtualNM, skipPkg: pkgName }];
while (extraQueue.length > 0) {
const { nodeModulesDir, skipPkg } = extraQueue.shift();
const packages = listPackages(nodeModulesDir);
for (const { name, fullPath } of packages) {
if (name === skipPkg) continue;
if (isOptionalNativeClipboardPackage(name)) {
skippedOptionalNativeCount++;
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);
extraCount++;
const innerVirtualNM = getVirtualStoreNodeModules(realPath);
if (innerVirtualNM && innerVirtualNM !== nodeModulesDir) {
extraQueue.push({ nodeModulesDir: innerVirtualNM, skipPkg: name });
}
}
}
}
}
}
if (extraCount > 0) {
echo` Added ${extraCount} extra packages (+ transitive deps) for Electron main process`;
}
// 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 (isOptionalNativeClipboardPackage(pkgName)) {
skippedOptionalNativeCount++;
continue;
}
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 {
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}`;
}
}
// 5b. Merge built-in extension node_modules into top-level node_modules
//
// OpenClaw 3.31+ ships built-in extensions (telegram, discord, etc.) under
// dist/extensions/<ext>/node_modules/. The Rollup bundler creates shared
// chunks at dist/ root (e.g. sticker-cache-*.js) that eagerly import
// extension-specific packages like "grammy". Node.js resolves bare
// specifiers from the importing file's directory upward:
// dist/ → openclaw/ → openclaw/node_modules/
// It does NOT search dist/extensions/telegram/node_modules/.
//
// Fix: copy extension deps into the top-level node_modules/ so they are
// resolvable from shared chunks. Skip-if-exists preserves version priority
// (openclaw's own deps take precedence over extension deps).
const extensionsDir = path.join(OUTPUT, 'dist', 'extensions');
let mergedExtCount = 0;
if (fs.existsSync(extensionsDir)) {
for (const extEntry of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
if (!extEntry.isDirectory()) continue;
const extNM = path.join(extensionsDir, extEntry.name, 'node_modules');
if (!fs.existsSync(extNM)) continue;
for (const pkgEntry of fs.readdirSync(extNM, { withFileTypes: true })) {
if (!pkgEntry.isDirectory() || pkgEntry.name === '.bin') continue;
const srcPkg = path.join(extNM, pkgEntry.name);
if (pkgEntry.name.startsWith('@')) {
// Scoped package — iterate sub-entries
let scopeEntries;
try { scopeEntries = fs.readdirSync(srcPkg, { withFileTypes: true }); } catch { continue; }
for (const scopeEntry of scopeEntries) {
if (!scopeEntry.isDirectory()) continue;
const scopedName = `${pkgEntry.name}/${scopeEntry.name}`;
if (isOptionalNativeClipboardPackage(scopedName)) {
skippedOptionalNativeCount++;
continue;
}
if (copiedNames.has(scopedName)) continue;
const srcScoped = path.join(srcPkg, scopeEntry.name);
const destScoped = path.join(outputNodeModules, pkgEntry.name, scopeEntry.name);
try {
fs.mkdirSync(normWin(path.dirname(destScoped)), { recursive: true });
fs.cpSync(normWin(srcScoped), normWin(destScoped), { recursive: true, dereference: true });
copiedNames.add(scopedName);
mergedExtCount++;
} catch { /* skip on copy error */ }
}
} else {
if (isOptionalNativeClipboardPackage(pkgEntry.name)) {
skippedOptionalNativeCount++;
continue;
}
if (copiedNames.has(pkgEntry.name)) continue;
const destPkg = path.join(outputNodeModules, pkgEntry.name);
try {
fs.cpSync(normWin(srcPkg), normWin(destPkg), { recursive: true, dereference: true });
copiedNames.add(pkgEntry.name);
mergedExtCount++;
} catch { /* skip on copy error */ }
}
}
}
}
if (mergedExtCount > 0) {
echo` Merged ${mergedExtCount} extension packages into top-level node_modules`;
}
let forcedRuntimeDepCount = 0;
for (const pkgName of OPENCLAW_RUNTIME_DEPS_PACKAGES) {
if (isOptionalNativeClipboardPackage(pkgName)) continue;
const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/'));
if (!fs.existsSync(pkgLink)) continue;
let pkgReal;
try { pkgReal = fs.realpathSync(pkgLink); } catch { continue; }
const dest = path.join(outputNodeModules, pkgName);
try {
fs.rmSync(normWin(dest), { recursive: true, force: true });
fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true });
fs.cpSync(normWin(pkgReal), normWin(dest), { recursive: true, dereference: true });
copiedNames.add(pkgName);
forcedRuntimeDepCount++;
} catch (err) {
echo` ⚠️ Failed to force-copy runtime dependency ${pkgName}: ${err.message}`;
}
}
if (forcedRuntimeDepCount > 0) {
echo` Forced ${forcedRuntimeDepCount} runtime dependency package(s) to workspace-resolved versions`;
}
// 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 pruneOpenClawDocsButKeepRuntimeTemplates(outputDir) {
const docsDir = path.join(outputDir, 'docs');
const templatesDir = path.join(docsDir, 'reference', 'templates');
if (!fs.existsSync(docsDir)) return false;
if (!fs.existsSync(templatesDir)) {
return rmSafe(docsDir);
}
const tmpDir = path.join(outputDir, '.openclaw-runtime-templates.tmp');
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.cpSync(templatesDir, tmpDir, { recursive: true, dereference: true });
fs.rmSync(docsDir, { recursive: true, force: true });
fs.mkdirSync(templatesDir, { recursive: true });
fs.cpSync(tmpDir, templatesDir, { recursive: true, dereference: true });
fs.rmSync(tmpDir, { recursive: true, force: true });
return true;
}
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++;
}
// Most docs are site content, but docs/reference/templates is runtime input:
// OpenClaw uses it when building workspace context before every chat.
if (pruneOpenClawDocsButKeepRuntimeTemplates(outputDir)) removedCount++;
// --- 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',
'__snapshots__', '__image_snapshots__', 'snapshots', 'fixtures', 'fixture',
'bench', 'benchmark', 'benchmarks', 'screenshots',
]);
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',
]);
const isEnvFile = (name) => name === '.env' || name.startsWith('.env.');
// .md files inside skills/ directories are runtime content (SKILL.md,
// block-types.md, etc.) and must NOT be removed.
const JUNK_MD_NAMES = new Set([
'README.md', 'CHANGELOG.md', 'LICENSE.md', 'CONTRIBUTING.md',
]);
function walkExt(dir, insideNodeModules, insideSkills) {
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',
insideSkills || entry.name === 'skills',
);
}
} else if (entry.isFile()) {
if (insideNodeModules) {
const name = entry.name;
if (isEnvFile(name) || NM_REMOVE_FILE_NAMES.has(name) || NM_REMOVE_FILE_EXTS.some(e => name.endsWith(e))) {
if (rmSafe(full)) removedCount++;
}
} else {
// Inside skills/ directories, .md files are skill content — keep them.
// Outside skills/, remove known junk .md files only.
const isMd = entry.name.endsWith('.md');
const isJunkMd = isMd && JUNK_MD_NAMES.has(entry.name);
const isJunkExt = JUNK_EXTS.has(path.extname(entry.name));
if (isJunkExt || (isMd && !insideSkills && isJunkMd)) {
if (rmSafe(full)) removedCount++;
}
}
}
}
}
walkExt(ext, false, 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',
'__snapshots__', '__image_snapshots__', 'snapshots', 'fixtures', 'fixture',
'bench', 'benchmark', 'benchmarks', 'screenshots',
]);
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',
]);
const isEnvFile = (name) => name === '.env' || name.startsWith('.env.');
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 (isEnvFile(name) || 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',
'extensions/feishu', // Removed in favor of official @larksuite/openclaw-lark plugin
];
for (const rel of LARGE_REMOVALS) {
if (rmSafe(path.join(outputDir, rel))) removedCount++;
}
return removedCount;
}
function removeOptionalNativeClipboard(nodeModulesDir) {
const scopeDir = path.join(nodeModulesDir, '@mariozechner');
let removedCount = 0;
let entries = [];
try {
entries = fs.readdirSync(scopeDir, { withFileTypes: true });
} catch {
return 0;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const packageName = `@mariozechner/${entry.name}`;
if (!isOptionalNativeClipboardPackage(packageName)) continue;
if (rmSafe(path.join(scopeDir, entry.name))) {
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 removedClipboardCount = removeOptionalNativeClipboard(outputNodeModules);
const sizeAfter = getDirSize(OUTPUT);
echo` Removed ${cleanedCount} files/directories`;
if (removedClipboardCount > 0) {
echo` Removed ${removedClipboardCount} optional native clipboard package(s)`;
}
echo` Size: ${formatSize(sizeBefore)}${formatSize(sizeAfter)} (saved ${formatSize(sizeBefore - sizeAfter)})`;
// 7. Patch known broken packages
//
// Some packages in the ecosystem have transpiled CJS output that sets
// `module.exports = exports.default` without ever assigning `exports.default`,
// resulting in `module.exports = undefined`. This causes a TypeError in
// Node.js 22+ ESM interop when the translators try to call hasOwnProperty on
// the undefined exports object.
//
// We also patch Windows child_process spawn sites in the bundled agent runtime
// so shell/tool execution does not flash a console window for each tool call.
// We patch these files in-place after the copy so the bundle is safe to run.
function patchBrokenModules(nodeModulesDir) {
const rewritePatches = {
// node-domexception@1.0.0: transpiled index.js leaves module.exports = undefined.
// Node.js 18+ ships DOMException as a built-in global, so a simple shim works.
'node-domexception/index.js': [
`'use strict';`,
`// Shim: the original transpiled file sets module.exports = exports.default`,
`// (which is undefined), causing TypeError in Node.js 22+ ESM interop.`,
`// 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'),
};
const replacePatches = [
// Note: @mariozechner/pi-coding-agent is no longer a dep of openclaw 3.31.
];
let count = 0;
for (const [rel, content] of Object.entries(rewritePatches)) {
const target = path.join(nodeModulesDir, rel);
if (fs.existsSync(target)) {
fs.writeFileSync(target, content + '\n', 'utf8');
count++;
}
}
for (const { rel, search, replace } of replacePatches) {
const target = path.join(nodeModulesDir, rel);
if (!fs.existsSync(target)) continue;
const current = fs.readFileSync(target, 'utf8');
if (!current.includes(search)) {
echo` ⚠️ Skipped patch for ${rel}: expected source snippet not found`;
continue;
}
const next = current.replace(search, replace);
if (next !== current) {
fs.writeFileSync(target, next, 'utf8');
count++;
}
}
// lru-cache CJS/ESM interop fix (recursive):
// Multiple versions of lru-cache may exist in the output tree — not just
// at node_modules/lru-cache/ but also nested inside other packages.
// Older CJS versions (v5, v6) export the class via `module.exports = LRUCache`
// without a named `LRUCache` property, so `import { LRUCache } from 'lru-cache'`
// fails in Node.js 22+ ESM interop (used by Electron 40+).
// We recursively scan the entire output for ALL lru-cache installations and
// patch each CJS entry to ensure `exports.LRUCache` always exists.
function patchAllLruCacheInstances(rootDir) {
let lruCount = 0;
const stack = [rootDir];
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try { entries = fs.readdirSync(normWin(dir), { withFileTypes: true }); } catch { continue; }
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
let isDirectory = entry.isDirectory();
if (!isDirectory) {
// pnpm layout may contain symlink/junction directories on Windows.
try { isDirectory = fs.statSync(normWin(fullPath)).isDirectory(); } catch { isDirectory = false; }
}
if (!isDirectory) continue;
if (entry.name === 'lru-cache') {
const pkgPath = path.join(fullPath, 'package.json');
if (!fs.existsSync(normWin(pkgPath))) { stack.push(fullPath); continue; }
try {
const pkg = JSON.parse(fs.readFileSync(normWin(pkgPath), 'utf8'));
if (pkg.type === 'module') continue; // ESM version — already has named exports
const mainFile = pkg.main || 'index.js';
const entryFile = path.join(fullPath, mainFile);
if (!fs.existsSync(normWin(entryFile))) continue;
const original = fs.readFileSync(normWin(entryFile), 'utf8');
if (!original.includes('exports.LRUCache')) {
const patched = [
original,
'',
'// ClawX patch: add LRUCache named export for Node.js 22+ ESM interop',
'if (typeof module.exports === "function" && !module.exports.LRUCache) {',
' module.exports.LRUCache = module.exports;',
'}',
'',
].join('\n');
fs.writeFileSync(normWin(entryFile), patched, 'utf8');
lruCount++;
echo` 🩹 Patched lru-cache CJS (v${pkg.version}) at ${path.relative(rootDir, fullPath)}`;
}
// lru-cache v7 ESM entry exports default only; add named export.
const moduleFile = typeof pkg.module === 'string' ? pkg.module : null;
if (moduleFile) {
const esmEntry = path.join(fullPath, moduleFile);
if (fs.existsSync(normWin(esmEntry))) {
const esmOriginal = fs.readFileSync(normWin(esmEntry), 'utf8');
if (
esmOriginal.includes('export default LRUCache') &&
!esmOriginal.includes('export { LRUCache')
) {
const esmPatched = [esmOriginal, '', 'export { LRUCache }', ''].join('\n');
fs.writeFileSync(normWin(esmEntry), esmPatched, 'utf8');
lruCount++;
echo` 🩹 Patched lru-cache ESM (v${pkg.version}) at ${path.relative(rootDir, fullPath)}`;
}
}
}
} catch (err) {
echo` ⚠️ Failed to patch lru-cache at ${fullPath}: ${err.message}`;
}
} else {
stack.push(fullPath);
}
}
}
return lruCount;
}
const lruPatched = patchAllLruCacheInstances(nodeModulesDir);
count += lruPatched;
if (count > 0) {
echo` 🩹 Patched ${count} broken module(s) in node_modules`;
}
}
function findFirstFileByName(rootDir, matcher) {
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (entry.isFile() && matcher.test(entry.name)) {
return fullPath;
}
}
}
return null;
}
function findFilesByName(rootDir, matcher) {
const matches = [];
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (entry.isFile() && matcher.test(entry.name)) {
matches.push(fullPath);
}
}
}
return matches;
}
function patchBundledRuntime(outputDir) {
const replacePatches = [
{
label: 'workspace command runner',
target: () => findFirstFileByName(path.join(outputDir, 'dist'), /^workspace-.*\.js$/),
search: `\tconst child = spawn(resolvedCommand, finalArgv.slice(1), {
\t\tstdio,
\t\tcwd,
\t\tenv: resolvedEnv,
\t\twindowsVerbatimArguments,
\t\t...shouldSpawnWithShell({
\t\t\tresolvedCommand,
\t\t\tplatform: process$1.platform
\t\t}) ? { shell: true } : {}
\t});`,
replace: `\tconst child = spawn(resolvedCommand, finalArgv.slice(1), {
\t\tstdio,
\t\tcwd,
\t\tenv: resolvedEnv,
\t\twindowsVerbatimArguments,
\t\twindowsHide: true,
\t\t...shouldSpawnWithShell({
\t\t\tresolvedCommand,
\t\t\tplatform: process$1.platform
\t\t}) ? { shell: true } : {}
\t});`,
},
{
label: 'agents skills discovery boundary',
target: () => findFirstFileByName(path.join(outputDir, 'dist'), /^workspace-.*\.js$/),
search: `\tconst osHomeDir = resolveUserHomeDir();
\tconst personalAgentsSkills = loadSkills({
\t\tdir: osHomeDir ? path.resolve(osHomeDir, ".agents", "skills") : path.resolve(".agents", "skills"),
\t\tsource: "agents-skills-personal"
\t});
\tconst projectAgentsSkills = loadSkills({
\t\tdir: path.resolve(workspaceDir, ".agents", "skills"),
\t\tsource: "agents-skills-project"
\t});`,
replace: `\tconst openClawAgentsSkillsDisabled = process.env.OPENCLAW_DISABLE_AGENTS_SKILLS === "1";
\tconst osHomeDir = openClawAgentsSkillsDisabled ? void 0 : resolveUserHomeDir();
\tconst personalAgentsSkills = openClawAgentsSkillsDisabled ? [] : loadSkills({
\t\tdir: osHomeDir ? path.resolve(osHomeDir, ".agents", "skills") : path.resolve(".agents", "skills"),
\t\tsource: "agents-skills-personal"
\t});
\tconst projectAgentsSkills = openClawAgentsSkillsDisabled ? [] : loadSkills({
\t\tdir: path.resolve(workspaceDir, ".agents", "skills"),
\t\tsource: "agents-skills-project"
\t});`,
},
{
label: 'main session restart recovery mark boundary',
target: () => findFirstFileByName(path.join(outputDir, 'dist'), /^main-session-restart-recovery-.*\.js$/),
search: `\t\t\tif (!interruptedSessionIds.has(entry.sessionId)) continue;
\t\t\tentry.abortedLastRun = true;
\t\t\tstore[sessionKey] = entry;
\t\t\tresult.marked++;`,
replace: `\t\t\tif (!interruptedSessionIds.has(entry.sessionId)) continue;
\t\t\tentry.abortedLastRun = true;
\t\t\tif (process.env.OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY === "1") {
\t\t\t\tentry.status = "failed";
\t\t\t\tentry.endedAt = Date.now();
\t\t\t\tentry.updatedAt = entry.endedAt;
\t\t\t} else {
\t\t\t\tentry.updatedAt = Date.now();
\t\t\t}
\t\t\tstore[sessionKey] = entry;
\t\t\tresult.marked++;`,
},
{
label: 'main session restart recovery schedule boundary',
target: () => findFirstFileByName(path.join(outputDir, 'dist'), /^main-session-restart-recovery-.*\.js$/),
search: `function scheduleRestartAbortedMainSessionRecovery(params = {}) {
\tconst initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;`,
replace: `function scheduleRestartAbortedMainSessionRecovery(params = {}) {
\tif (process.env.OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY === "1") {
\t\tlog.info("main-session restart recovery disabled by OPENCLAW_DISABLE_MAIN_SESSION_RESTART_RECOVERY");
\t\treturn;
\t}
\tconst initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS;`,
},
{
label: 'stuck session active-run recovery boundary',
target: () => findFirstFileByName(path.join(outputDir, 'dist'), /^diagnostic-.*\.js$/),
search: `\t\t\t\t(opts?.recoverStuckSession ?? recoverStuckSession)({
\t\t\t\t\tsessionId: state.sessionId,
\t\t\t\t\tsessionKey: state.sessionKey,
\t\t\t\t\tageMs,
\t\t\t\t\tqueueDepth: state.queueDepth
\t\t\t\t});`,
replace: `\t\t\t\t(opts?.recoverStuckSession ?? recoverStuckSession)({
\t\t\t\t\tsessionId: state.sessionId,
\t\t\t\t\tsessionKey: state.sessionKey,
\t\t\t\t\tageMs,
\t\t\t\t\tqueueDepth: state.queueDepth,
\t\t\t\t\tallowActiveAbort: (() => {
\t\t\t\t\t\tconst thresholdMs = Number(process.env.YINIAN_OPENCLAW_STUCK_ACTIVE_ABORT_MS || "900000");
\t\t\t\t\t\treturn Number.isFinite(thresholdMs) && thresholdMs > 0 && ageMs >= thresholdMs;
\t\t\t\t\t})()
\t\t\t\t});`,
},
// Note: OpenClaw 3.31 removed the hash-suffixed agent-scope-*.js, chrome-*.js,
// and qmd-manager-*.js files from dist/plugin-sdk/. Patches for those spawn
// sites are no longer needed — the runtime now uses windowsHide natively.
];
let count = 0;
for (const patch of replacePatches) {
const target = patch.target();
if (!target || !fs.existsSync(target)) {
echo` ⚠️ Skipped patch for ${patch.label}: target file not found`;
continue;
}
const current = fs.readFileSync(target, 'utf8');
if (!current.includes(patch.search)) {
echo` ⚠️ Skipped patch for ${patch.label}: expected source snippet not found`;
continue;
}
const next = current.replace(patch.search, patch.replace);
if (next !== current) {
fs.writeFileSync(target, next, 'utf8');
count++;
}
}
if (count > 0) {
echo` 🩹 Patched ${count} bundled runtime spawn site(s)`;
}
const ptyTargets = findFilesByName(
path.join(outputDir, 'dist'),
/^(subagent-registry|reply|pi-embedded)-.*\.js$/,
);
const ptyPatches = [
{
label: 'pty launcher windowsHide',
search: `\tconst pty = spawn(params.shell, params.args, {
\t\tcwd: params.cwd,
\t\tenv: params.env ? toStringEnv(params.env) : void 0,
\t\tname: params.name ?? process.env.TERM ?? "xterm-256color",
\t\tcols: params.cols ?? 120,
\t\trows: params.rows ?? 30
\t});`,
replace: `\tconst pty = spawn(params.shell, params.args, {
\t\tcwd: params.cwd,
\t\tenv: params.env ? toStringEnv(params.env) : void 0,
\t\tname: params.name ?? process.env.TERM ?? "xterm-256color",
\t\tcols: params.cols ?? 120,
\t\trows: params.rows ?? 30,
\t\twindowsHide: true
\t});`,
},
{
label: 'disable pty on windows',
search: `\t\t\tconst usePty = params.pty === true && !sandbox;`,
replace: `\t\t\tconst usePty = params.pty === true && !sandbox && process.platform !== "win32";`,
},
{
label: 'disable approval pty on windows',
search: `\t\t\t\t\tpty: params.pty === true && !sandbox,`,
replace: `\t\t\t\t\tpty: params.pty === true && !sandbox && process.platform !== "win32",`,
},
];
let ptyCount = 0;
for (const patch of ptyPatches) {
let matchedAny = false;
for (const target of ptyTargets) {
const current = fs.readFileSync(target, 'utf8');
if (!current.includes(patch.search)) continue;
matchedAny = true;
const next = current.replaceAll(patch.search, patch.replace);
if (next !== current) {
fs.writeFileSync(target, next, 'utf8');
ptyCount++;
}
}
if (!matchedAny) {
echo` ⚠️ Skipped patch for ${patch.label}: expected source snippet not found`;
}
}
if (ptyCount > 0) {
echo` 🩹 Patched ${ptyCount} bundled PTY site(s)`;
}
// --- Browser tool hint patch ---
// OpenClaw's BROWSER_TOOL_MODEL_HINT tells the model "Do NOT retry the
// browser tool — it will keep failing" after ANY error, causing the model
// to permanently refuse browser usage even on transient failures.
// Replace with a gentler hint that allows retries on transient errors.
const ORIGINAL_HINT =
'Do NOT retry the browser tool \u2014 it will keep failing. Use an alternative approach or inform the user that the browser is currently unavailable.';
const PATCHED_HINT =
'If this was a transient error (timeout, network), you may retry once. If the same error persists after retry, try an alternative approach and let the user know.';
const ORIGINAL_SHORT = 'Do NOT retry the browser tool.';
const PATCHED_SHORT = 'You may retry once if this was a transient error.';
const distDir = path.join(outputDir, 'dist');
let hintCount = 0;
if (fs.existsSync(distDir)) {
for (const file of fs.readdirSync(distDir)) {
if (!file.endsWith('.js')) continue;
const filePath = path.join(distDir, file);
try {
const content = fs.readFileSync(filePath, 'utf8');
if (!content.includes(ORIGINAL_HINT) && !content.includes(ORIGINAL_SHORT)) continue;
const patched = content
.replaceAll(ORIGINAL_HINT, PATCHED_HINT)
.replaceAll(ORIGINAL_SHORT, PATCHED_SHORT);
if (patched !== content) {
fs.writeFileSync(filePath, patched, 'utf8');
hintCount++;
}
} catch { /* skip on error */ }
}
}
if (hintCount > 0) {
echo` 🩹 Patched ${hintCount} browser tool hint(s) to allow transient error retry`;
}
const desktopFastChatPatches = [
{
label: 'desktop chat raw prompt mode',
search: 'const contextInjectionMode = resolveContextInjectionMode(params.config);\n\t\tconst isRawModelRun = params.modelRun === true || params.promptMode === "none";',
replace: 'const contextInjectionMode = resolveContextInjectionMode(params.config);\n\t\tconst yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst isRawModelRun = params.modelRun === true || params.promptMode === "none" || yinianDesktopFastModelRun;',
},
{
label: 'desktop chat skip raw system prompt build',
search: 'const builtAppendPrompt = resolveSystemPromptOverride({',
replace: 'const builtAppendPrompt = isRawModelRun ? "" : resolveSystemPromptOverride({',
},
{
label: 'desktop chat disable tools',
search: 'const toolsRaw = params.disableTools || isRawModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
replace: 'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
},
{
label: 'desktop chat remove duplicate fast path flag',
search: 'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
replace: 'const toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
},
{
label: 'desktop chat webchat fast path',
search: 'const yinianDesktopFastModelRun = !params.messageChannel && !params.messageProvider && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");',
replace: 'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");',
},
{
label: 'skip channel agent tools for lightweight profiles',
search: '...listChannelAgentTools({ cfg: options?.config }),',
replace: '...(profile === "messaging" || profile === "minimal" ? [] : listChannelAgentTools({ cfg: options?.config })),',
},
{
label: 'skip plugin tools for lightweight profiles',
search: 'allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding\n\t\t})',
replace: 'allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,\n\t\t\tdisablePluginTools: options?.disablePluginTools ?? (profile === "messaging" || profile === "minimal")\n\t\t})',
},
];
let desktopFastChatCount = 0;
for (const patch of desktopFastChatPatches) {
let matchedAny = false;
if (fs.existsSync(distDir)) {
for (const file of fs.readdirSync(distDir)) {
if (!file.endsWith('.js')) continue;
const filePath = path.join(distDir, file);
const content = fs.readFileSync(filePath, 'utf8');
if (!content.includes(patch.search)) continue;
matchedAny = true;
const next = content.replace(patch.search, patch.replace);
if (next !== content) {
fs.writeFileSync(filePath, next, 'utf8');
desktopFastChatCount++;
}
}
}
if (!matchedAny) {
echo` ⚠️ Skipped patch for ${patch.label}: expected source snippet not found`;
}
}
if (desktopFastChatCount > 0) {
echo` 🩹 Patched ${desktopFastChatCount} desktop fast chat runtime site(s)`;
}
const runtimeDepsTargets = findFilesByName(
path.join(outputDir, 'dist'),
/^bundled-runtime-deps-.*\.js$/,
);
const npmRunnerSearch = `\tconst execPath = params.execPath ?? process.execPath;
\tconst existsSync = params.existsSync ?? fs.existsSync;
\tconst platform = params.platform ?? process.platform;
\tconst pathImpl = platform === "win32" ? path.win32 : path.posix;`;
const npmRunnerReplace = `\tconst execPath = params.execPath ?? process.execPath;
\tconst existsSync = params.existsSync ?? fs.existsSync;
\tconst platform = params.platform ?? process.platform;
\tconst pathImpl = platform === "win32" ? path.win32 : path.posix;
\tconst env = params.env ?? process.env;
\tconst explicitNodePath = env.YINIAN_NODE_EXEC_PATH;
\tconst explicitNpmCliPath = env.YINIAN_NPM_CLI_PATH;
\tif (explicitNodePath && explicitNpmCliPath && pathImpl.isAbsolute(explicitNodePath) && pathImpl.isAbsolute(explicitNpmCliPath) && existsSync(explicitNodePath) && existsSync(explicitNpmCliPath)) return {
\t\tcommand: explicitNodePath,
\t\targs: [explicitNpmCliPath, ...params.npmArgs]
\t};`;
let npmRunnerCount = 0;
let materializedCheckCount = 0;
for (const target of runtimeDepsTargets) {
const current = fs.readFileSync(target, 'utf8');
let next = current;
if (next.includes(npmRunnerSearch)) {
next = next.replace(npmRunnerSearch, npmRunnerReplace);
npmRunnerCount++;
}
const materializedSearch = `function isRuntimeDepsPlanMaterialized(installRoot, installSpecs) {
\tconst generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot);
\tconst packageManifestSpecs = generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot);
\treturn (generatedManifestSpecs !== null && sameRuntimeDepSpecs(generatedManifestSpecs, installSpecs) || packageManifestSpecs !== null && sameRuntimeDepSpecs(packageManifestSpecs, installSpecs)) && hasSatisfiedInstallSpecPackages(installRoot, installSpecs);
}`;
const materializedReplace = `function isRuntimeDepsPlanMaterialized(installRoot, installSpecs) {
\tconst generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot);
\tconst packageManifestSpecs = generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot);
\treturn (generatedManifestSpecs !== null || packageManifestSpecs !== null) && hasSatisfiedInstallSpecPackages(installRoot, installSpecs);
}`;
if (next.includes(materializedSearch)) {
next = next.replace(materializedSearch, materializedReplace);
materializedCheckCount++;
} else if (next.includes(materializedReplace)) {
materializedCheckCount++;
}
const loadRootSearch = `const packagePlan = collectBundledPluginRuntimeDeps({
\t\t\textensionsDir,
\t\t\t...params.config ? { config: params.config } : {},
\t\t\tmanifestCache,
\t\t\t...normalizePluginId ? { normalizePluginId } : {}
\t\t});`;
const loadRootReplace = `const packagePlan = collectBundledPluginRuntimeDeps({
\t\t\textensionsDir,
\t\t\t...params.config ? { config: params.config } : { selectedPluginIds: new Set([params.pluginId]) },
\t\t\tmanifestCache,
\t\t\t...normalizePluginId ? { normalizePluginId } : {}
\t\t});`;
if (next.includes(loadRootSearch)) {
next = next.replace(loadRootSearch, loadRootReplace);
}
if (next !== current) {
fs.writeFileSync(target, next, 'utf8');
}
}
if (npmRunnerCount > 0) {
echo` 🩹 Patched ${npmRunnerCount} bundled npm runner resolver(s)`;
} else {
echo` ⚠️ Skipped patch for bundled npm runner resolver: expected source snippet not found`;
}
if (materializedCheckCount > 0) {
echo` 🩹 Patched ${materializedCheckCount} bundled runtime dependency materialization check(s)`;
} else {
echo` ⚠️ Skipped patch for bundled runtime dependency materialization check: expected source snippet not found`;
}
}
patchBrokenModules(outputNodeModules);
patchBundledRuntime(OUTPUT);
fs.writeFileSync(
path.join(OUTPUT, YINIAN_RUNTIME_PATCH_MARKER_FILE),
JSON.stringify(YINIAN_RUNTIME_PATCH_MARKER, null, 2) + '\n',
'utf8',
);
echo` 🧭 Wrote Yinian runtime patch marker ${YINIAN_RUNTIME_PATCH_MARKER.version}`;
// 8. Verify the bundle
const entryExists = fs.existsSync(path.join(OUTPUT, 'openclaw.mjs'));
const distExists = fs.existsSync(path.join(OUTPUT, 'dist', 'entry.js'));
const requiredTemplateFiles = ['AGENTS.md', 'TOOLS.md', 'HEARTBEAT.md'];
const missingTemplateFiles = requiredTemplateFiles.filter((fileName) => (
!fs.existsSync(path.join(OUTPUT, 'docs', 'reference', 'templates', fileName))
));
echo``;
echo`✅ Bundle complete: ${OUTPUT}`;
echo` Unique packages copied: ${copiedCount}`;
echo` Dev-only packages skipped: ${skippedDevCount}`;
echo` Optional native packages skipped: ${skippedOptionalNativeCount}`;
echo` Duplicate versions skipped: ${skippedDupes}`;
echo` Total discovered: ${collected.size}`;
echo` openclaw.mjs: ${entryExists ? '✓' : '✗'}`;
echo` dist/entry.js: ${distExists ? '✓' : '✗'}`;
echo` runtime templates: ${missingTemplateFiles.length === 0 ? '✓' : `✗ missing ${missingTemplateFiles.join(', ')}`}`;
if (!entryExists || !distExists || missingTemplateFiles.length > 0) {
echo`❌ Bundle verification failed!`;
process.exit(1);
}