import fs from "node:fs"; import path from "node:path"; import AjvModule from "ajv"; const Ajv = AjvModule.default || AjvModule; import { pluginManifestSchema } from "./schema.js"; export class PluginRegistry { pluginsDir; stateFile; basePath; ajv = new Ajv({ allErrors: true }); validate = this.ajv.compile(pluginManifestSchema); plugins = new Map(); state = new Map(); constructor(pluginsDir, stateFile, basePath = "") { this.pluginsDir = pluginsDir; this.stateFile = stateFile; this.basePath = basePath; this.loadState(); } loadState() { this.state.clear(); if (!fs.existsSync(this.stateFile)) return; try { const raw = JSON.parse(fs.readFileSync(this.stateFile, "utf8")); for (const [id, enabled] of Object.entries(raw.enabled || {})) { this.state.set(id, !!enabled); } } catch { // ignore corrupt state file and fall back to defaults } } saveState() { fs.mkdirSync(path.dirname(this.stateFile), { recursive: true }); const enabled = Object.fromEntries([...this.state.entries()]); fs.writeFileSync(this.stateFile, JSON.stringify({ enabled }, null, 2)); } load() { this.plugins.clear(); this.loadState(); if (!fs.existsSync(this.pluginsDir)) return []; const dirs = fs .readdirSync(this.pluginsDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); for (const dir of dirs) { const pluginPath = path.join(this.pluginsDir, dir); const manifestPath = path.join(pluginPath, "plugin.json"); if (!fs.existsSync(manifestPath)) continue; try { const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const valid = this.validate(manifest); const enabled = this.state.has(manifest.id) ? !!this.state.get(manifest.id) : !!manifest.enabledByDefault; this.plugins.set(manifest.id, { manifest, path: pluginPath, enabled, valid: !!valid, errors: valid ? [] : (this.validate.errors || []).map((e) => `${e.instancePath || "/"} ${e.message}`), }); } catch (err) { this.plugins.set(dir, { manifest: { id: dir, name: dir, version: "0.0.0" }, path: pluginPath, enabled: false, valid: false, errors: [String(err?.message || err)], }); } } return [...this.plugins.values()]; } list() { return [...this.plugins.values()]; } get(id) { return this.plugins.get(id); } enable(id) { const r = this.plugins.get(id); if (r) { r.enabled = true; this.state.set(id, true); this.saveState(); } return r; } disable(id) { const r = this.plugins.get(id); if (r) { r.enabled = false; this.state.set(id, false); this.saveState(); } return r; } resolveContribution(pluginId, manifest, contribution) { const uiBase = `${this.basePath}/plugins/${pluginId}/ui`; const apiBase = `${this.basePath}/plugins/${pluginId}/api`; return { ...contribution, pluginId, pluginName: manifest.name, pluginVersion: manifest.version, uiBase, apiBase, mountUrl: contribution.mount ? `${uiBase}/${contribution.mount.replace(/^\/+/, "")}` : undefined, cssUrl: contribution.css ? `${uiBase}/${contribution.css.replace(/^\/+/, "")}` : undefined, }; } hooksIndex() { const m = {}; for (const r of this.plugins.values()) { if (!r.enabled || !r.valid) continue; for (const h of r.manifest.hooks || []) { m[h] ||= []; m[h].push(r.manifest.id); } } return m; } resolvedHooks() { const m = {}; for (const r of this.plugins.values()) { if (!r.enabled || !r.valid) continue; const contributions = r.manifest.contributions || {}; for (const [hook, items] of Object.entries(contributions)) { if (!items?.length) continue; m[hook] ||= []; for (const item of items) { m[hook].push(this.resolveContribution(r.manifest.id, r.manifest, item)); } m[hook].sort((a, b) => (a.order || 0) - (b.order || 0)); } } return m; } }