11import { app } from 'electron' ;
22import path from 'path' ;
3- import { existsSync , readFileSync , cpSync , mkdirSync , rmSync } from 'fs' ;
3+ import { existsSync , readFileSync , cpSync , mkdirSync , rmSync , readdirSync , realpathSync } from 'fs' ;
44import { homedir } from 'os' ;
55import { join } from 'path' ;
66import { getAllSettings } from '../utils/store' ;
@@ -30,11 +30,11 @@ export interface GatewayLaunchContext {
3030
3131// ── Auto-upgrade bundled plugins on startup ──────────────────────
3232
33- const CHANNEL_PLUGIN_MAP : Record < string , string > = {
34- dingtalk : 'dingtalk' ,
35- wecom : 'wecom' ,
36- feishu : 'feishu-openclaw-plugin' ,
37- qqbot : 'qqbot' ,
33+ const CHANNEL_PLUGIN_MAP : Record < string , { dirName : string ; npmName : string } > = {
34+ dingtalk : { dirName : 'dingtalk' , npmName : '@soimy/dingtalk' } ,
35+ wecom : { dirName : 'wecom' , npmName : '@wecom/wecom-openclaw-plugin' } ,
36+ feishu : { dirName : 'feishu-openclaw-plugin' , npmName : '@larksuite/openclaw-lark' } ,
37+ qqbot : { dirName : 'qqbot' , npmName : '@sliverp/qqbot' } ,
3838} ;
3939
4040function readPluginVersion ( pkgJsonPath : string ) : string | null {
@@ -47,7 +47,113 @@ function readPluginVersion(pkgJsonPath: string): string | null {
4747 }
4848}
4949
50- function buildPluginCandidateSources ( pluginDirName : string ) : string [ ] {
50+ /** Walk up from a path until we find a parent named node_modules. */
51+ function findParentNodeModules ( startPath : string ) : string | null {
52+ let dir = startPath ;
53+ while ( dir !== path . dirname ( dir ) ) {
54+ if ( path . basename ( dir ) === 'node_modules' ) return dir ;
55+ dir = path . dirname ( dir ) ;
56+ }
57+ return null ;
58+ }
59+
60+ /** List packages inside a node_modules dir (handles @scoped packages). */
61+ function listPackagesInDir ( nodeModulesDir : string ) : Array < { name : string ; fullPath : string } > {
62+ const result : Array < { name : string ; fullPath : string } > = [ ] ;
63+ if ( ! existsSync ( nodeModulesDir ) ) return result ;
64+ const SKIP = new Set ( [ '.bin' , '.package-lock.json' , '.modules.yaml' , '.pnpm' ] ) ;
65+ for ( const entry of readdirSync ( nodeModulesDir , { withFileTypes : true } ) ) {
66+ if ( ! entry . isDirectory ( ) && ! entry . isSymbolicLink ( ) ) continue ;
67+ if ( SKIP . has ( entry . name ) ) continue ;
68+ const entryPath = join ( nodeModulesDir , entry . name ) ;
69+ if ( entry . name . startsWith ( '@' ) ) {
70+ try {
71+ for ( const sub of readdirSync ( entryPath ) ) {
72+ result . push ( { name : `${ entry . name } /${ sub } ` , fullPath : join ( entryPath , sub ) } ) ;
73+ }
74+ } catch { /* ignore */ }
75+ } else {
76+ result . push ( { name : entry . name , fullPath : entryPath } ) ;
77+ }
78+ }
79+ return result ;
80+ }
81+
82+ /**
83+ * Copy a plugin from a pnpm node_modules location, including its
84+ * transitive runtime dependencies (replicates bundle-openclaw-plugins.mjs
85+ * logic).
86+ */
87+ function copyPluginFromNodeModules ( npmPkgPath : string , targetDir : string , npmName : string ) : void {
88+ let realPath : string ;
89+ try {
90+ realPath = realpathSync ( npmPkgPath ) ;
91+ } catch {
92+ throw new Error ( `Cannot resolve real path for ${ npmPkgPath } ` ) ;
93+ }
94+
95+ // 1. Copy plugin package itself
96+ rmSync ( targetDir , { recursive : true , force : true } ) ;
97+ mkdirSync ( targetDir , { recursive : true } ) ;
98+ cpSync ( realPath , targetDir , { recursive : true , dereference : true } ) ;
99+
100+ // 2. Collect transitive deps from pnpm virtual store
101+ const rootVirtualNM = findParentNodeModules ( realPath ) ;
102+ if ( ! rootVirtualNM ) {
103+ logger . warn ( `[plugin] Cannot find virtual store node_modules for ${ npmName } , plugin may lack deps` ) ;
104+ return ;
105+ }
106+
107+ // Read peer deps to skip (they're provided by the host gateway)
108+ const SKIP_PACKAGES = new Set ( [ 'typescript' , '@playwright/test' ] ) ;
109+ try {
110+ const pluginPkg = JSON . parse ( readFileSync ( join ( targetDir , 'package.json' ) , 'utf-8' ) ) ;
111+ for ( const peer of Object . keys ( pluginPkg . peerDependencies || { } ) ) {
112+ SKIP_PACKAGES . add ( peer ) ;
113+ }
114+ } catch { /* ignore */ }
115+
116+ const collected = new Map < string , string > ( ) ; // realPath → packageName
117+ const queue : Array < { nodeModulesDir : string ; skipPkg : string } > = [
118+ { nodeModulesDir : rootVirtualNM , skipPkg : npmName } ,
119+ ] ;
120+
121+ while ( queue . length > 0 ) {
122+ const { nodeModulesDir, skipPkg } = queue . shift ( ) ! ;
123+ for ( const { name, fullPath } of listPackagesInDir ( nodeModulesDir ) ) {
124+ if ( name === skipPkg ) continue ;
125+ if ( SKIP_PACKAGES . has ( name ) || name . startsWith ( '@types/' ) ) continue ;
126+ let depRealPath : string ;
127+ try {
128+ depRealPath = realpathSync ( fullPath ) ;
129+ } catch { continue ; }
130+ if ( collected . has ( depRealPath ) ) continue ;
131+ collected . set ( depRealPath , name ) ;
132+ const depVirtualNM = findParentNodeModules ( depRealPath ) ;
133+ if ( depVirtualNM && depVirtualNM !== nodeModulesDir ) {
134+ queue . push ( { nodeModulesDir : depVirtualNM , skipPkg : name } ) ;
135+ }
136+ }
137+ }
138+
139+ // 3. Copy flattened deps into targetDir/node_modules/
140+ const outputNM = join ( targetDir , 'node_modules' ) ;
141+ mkdirSync ( outputNM , { recursive : true } ) ;
142+ const copiedNames = new Set < string > ( ) ;
143+ for ( const [ depRealPath , pkgName ] of collected ) {
144+ if ( copiedNames . has ( pkgName ) ) continue ;
145+ copiedNames . add ( pkgName ) ;
146+ const dest = join ( outputNM , pkgName ) ;
147+ try {
148+ mkdirSync ( path . dirname ( dest ) , { recursive : true } ) ;
149+ cpSync ( depRealPath , dest , { recursive : true , dereference : true } ) ;
150+ } catch { /* skip individual dep failures */ }
151+ }
152+
153+ logger . info ( `[plugin] Copied ${ copiedNames . size } deps for ${ npmName } ` ) ;
154+ }
155+
156+ function buildBundledPluginSources ( pluginDirName : string ) : string [ ] {
51157 return app . isPackaged
52158 ? [
53159 join ( process . resourcesPath , 'openclaw-plugins' , pluginDirName ) ,
@@ -62,33 +168,54 @@ function buildPluginCandidateSources(pluginDirName: string): string[] {
62168
63169/**
64170 * Auto-upgrade all configured channel plugins before Gateway start.
65- * Compares the installed version in ~/.openclaw/extensions/ with the
66- * bundled version and overwrites if the bundled version is newer.
171+ * - Packaged mode: uses bundled plugins from resources/ (includes deps)
172+ * - Dev mode: falls back to node_modules/ with pnpm-aware dep collection
67173 */
68174function ensureConfiguredPluginsUpgraded ( configuredChannels : string [ ] ) : void {
69175 for ( const channelType of configuredChannels ) {
70- const pluginDirName = CHANNEL_PLUGIN_MAP [ channelType ] ;
71- if ( ! pluginDirName ) continue ;
176+ const pluginInfo = CHANNEL_PLUGIN_MAP [ channelType ] ;
177+ if ( ! pluginInfo ) continue ;
178+ const { dirName, npmName } = pluginInfo ;
72179
73- const targetDir = join ( homedir ( ) , '.openclaw' , 'extensions' , pluginDirName ) ;
180+ const targetDir = join ( homedir ( ) , '.openclaw' , 'extensions' , dirName ) ;
74181 const targetManifest = join ( targetDir , 'openclaw.plugin.json' ) ;
75182 if ( ! existsSync ( targetManifest ) ) continue ; // not installed, nothing to upgrade
76183
77- const sources = buildPluginCandidateSources ( pluginDirName ) ;
78- const sourceDir = sources . find ( ( dir ) => existsSync ( join ( dir , 'openclaw.plugin.json' ) ) ) ;
79- if ( ! sourceDir ) continue ; // no bundled source available
80-
81184 const installedVersion = readPluginVersion ( join ( targetDir , 'package.json' ) ) ;
82- const sourceVersion = readPluginVersion ( join ( sourceDir , 'package.json' ) ) ;
83- if ( ! sourceVersion || ! installedVersion || sourceVersion === installedVersion ) continue ;
84185
85- logger . info ( `[plugin] Auto-upgrading ${ channelType } plugin: ${ installedVersion } → ${ sourceVersion } ` ) ;
86- try {
87- mkdirSync ( join ( homedir ( ) , '.openclaw' , 'extensions' ) , { recursive : true } ) ;
88- rmSync ( targetDir , { recursive : true , force : true } ) ;
89- cpSync ( sourceDir , targetDir , { recursive : true , dereference : true } ) ;
90- } catch ( err ) {
91- logger . warn ( `[plugin] Failed to auto-upgrade ${ channelType } plugin:` , err ) ;
186+ // Try bundled sources first (packaged mode or if bundle-plugins was run)
187+ const bundledSources = buildBundledPluginSources ( dirName ) ;
188+ const bundledDir = bundledSources . find ( ( dir ) => existsSync ( join ( dir , 'openclaw.plugin.json' ) ) ) ;
189+
190+ if ( bundledDir ) {
191+ const sourceVersion = readPluginVersion ( join ( bundledDir , 'package.json' ) ) ;
192+ if ( sourceVersion && installedVersion && sourceVersion !== installedVersion ) {
193+ logger . info ( `[plugin] Auto-upgrading ${ channelType } plugin: ${ installedVersion } → ${ sourceVersion } (bundled)` ) ;
194+ try {
195+ mkdirSync ( join ( homedir ( ) , '.openclaw' , 'extensions' ) , { recursive : true } ) ;
196+ rmSync ( targetDir , { recursive : true , force : true } ) ;
197+ cpSync ( bundledDir , targetDir , { recursive : true , dereference : true } ) ;
198+ } catch ( err ) {
199+ logger . warn ( `[plugin] Failed to auto-upgrade ${ channelType } plugin:` , err ) ;
200+ }
201+ }
202+ continue ;
203+ }
204+
205+ // Dev mode fallback: copy from node_modules/ with pnpm dep resolution
206+ if ( ! app . isPackaged ) {
207+ const npmPkgPath = join ( process . cwd ( ) , 'node_modules' , ...npmName . split ( '/' ) ) ;
208+ if ( ! existsSync ( join ( npmPkgPath , 'openclaw.plugin.json' ) ) ) continue ;
209+ const sourceVersion = readPluginVersion ( join ( npmPkgPath , 'package.json' ) ) ;
210+ if ( ! sourceVersion || ! installedVersion || sourceVersion === installedVersion ) continue ;
211+
212+ logger . info ( `[plugin] Auto-upgrading ${ channelType } plugin: ${ installedVersion } → ${ sourceVersion } (dev/node_modules)` ) ;
213+ try {
214+ mkdirSync ( join ( homedir ( ) , '.openclaw' , 'extensions' ) , { recursive : true } ) ;
215+ copyPluginFromNodeModules ( npmPkgPath , targetDir , npmName ) ;
216+ } catch ( err ) {
217+ logger . warn ( `[plugin] Failed to auto-upgrade ${ channelType } plugin from node_modules:` , err ) ;
218+ }
92219 }
93220 }
94221}
0 commit comments