import { EventEmitter } from 'events'; import { readdir, readFile, mkdir } from 'fs/promises'; import { join, resolve, dirname } from 'path'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; import fetch from 'node-fetch'; import { exec } from 'child_process'; import { promisify } from 'util'; import readline from 'readline'; const execAsync = promisify(exec); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * Plugin Host - Manages plugin lifecycle and provides plugin API */ export class PluginHost extends EventEmitter { constructor(options = {}) { super(); this.directoryUrl = options.directoryUrl; // Resolve relative to the PluginHost.js file location (src/), then go up one level this.pluginsDir = options.pluginsDir ? (options.pluginsDir.startsWith('.') ? resolve(__dirname, '..', options.pluginsDir) : resolve(options.pluginsDir)) : resolve(__dirname, '../plugins'); this.autoUpdate = options.autoUpdate ?? true; this.updateInterval = options.updateInterval || 300000; this.plugins = new Map(); this.pluginRegistry = new Map(); this.hooks = new Map(); this._updateTimer = null; } /** * Initialize the plugin host */ async initialize() { if (!existsSync(this.pluginsDir)) { await mkdir(this.pluginsDir, { recursive: true }); } if (this.autoUpdate && this.directoryUrl) { await this.updateRegistry(); this._updateTimer = setInterval(() => this.updateRegistry(), this.updateInterval); } this.emit('initialized'); } /** * Fetch plugin registry from the directory */ async updateRegistry() { try { // First fetch the index to get the list of available plugins const indexUrl = `${this.directoryUrl}/index.json`; const indexResponse = await fetch(indexUrl); if (!indexResponse.ok) { console.warn('Failed to fetch plugin index:', indexResponse.statusText); return; } const index = await indexResponse.json(); const pluginIds = index.plugins || []; console.log(`Found ${pluginIds.length} plugins in directory`); // Fetch each plugin definition for (const pluginId of pluginIds) { const pluginUrl = `${this.directoryUrl}/${pluginId}.json`; try { const response = await fetch(pluginUrl); if (response.ok) { const plugin = await response.json(); this.pluginRegistry.set(plugin.id, plugin); console.log(` ✓ ${plugin.name} v${plugin.version}`); } } catch (error) { console.warn(` ✗ Could not fetch ${pluginId}:`, error.message); } } this.emit('registry-updated', Array.from(this.pluginRegistry.values())); } catch (error) { console.error('Error updating plugin registry:', error); } } /** * Parse HTML directory listing to find JSON files */ _parseDirectoryListing(html) { const files = []; const regex = /href="([^"]+\.json)"/g; let match; while ((match = regex.exec(html)) !== null) { files.push(match[1]); } return files; } /** * Fetch individual plugin definition */ async _fetchPluginDefinition(filename) { try { const url = `${this.directoryUrl}/${filename}`; const response = await fetch(url); if (!response.ok) return; const definition = await response.json(); this.pluginRegistry.set(definition.id, definition); } catch (error) { console.error(`Error fetching plugin definition ${filename}:`, error); } } /** * Load all plugins from the plugins directory */ async loadPlugins() { try { const entries = await readdir(this.pluginsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { await this.loadPlugin(entry.name); } } } catch (error) { console.error('Error loading plugins:', error); } } /** * Load a specific plugin */ async loadPlugin(pluginName) { const pluginPath = join(this.pluginsDir, pluginName); const manifestPath = join(pluginPath, 'plugin.json'); try { if (!existsSync(manifestPath)) { throw new Error(`Plugin manifest not found: ${manifestPath}`); } const manifestData = await readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(manifestData); const entryPoint = join(pluginPath, manifest.main || 'index.js'); if (!existsSync(entryPoint)) { throw new Error(`Plugin entry point not found: ${entryPoint}`); } const pluginModule = await import(`file:///${entryPoint.replace(/\\/g, '/')}`); const pluginInstance = { id: manifest.id, name: manifest.name, version: manifest.version, manifest, module: pluginModule, context: this._createPluginContext(manifest.id) }; if (typeof pluginModule.activate === 'function') { await pluginModule.activate(pluginInstance.context); } this.plugins.set(manifest.id, pluginInstance); this.emit('plugin-loaded', pluginInstance); console.log(`✓ Loaded plugin: ${manifest.name} v${manifest.version}`); return pluginInstance; } catch (error) { console.error(`Failed to load plugin ${pluginName}:`, error); this.emit('plugin-error', { pluginName, error }); return null; } } /** * Create plugin context API */ _createPluginContext(pluginId) { return { pluginId, registerHook: (hookName, callback) => { if (!this.hooks.has(hookName)) { this.hooks.set(hookName, []); } this.hooks.get(hookName).push({ pluginId, callback }); }, unregisterHook: (hookName, callback) => { const hooks = this.hooks.get(hookName); if (hooks) { const index = hooks.findIndex(h => h.pluginId === pluginId && h.callback === callback); if (index !== -1) { hooks.splice(index, 1); } } }, runHook: async (hookName, ...args) => { return this.runHook(hookName, ...args); }, getConfig: (key) => { const plugin = this.plugins.get(pluginId); return plugin?.manifest?.config?.[key]; }, log: (...args) => { console.log(`[${pluginId}]`, ...args); }, on: (event, listener) => { this.on(event, listener); }, emit: (event, ...args) => { this.emit(event, ...args); } }; } /** * Run all hooks registered for a specific hook name */ async runHook(hookName, ...args) { const hooks = this.hooks.get(hookName) || []; const results = []; for (const { pluginId, callback } of hooks) { try { const result = await callback(...args); results.push({ pluginId, result }); } catch (error) { console.error(`Error running hook ${hookName} in plugin ${pluginId}:`, error); results.push({ pluginId, error }); } } return results; } /** * Unload a plugin */ async unloadPlugin(pluginId) { const plugin = this.plugins.get(pluginId); if (!plugin) return false; try { if (typeof plugin.module.deactivate === 'function') { await plugin.module.deactivate(plugin.context); } // Remove all hooks from this plugin for (const [hookName, hooks] of this.hooks.entries()) { this.hooks.set(hookName, hooks.filter(h => h.pluginId !== pluginId)); } this.plugins.delete(pluginId); this.emit('plugin-unloaded', plugin); console.log(`✓ Unloaded plugin: ${plugin.name}`); return true; } catch (error) { console.error(`Error unloading plugin ${pluginId}:`, error); return false; } } /** * Get all loaded plugins */ getLoadedPlugins() { return Array.from(this.plugins.values()).map(p => ({ id: p.id, name: p.name, version: p.version, manifest: p.manifest })); } /** * Get plugin registry */ getRegistry() { return Array.from(this.pluginRegistry.values()); } /** * Install a plugin from its repository */ async installPlugin(pluginId) { const pluginDef = this.pluginRegistry.get(pluginId); if (!pluginDef) { throw new Error(`Plugin ${pluginId} not found in registry`); } const pluginPath = join(this.pluginsDir, pluginId); if (existsSync(pluginPath)) { console.log(`⚠ Plugin ${pluginId} already installed`); return false; } console.log(`📥 Installing ${pluginDef.name} v${pluginDef.version}...`); try { await execAsync(`git clone ${pluginDef.repository} "${pluginPath}"`, { cwd: this.pluginsDir }); console.log(`✅ Installed ${pluginDef.name}`); this.emit('plugin-installed', pluginDef); return true; } catch (error) { console.error(`❌ Failed to install ${pluginId}:`, error.message); return false; } } /** * Interactive plugin installation */ async interactiveInstall() { const available = this.getRegistry(); const loadedIds = new Set(this.getLoadedPlugins().map(p => p.id)); const installedPluginDirs = existsSync(this.pluginsDir) ? await readdir(this.pluginsDir, { withFileTypes: true }).then(entries => entries.filter(e => e.isDirectory()).map(e => e.name) ) : []; const installedIds = new Set(installedPluginDirs); const installable = available.filter(p => !installedIds.has(p.id)); if (installable.length === 0) { console.log('\n✨ All available plugins are already installed!'); return; } console.log('\n📦 Available Plugins to Install:'); console.log('================================'); installable.forEach((p, i) => { console.log(`${i + 1}. ${p.name} v${p.version} by ${p.author}`); console.log(` ${p.description || 'No description'}`); }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const question = (query) => new Promise(resolve => rl.question(query, resolve)); console.log('\nEnter plugin numbers to install (comma-separated), or "all" for all plugins:'); const answer = await question('> '); rl.close(); let toInstall = []; if (answer.toLowerCase().trim() === 'all') { toInstall = installable; } else { const indices = answer.split(',').map(s => parseInt(s.trim()) - 1).filter(i => !isNaN(i)); toInstall = indices.map(i => installable[i]).filter(Boolean); } if (toInstall.length === 0) { console.log('\n❌ No valid plugins selected'); return; } console.log(`\n📥 Installing ${toInstall.length} plugin(s)...`); for (const plugin of toInstall) { await this.installPlugin(plugin.id); } console.log('\n✅ Installation complete!'); } /** * Shutdown the plugin host */ async shutdown() { if (this._updateTimer) { clearInterval(this._updateTimer); } for (const pluginId of this.plugins.keys()) { await this.unloadPlugin(pluginId); } this.emit('shutdown'); } }