Files
Plugin-Host/src/PluginHost.js

407 lines
11 KiB
JavaScript

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');
}
}