diff --git a/src/plugins/plugin-loader.js b/src/plugins/plugin-loader.js new file mode 100644 index 0000000..4334b05 --- /dev/null +++ b/src/plugins/plugin-loader.js @@ -0,0 +1,75 @@ +const fs = require('fs'); +const path = require('path'); + +class PluginLoader { + /** + * @param {string[]} searchDirs - Directories to scan for plugin folders + */ + constructor(searchDirs = []) { + this.searchDirs = searchDirs; + this.loadedIds = new Set(); + } + + /** + * Discover plugins by scanning searchDirs for manifest.json files. + * Returns array of { id, name, version, description, manifest, PluginClass, dir } + */ + discoverPlugins() { + const plugins = []; + for (const dir of this.searchDirs) { + if (!fs.existsSync(dir)) continue; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const pluginDir = path.join(dir, entry.name); + const manifestPath = path.join(pluginDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) continue; + try { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(raw); + this.validateManifest(manifest); + let PluginClass = null; + const indexPath = path.join(pluginDir, 'index.js'); + if (fs.existsSync(indexPath)) { + try { + const loaded = require(indexPath); + PluginClass = loaded.Plugin || loaded.default || null; + } catch (err) { + console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message); + continue; + } + } + plugins.push({ + id: manifest.id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + manifest, + PluginClass, + dir: pluginDir + }); + this.loadedIds.add(manifest.id); + } catch (err) { + console.error(`[PluginLoader] Skipping plugin in ${pluginDir}:`, err.message); + } + } + } + return plugins; + } + + /** + * Validate a manifest object. Throws on invalid. + */ + validateManifest(manifest) { + if (!manifest.id) throw new Error('Manifest missing required field: id'); + if (!manifest.name) throw new Error('Manifest missing required field: name'); + if (!manifest.version) throw new Error('Manifest missing required field: version'); + if (!manifest.description) throw new Error('Manifest missing required field: description'); + if (this.loadedIds.has(manifest.id)) { + throw new Error(`Duplicate plugin id: "${manifest.id}"`); + } + return true; + } +} + +module.exports = { PluginLoader }; diff --git a/tests/plugin-loader.test.js b/tests/plugin-loader.test.js new file mode 100644 index 0000000..ed4ce7b --- /dev/null +++ b/tests/plugin-loader.test.js @@ -0,0 +1,102 @@ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { PluginLoader } = require('../src/plugins/plugin-loader'); + +describe('PluginLoader', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function writeManifest(dir, manifest) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + } + + test('discoverPlugins — finds manifests in built-in directory', () => { + const pluginDir = path.join(tempDir, 'my-plugin'); + writeManifest(pluginDir, { + id: 'my-plugin', + name: 'My Plugin', + version: '1.0.0', + description: 'Test' + }); + const loader = new PluginLoader([tempDir]); + const plugins = loader.discoverPlugins(); + expect(plugins).toHaveLength(1); + expect(plugins[0].id).toBe('my-plugin'); + }); + + test('discoverPlugins — skips directories without manifest.json', () => { + const emptyDir = path.join(tempDir, 'no-manifest'); + fs.mkdirSync(emptyDir, { recursive: true }); + const loader = new PluginLoader([emptyDir]); + const plugins = loader.discoverPlugins(); + expect(plugins).toHaveLength(0); + }); + + test('validateManifest — accepts valid manifest', () => { + const manifest = { + id: 'test-plugin', + name: 'Test Plugin', + version: '1.0.0', + description: 'A test plugin' + }; + const loader = new PluginLoader([]); + expect(loader.validateManifest(manifest)).toBe(true); + }); + + test('validateManifest — rejects manifest missing id', () => { + const manifest = { name: 'No ID', version: '1.0.0', description: 'x' }; + const loader = new PluginLoader([]); + expect(() => loader.validateManifest(manifest)).toThrow(/id/); + }); + + test('validateManifest — rejects manifest missing name', () => { + const manifest = { id: 'test', version: '1.0.0', description: 'x' }; + const loader = new PluginLoader([]); + expect(() => loader.validateManifest(manifest)).toThrow(/name/); + }); + + test('validateManifest — rejects manifest with duplicate id', () => { + const loader = new PluginLoader([]); + loader.loadedIds = new Set(['existing-plugin']); + expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' })) + .toThrow(/duplicate/i); + }); + + test('discoverPlugins — loads index.js Plugin export if present', () => { + const pluginDir = path.join(tempDir, 'with-index'); + writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' }); + // Write a simple index.js with a Plugin export + fs.writeFileSync(path.join(pluginDir, 'index.js'), ` +class SimplePlugin { init() {} } +module.exports = { Plugin: SimplePlugin }; +`); + const loader = new PluginLoader([tempDir]); + const plugins = loader.discoverPlugins(); + expect(plugins).toHaveLength(1); + expect(plugins[0].PluginClass).toBeDefined(); + expect(plugins[0].PluginClass.name).toBe('SimplePlugin'); + }); + + test('discoverPlugins — continues if one plugin fails to load', () => { + const badDir = path.join(tempDir, 'bad'); + writeManifest(badDir, { id: 'bad', name: 'Bad', version: '1.0.0', description: 'Broken' }); + fs.writeFileSync(path.join(badDir, 'index.js'), 'throw new Error("broken");'); + + const goodDir = path.join(tempDir, 'good'); + writeManifest(goodDir, { id: 'good', name: 'Good', version: '1.0.0', description: 'Works' }); + + const loader = new PluginLoader([tempDir]); + const plugins = loader.discoverPlugins(); + expect(plugins).toHaveLength(1); + expect(plugins[0].id).toBe('good'); + }); +});