11import { existsSync , readdirSync , readFileSync } from 'node:fs' ;
22import { join } from 'node:path' ;
3+ import { pathToFileURL } from 'node:url' ;
34import TOML from '@iarna/toml' ;
45import type { EventBus } from './event-bus.js' ;
56import { PATHS } from './paths.js' ;
67import { LogWatcher } from './watchers/log-watcher.js' ;
78import { JournalWatcher } from './watchers/journal-watcher.js' ;
8- import type { ModuleManifest } from '../types/config.js' ;
9+ import { loadModuleConfigs } from '../core/config.js' ;
10+ import { getModuleState , setModuleState } from '../core/state.js' ;
11+ import type { ModuleConfig , ModuleManifest } from '../types/config.js' ;
12+ import type { ThreatEvent } from '../types/events.js' ;
13+ import type { ModuleAlert , ThreatCrushModule } from '../types/module.js' ;
914
1015interface HostedModule {
1116 name : string ;
@@ -14,6 +19,9 @@ interface HostedModule {
1419 status : 'running' | 'loaded' | 'error' | 'disabled' ;
1520 events : number ;
1621 detail ?: string ;
22+ path ?: string ;
23+ config ?: ModuleConfig ;
24+ instance ?: ThreatCrushModule ;
1725}
1826
1927export class ModuleHost {
@@ -25,12 +33,20 @@ export class ModuleHost {
2533 bus . on ( 'event' , ( event ) => {
2634 const mod = this . modules . get ( event . module ) ;
2735 if ( mod ) mod . events ++ ;
36+ for ( const hosted of this . modules . values ( ) ) {
37+ if ( hosted . status !== 'running' || ! hosted . instance ?. onEvent ) continue ;
38+ void hosted . instance . onEvent ( event ) . catch ( ( err ) => {
39+ hosted . status = 'error' ;
40+ hosted . detail = `onEvent failed: ${ String ( ( err as Error ) . message || err ) } ` ;
41+ this . bus . announceModule ( hosted . name , 'error' , hosted . detail ) ;
42+ } ) ;
43+ }
2844 } ) ;
2945 }
3046
3147 async start ( ) : Promise < void > {
3248 this . registerBuiltins ( ) ;
33- this . discoverInstalled ( ) ;
49+ await this . discoverAndStartInstalled ( ) ;
3450
3551 this . logWatcher = new LogWatcher ( this . bus ) ;
3652 const watched = this . logWatcher . start ( ) ;
@@ -59,6 +75,16 @@ export class ModuleHost {
5975 this . logWatcher ?. stop ( ) ;
6076 this . journalWatcher ?. stop ( ) ;
6177 for ( const mod of this . modules . values ( ) ) {
78+ try {
79+ if ( mod . instance && mod . status === 'running' ) {
80+ await mod . instance . stop ( ) ;
81+ }
82+ } catch ( err ) {
83+ mod . status = 'error' ;
84+ mod . detail = `stop failed: ${ String ( ( err as Error ) . message || err ) } ` ;
85+ this . bus . announceModule ( mod . name , 'error' , mod . detail ) ;
86+ continue ;
87+ }
6288 mod . status = 'loaded' ;
6389 this . bus . announceModule ( mod . name , 'stopped' ) ;
6490 }
@@ -82,8 +108,9 @@ export class ModuleHost {
82108 for ( const m of builtins ) this . modules . set ( m . name , m ) ;
83109 }
84110
85- private discoverInstalled ( ) : void {
111+ private async discoverAndStartInstalled ( ) : Promise < void > {
86112 if ( ! existsSync ( PATHS . moduleDir ) ) return ;
113+ const configs = loadModuleConfigs ( PATHS . confD ) ;
87114 const entries = readdirSync ( PATHS . moduleDir , { withFileTypes : true } ) ;
88115 for ( const entry of entries ) {
89116 if ( ! entry . isDirectory ( ) ) continue ;
@@ -92,16 +119,124 @@ export class ModuleHost {
92119 try {
93120 const manifest = TOML . parse ( readFileSync ( manifestPath , 'utf-8' ) ) as unknown as ModuleManifest ;
94121 const name = manifest . module ?. name || entry . name ;
95- this . modules . set ( name , {
122+ const defaults = manifest . module ?. config ?. defaults || { } ;
123+ const config = {
124+ enabled : true ,
125+ ...defaults ,
126+ ...( configs . get ( name ) || { } ) ,
127+ } as ModuleConfig ;
128+ const hosted : HostedModule = {
96129 name,
97130 version : manifest . module ?. version || '0.0.0' ,
98131 source : 'installed' ,
99- status : 'loaded' ,
132+ status : config . enabled === false ? 'disabled' : 'loaded' ,
100133 events : 0 ,
134+ path : join ( PATHS . moduleDir , entry . name ) ,
135+ config,
136+ } ;
137+ this . modules . set ( name , hosted ) ;
138+ if ( config . enabled === false ) continue ;
139+ await this . startInstalled ( hosted ) ;
140+ } catch ( err ) {
141+ const name = entry . name ;
142+ this . modules . set ( name , {
143+ name,
144+ version : '0.0.0' ,
145+ source : 'installed' ,
146+ status : 'error' ,
147+ events : 0 ,
148+ detail : `manifest load failed: ${ String ( ( err as Error ) . message || err ) } ` ,
149+ path : join ( PATHS . moduleDir , entry . name ) ,
101150 } ) ;
151+ }
152+ }
153+ }
154+
155+ private async startInstalled ( hosted : HostedModule ) : Promise < void > {
156+ const entrypoint = this . installedEntrypoint ( hosted . path ! ) ;
157+ if ( ! entrypoint ) {
158+ hosted . status = 'loaded' ;
159+ hosted . detail = 'no built entrypoint found; run npm install && npm run build in the module directory' ;
160+ return ;
161+ }
162+
163+ try {
164+ const imported = await import ( pathToFileURL ( entrypoint ) . href ) ;
165+ const exported = imported . default || imported . module || imported ;
166+ const instance = typeof exported === 'function' ? new exported ( ) : exported ;
167+ if ( ! this . isThreatCrushModule ( instance ) ) {
168+ throw new Error ( 'entrypoint does not export a ThreatCrush module' ) ;
169+ }
170+
171+ hosted . instance = instance ;
172+ await instance . init ( this . contextFor ( hosted ) ) ;
173+ await instance . start ( ) ;
174+ hosted . status = 'running' ;
175+ hosted . detail = `started from ${ entrypoint } ` ;
176+ this . bus . announceModule ( hosted . name , 'running' , hosted . detail ) ;
177+ } catch ( err ) {
178+ hosted . status = 'error' ;
179+ hosted . detail = String ( ( err as Error ) . message || err ) ;
180+ this . bus . announceModule ( hosted . name , 'error' , hosted . detail ) ;
181+ }
182+ }
183+
184+ private installedEntrypoint ( modulePath : string ) : string | null {
185+ const packageJson = join ( modulePath , 'package.json' ) ;
186+ const candidates : string [ ] = [ ] ;
187+ if ( existsSync ( packageJson ) ) {
188+ try {
189+ const pkg = JSON . parse ( readFileSync ( packageJson , 'utf-8' ) ) as { main ?: string } ;
190+ if ( pkg . main ) candidates . push ( join ( modulePath , pkg . main ) ) ;
102191 } catch {
103- // skip malformed
192+ // fall through to conventional paths
104193 }
105194 }
195+ candidates . push ( join ( modulePath , 'dist' , 'index.js' ) , join ( modulePath , 'index.js' ) ) ;
196+ return candidates . find ( ( candidate ) => existsSync ( candidate ) ) || null ;
197+ }
198+
199+ private isThreatCrushModule ( value : unknown ) : value is ThreatCrushModule {
200+ return Boolean (
201+ value &&
202+ typeof value === 'object' &&
203+ typeof ( value as ThreatCrushModule ) . init === 'function' &&
204+ typeof ( value as ThreatCrushModule ) . start === 'function' &&
205+ typeof ( value as ThreatCrushModule ) . stop === 'function' ,
206+ ) ;
207+ }
208+
209+ private contextFor ( hosted : HostedModule ) {
210+ return {
211+ config : hosted . config || { enabled : true } ,
212+ logger : this . loggerFor ( hosted . name ) ,
213+ emit : ( event : ThreatEvent ) => this . bus . publish ( event ) ,
214+ subscribe : ( eventType : string , handler : ( event : ThreatEvent ) => void ) => {
215+ this . bus . on ( 'event' , ( event ) => {
216+ if ( event . category === eventType || event . module === eventType ) handler ( event ) ;
217+ } ) ;
218+ } ,
219+ alert : ( alert : ModuleAlert ) => {
220+ this . bus . emit ( 'alert' , alert . event || {
221+ timestamp : new Date ( ) ,
222+ module : hosted . name ,
223+ category : 'system' ,
224+ severity : alert . severity ,
225+ message : alert . title ,
226+ details : alert . body ? { body : alert . body } : undefined ,
227+ } ) ;
228+ } ,
229+ getState : ( key : string ) => getModuleState ( hosted . name , key ) ,
230+ setState : ( key : string , value : unknown ) => setModuleState ( hosted . name , key , value ) ,
231+ } ;
232+ }
233+
234+ private loggerFor ( moduleName : string ) {
235+ return {
236+ debug : ( msg : string , ...args : unknown [ ] ) => console . debug ( `[${ moduleName } ] ${ msg } ` , ...args ) ,
237+ info : ( msg : string , ...args : unknown [ ] ) => console . info ( `[${ moduleName } ] ${ msg } ` , ...args ) ,
238+ warn : ( msg : string , ...args : unknown [ ] ) => console . warn ( `[${ moduleName } ] ${ msg } ` , ...args ) ,
239+ error : ( msg : string , ...args : unknown [ ] ) => console . error ( `[${ moduleName } ] ${ msg } ` , ...args ) ,
240+ } ;
106241 }
107242}
0 commit comments