/** * DC-080: Plugin/Extension system for DashCaddy * * Allows third-party extensions to register: * - Custom service types with health-check logic * - Custom notification providers * - Custom workflow actions * - Dashboard widgets (via manifest) * * Plugins are loaded from the data directory: * {dataDir}/plugins/{plugin-name}/manifest.json * {dataDir}/plugins/{plugin-name}/index.js * * The manifest.json describes capabilities and permissions. * The index.js exports hooks that DashCaddy calls at appropriate times. * * Security: plugins run in the same process (no sandbox yet). The manifest * declares required permissions, and the admin must approve on install. */ const fs = require('fs'); const path = require('path'); const EventEmitter = require('events'); const PLUGIN_DIR = process.env.PLUGIN_DIR || path.join(process.cwd(), 'data', 'plugins'); const HOOK_TYPES = [ 'service:health-check', // Custom health check for a service type 'notification:provider', // Custom notification provider 'workflow:action', // Custom workflow action type 'dashboard:widget', // Custom dashboard widget manifest 'container:pre-deploy', // Hook before container deployment 'container:post-deploy', // Hook after container deployment 'config:validate', // Hook for config validation ]; class PluginManager extends EventEmitter { constructor({ dataDir, log }) { super(); this.pluginDir = dataDir ? path.join(dataDir, 'plugins') : PLUGIN_DIR; this.log = log || console; this.plugins = new Map(); // name → { manifest, module, hooks } this.serviceTypes = new Map(); // typeName → pluginName this.notificationProviders = new Map(); this.workflowActions = new Map(); this.dashboardWidgets = new Map(); this.loaded = false; } /** * Discover and load all plugins from the plugin directory. */ async loadAll() { if (this.loaded) return; try { if (!fs.existsSync(this.pluginDir)) { fs.mkdirSync(this.pluginDir, { recursive: true }); this.log.info('plugins', 'Plugin directory created', { dir: this.pluginDir }); this.loaded = true; return; } const entries = fs.readdirSync(this.pluginDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name.startsWith('.')) continue; try { await this.loadOne(path.join(this.pluginDir, entry.name)); } catch (err) { this.log.error('plugins', `Failed to load plugin: ${entry.name}`, { error: err.message }); } } this.loaded = true; this.log.info('plugins', 'All plugins loaded', { count: this.plugins.size, serviceTypes: [...this.serviceTypes.keys()], notificationProviders: [...this.notificationProviders.keys()], workflowActions: [...this.workflowActions.keys()], }); } catch (err) { this.log.error('plugins', err, null, { note: 'Failed to scan plugin directory' }); this.loaded = true; // Don't crash — just run without plugins } } /** * Load a single plugin from its directory. */ async loadOne(pluginPath) { const manifestPath = path.join(pluginPath, 'manifest.json'); const indexPath = path.join(pluginPath, 'index.js'); if (!fs.existsSync(manifestPath)) { throw new Error('manifest.json not found'); } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); // Validate manifest if (!manifest.name || !manifest.version) { throw new Error('manifest.json must have name and version'); } if (this.plugins.has(manifest.name)) { throw new Error(`Plugin ${manifest.name} already loaded`); } // Load the plugin module if it exists let module = {}; if (fs.existsSync(indexPath)) { delete require.cache[require.resolve(indexPath)]; module = require(indexPath); } // Register hooks const hooks = {}; if (module.hooks) { for (const [hookType, fn] of Object.entries(module.hooks)) { if (HOOK_TYPES.includes(hookType)) { hooks[hookType] = fn; this._registerHook(manifest.name, hookType, fn, manifest); } } } this.plugins.set(manifest.name, { manifest, module, hooks, path: pluginPath }); this.emit('plugin-loaded', manifest); this.log.info('plugins', `Loaded plugin: ${manifest.name} v${manifest.version}`, { hooks: Object.keys(hooks), }); } _registerHook(pluginName, hookType, fn, manifest) { switch (hookType) { case 'service:health-check': if (manifest.serviceType) { this.serviceTypes.set(manifest.serviceType, pluginName); } break; case 'notification:provider': if (manifest.providerName) { this.notificationProviders.set(manifest.providerName, { pluginName, fn }); } break; case 'workflow:action': if (manifest.actionType) { this.workflowActions.set(manifest.actionType, { pluginName, fn }); } break; case 'dashboard:widget': if (manifest.widget) { this.dashboardWidgets.set(manifest.name, { pluginName, manifest: manifest.widget }); } break; } } /** * Unload a plugin by name. */ unload(name) { const plugin = this.plugins.get(name); if (!plugin) return false; // Clean up registrations for (const [type, pName] of this.serviceTypes) { if (pName === name) this.serviceTypes.delete(type); } for (const [type, { pluginName }] of this.notificationProviders) { if (pluginName === name) this.notificationProviders.delete(type); } for (const [type, { pluginName }] of this.workflowActions) { if (pluginName === name) this.workflowActions.delete(type); } for (const [wName, { pluginName }] of this.dashboardWidgets) { if (pluginName === name) this.dashboardWidgets.delete(wName); } this.plugins.delete(name); this.emit('plugin-unloaded', name); this.log.info('plugins', `Unloaded plugin: ${name}`); return true; } /** * Execute a plugin hook for a specific type. */ async executeHook(hookType, ...args) { // Try each plugin that registered this hook const results = []; for (const [name, plugin] of this.plugins) { if (plugin.hooks[hookType]) { try { const result = await plugin.hooks[hookType](...args); results.push({ plugin: name, result }); } catch (err) { this.log.error('plugins', `Hook ${hookType} failed in ${name}`, { error: err.message }); results.push({ plugin: name, error: err.message }); } } } return results; } /** * Get list of loaded plugins with their manifests. */ list() { return [...this.plugins.values()].map(p => ({ name: p.manifest.name, version: p.manifest.version, description: p.manifest.description || '', hooks: Object.keys(p.hooks), permissions: p.manifest.permissions || [], })); } /** * Get dashboard widget manifests from plugins. */ getWidgets() { return [...this.dashboardWidgets.values()].map(w => w.manifest); } /** * Get registered service types. */ getServiceTypes() { return [...this.serviceTypes.keys()]; } /** * Get registered workflow action types. */ getWorkflowActions() { return [...this.workflowActions.keys()]; } } module.exports = { PluginManager, HOOK_TYPES };