Skip to content

Commit 98a58b6

Browse files
op-simoneromeoSimone
andauthored
fix: start installed daemon modules (#4)
Co-authored-by: Simone <a1234@1234deMac-mini.local>
1 parent 90c0754 commit 98a58b6

2 files changed

Lines changed: 150 additions & 6 deletions

File tree

apps/cli/src/daemon/module-host.ts

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { existsSync, readdirSync, readFileSync } from 'node:fs';
22
import { join } from 'node:path';
3+
import { pathToFileURL } from 'node:url';
34
import TOML from '@iarna/toml';
45
import type { EventBus } from './event-bus.js';
56
import { PATHS } from './paths.js';
67
import { LogWatcher } from './watchers/log-watcher.js';
78
import { 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

1015
interface 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

1927
export 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
}

apps/cli/src/types/module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import type { ThreatEvent } from './events.js';
22
import type { ModuleConfig } from './config.js';
33

4+
export interface ModuleAlert {
5+
title: string;
6+
severity: ThreatEvent['severity'];
7+
body?: string;
8+
event?: ThreatEvent;
9+
}
10+
411
export interface ModuleContext {
512
config: ModuleConfig;
613
logger: ModuleLogger;
714
emit: (event: ThreatEvent) => void;
15+
subscribe: (eventType: string, handler: (event: ThreatEvent) => void) => void;
16+
alert: (alert: ModuleAlert) => void;
817
getState: (key: string) => unknown;
918
setState: (key: string, value: unknown) => void;
1019
}

0 commit comments

Comments
 (0)