-
Notifications
You must be signed in to change notification settings - Fork 993
Expand file tree
/
Copy pathbundle-openclaw.mjs
More file actions
373 lines (326 loc) · 13.2 KB
/
bundle-openclaw.mjs
File metadata and controls
373 lines (326 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
#!/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-core',
'@playwright/test',
// node-llama-cpp is an optional peer dep of openclaw used only for local
// embedding generation. It adds ~700 MB of CUDA/Vulkan/Metal binaries.
// ClawX users rely on remote embedding providers (OpenAI, Gemini, etc.)
// and openclaw gracefully handles the missing dependency at runtime.
'node-llama-cpp',
]);
const SKIP_SCOPES = ['@cloudflare/', '@types/', '@node-llama-cpp/'];
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/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);
}