407 lines
11 KiB
JavaScript
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');
|
|
}
|
|
}
|