diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index 4f92e4141..8d1b1428c 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -1,6 +1,6 @@ import { app } from 'electron'; import path from 'path'; -import { existsSync, readFileSync, mkdirSync, rmSync } from 'fs'; +import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; @@ -164,6 +164,73 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { } } +/** + * Ensure extension-specific packages are resolvable from shared dist/ chunks. + * + * OpenClaw's Rollup bundler creates shared chunks in dist/ (e.g. + * sticker-cache-*.js) that eagerly `import "grammy"`. ESM bare specifier + * resolution walks from the importing file's directory upward: + * dist/node_modules/ → openclaw/node_modules/ → … + * It does NOT search `dist/extensions/telegram/node_modules/`. + * + * NODE_PATH only works for CJS require(), NOT for ESM import statements. + * + * Fix: create symlinks in openclaw/node_modules/ pointing to packages in + * dist/extensions//node_modules/. This makes the standard ESM + * resolution algorithm find them. Skip-if-exists avoids overwriting + * openclaw's own deps (they take priority). + */ +function ensureExtensionDepsResolvable(openclawDir: string): void { + const extDir = join(openclawDir, 'dist', 'extensions'); + const topNM = join(openclawDir, 'node_modules'); + let linkedCount = 0; + + try { + if (!existsSync(extDir)) return; + + for (const ext of readdirSync(extDir, { withFileTypes: true })) { + if (!ext.isDirectory()) continue; + const extNM = join(extDir, ext.name, 'node_modules'); + if (!existsSync(extNM)) continue; + + for (const pkg of readdirSync(extNM, { withFileTypes: true })) { + if (pkg.name === '.bin') continue; + + if (pkg.name.startsWith('@')) { + // Scoped package — iterate sub-entries + const scopeDir = join(extNM, pkg.name); + let scopeEntries; + try { scopeEntries = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; } + for (const sub of scopeEntries) { + if (!sub.isDirectory()) continue; + const dest = join(topNM, pkg.name, sub.name); + if (existsSync(dest)) continue; + try { + mkdirSync(join(topNM, pkg.name), { recursive: true }); + symlinkSync(join(scopeDir, sub.name), dest); + linkedCount++; + } catch { /* skip on error — non-fatal */ } + } + } else { + const dest = join(topNM, pkg.name); + if (existsSync(dest)) continue; + try { + mkdirSync(topNM, { recursive: true }); + symlinkSync(join(extNM, pkg.name), dest); + linkedCount++; + } catch { /* skip on error — non-fatal */ } + } + } + } + } catch { + // extensions dir may not exist or be unreadable — non-fatal + } + + if (linkedCount > 0) { + logger.info(`[extension-deps] Linked ${linkedCount} extension packages into ${topNM}`); + } +} + // ── Pre-launch sync ────────────────────────────────────────────── export async function syncGatewayConfigBeforeLaunch( @@ -365,6 +432,11 @@ export async function prepareGatewayLaunchContext(port: number): Promise/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 (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 (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`; +} + // 6. Clean up the bundle to reduce package size // // This removes platform-agnostic waste: dev artifacts, docs, source maps,