diff --git a/dashcaddy-api/__tests__/plugins/plugin-manager.test.js b/dashcaddy-api/__tests__/plugins/plugin-manager.test.js new file mode 100644 index 0000000..0c34f32 --- /dev/null +++ b/dashcaddy-api/__tests__/plugins/plugin-manager.test.js @@ -0,0 +1,155 @@ +/** + * DC-080: Plugin manager tests + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { PluginManager } = require('../../src/plugins/plugin-manager'); + +describe('DC-080: Plugin Manager', () => { + let tmpDir, manager; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-plugins-')); + manager = new PluginManager({ + dataDir: tmpDir, + log: { info: jest.fn(), error: jest.fn() }, + }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('loadAll()', () => { + it('creates plugin directory if it does not exist', async () => { + const pluginDir = path.join(tmpDir, 'plugins'); + expect(fs.existsSync(pluginDir)).toBe(false); + await manager.loadAll(); + expect(fs.existsSync(pluginDir)).toBe(true); + }); + + it('loads successfully with empty plugin dir', async () => { + await manager.loadAll(); + expect(manager.plugins.size).toBe(0); + expect(manager.loaded).toBe(true); + }); + + it('skips hidden directories', async () => { + const hiddenDir = path.join(tmpDir, 'plugins', '.hidden'); + fs.mkdirSync(hiddenDir, { recursive: true }); + await manager.loadAll(); + expect(manager.plugins.size).toBe(0); + }); + }); + + describe('loadOne()', () => { + it('loads a plugin with valid manifest', async () => { + const pluginDir = path.join(tmpDir, 'plugins', 'test-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'manifest.json'), + JSON.stringify({ + name: 'test-plugin', + version: '1.0.0', + description: 'A test plugin', + }) + ); + + await manager.loadOne(pluginDir); + expect(manager.plugins.has('test-plugin')).toBe(true); + }); + + it('throws if manifest.json is missing', async () => { + const pluginDir = path.join(tmpDir, 'plugins', 'no-manifest'); + fs.mkdirSync(pluginDir, { recursive: true }); + + await expect(manager.loadOne(pluginDir)).rejects.toThrow('manifest.json'); + }); + + it('throws if manifest lacks name or version', async () => { + const pluginDir = path.join(tmpDir, 'plugins', 'invalid'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'manifest.json'), + JSON.stringify({ description: 'no name' }) + ); + + await expect(manager.loadOne(pluginDir)).rejects.toThrow('name and version'); + }); + + it('throws on duplicate plugin name', async () => { + const pluginDir = path.join(tmpDir, 'plugins', 'dup'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'manifest.json'), + JSON.stringify({ name: 'dup', version: '1.0.0' }) + ); + + await manager.loadOne(pluginDir); + await expect(manager.loadOne(pluginDir)).rejects.toThrow('already loaded'); + }); + }); + + describe('unload()', () => { + it('unloads a loaded plugin', async () => { + const pluginDir = path.join(tmpDir, 'plugins', 'removable'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'manifest.json'), + JSON.stringify({ name: 'removable', version: '1.0.0' }) + ); + + await manager.loadOne(pluginDir); + expect(manager.plugins.has('removable')).toBe(true); + + manager.unload('removable'); + expect(manager.plugins.has('removable')).toBe(false); + }); + + it('returns false for unknown plugin', () => { + expect(manager.unload('nonexistent')).toBe(false); + }); + }); + + describe('list()', () => { + it('returns empty array when no plugins', () => { + expect(manager.list()).toEqual([]); + }); + + it('returns plugin metadata', async () => { + const pluginDir = path.join(tmpDir, 'plugins', 'listed'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'manifest.json'), + JSON.stringify({ name: 'listed', version: '2.0.0', description: 'Test' }) + ); + + await manager.loadOne(pluginDir); + const list = manager.list(); + expect(list).toHaveLength(1); + expect(list[0].name).toBe('listed'); + expect(list[0].version).toBe('2.0.0'); + }); + }); + + describe('executeHook()', () => { + it('returns empty results when no plugins have the hook', async () => { + await manager.loadAll(); + const results = await manager.executeHook('service:health-check'); + expect(results).toEqual([]); + }); + }); + + describe('getWidgets()', () => { + it('returns empty array by default', () => { + expect(manager.getWidgets()).toEqual([]); + }); + }); + + describe('getServiceTypes()', () => { + it('returns empty array by default', () => { + expect(manager.getServiceTypes()).toEqual([]); + }); + }); +}); diff --git a/dashcaddy-api/src/plugins/plugin-manager.js b/dashcaddy-api/src/plugins/plugin-manager.js new file mode 100644 index 0000000..5d081ca --- /dev/null +++ b/dashcaddy-api/src/plugins/plugin-manager.js @@ -0,0 +1,243 @@ +/** + * 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', 'Failed to scan plugin directory', { error: err.message }); + 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 };