From c32ade2f6542345cfa82c6a9f6b36998ae2cbf73 Mon Sep 17 00:00:00 2001 From: Nystik <236107-Nystik@users.noreply.gitlab.com> Date: Thu, 26 Mar 2026 23:55:12 +0100 Subject: [PATCH] implement server plugin system --- server/config.js | 12 +- server/index.js | 4 + server/plugin-system/config-store.js | 30 +++ server/plugin-system/discovery.js | 74 +++++++ server/plugin-system/manager.js | 283 +++++++++++++++++++++++++++ server/plugins/.gitkeep | 0 server/routes/plugins.js | 44 +++++ 7 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 server/plugin-system/config-store.js create mode 100644 server/plugin-system/discovery.js create mode 100644 server/plugin-system/manager.js create mode 100644 server/plugins/.gitkeep create mode 100644 server/routes/plugins.js diff --git a/server/config.js b/server/config.js index d9fcc67..611c041 100644 --- a/server/config.js +++ b/server/config.js @@ -6,13 +6,22 @@ const fs = require("fs"); const vaultRoot = process.env.VAULT_ROOT || path.join(__dirname, "..", "vaults"); -// Ensure vault root exists +const dataRoot = + process.env.DATA_ROOT || path.join(__dirname, "..", "data"); + +// Ensure required directories exist try { fs.mkdirSync(vaultRoot, { recursive: true }); } catch (e) { console.error("[config] Failed to create VAULT_ROOT:", vaultRoot, e.message); } +try { + fs.mkdirSync(dataRoot, { recursive: true }); +} catch (e) { + console.error("[config] Failed to create DATA_ROOT:", dataRoot, e.message); +} + function discoverVaults() { const vaults = {}; @@ -52,6 +61,7 @@ let vaults = discoverVaults(); module.exports = { port: process.env.PORT || 8080, vaultRoot, + dataRoot, get vaults() { return vaults; }, diff --git a/server/index.js b/server/index.js index dc406b7..fc0eedc 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,8 @@ const config = require("./config"); const { setupWebSocket } = require("./ws"); const watcher = require("./watcher"); const { updateBridgePluginInAllVaults } = require("./bridge-plugin"); +const { initPlugins, shutdownPlugins } = require("./plugin-system/manager"); +const pluginRoutes = require("./routes/plugins"); const ANSI_RED = "\x1b[31m"; const ANSI_YELLOW = "\x1b[33m"; @@ -52,6 +54,7 @@ app.use("/api/fs", fsRoutes); app.use("/api/vault", vaultRoutes); app.use("/api/proxy", proxyRoutes); app.use("/api/version", versionRoutes); +app.use("/api/plugins", pluginRoutes); // Serve vault files for resource URLs (images, attachments, etc.) // Vault ID is the first path segment: /vault-files//path/to/file @@ -99,6 +102,7 @@ const server = app.listen(config.port, async () => { console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`); await updateBridgePluginInAllVaults(config.vaultRoot); + await initPlugins({ app, config, wss, watcher }); }); const wss = setupWebSocket(server); diff --git a/server/plugin-system/config-store.js b/server/plugin-system/config-store.js new file mode 100644 index 0000000..5d8aefd --- /dev/null +++ b/server/plugin-system/config-store.js @@ -0,0 +1,30 @@ +const fs = require("fs"); +const path = require("path"); + +async function load(filePath) { + try { + const content = await fs.promises.readFile(filePath, "utf-8"); + return JSON.parse(content); + } catch { + return {}; + } +} + +async function save(filePath, data) { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2)); +} + +function getEnabledVaults(config, pluginId) { + return config[pluginId]?.enabledVaults || []; +} + +function setEnabledVaults(config, pluginId, vaultIds) { + if (!config[pluginId]) { + config[pluginId] = {}; + } + + config[pluginId].enabledVaults = vaultIds; +} + +module.exports = { load, save, getEnabledVaults, setEnabledVaults }; diff --git a/server/plugin-system/discovery.js b/server/plugin-system/discovery.js new file mode 100644 index 0000000..ed710e9 --- /dev/null +++ b/server/plugin-system/discovery.js @@ -0,0 +1,74 @@ +const fs = require("fs"); +const path = require("path"); + +function discoverPlugins(pluginsDir) { + const discovered = new Map(); + + let entries; + + try { + entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); + } catch { + return discovered; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) { + continue; + } + + const pluginPath = path.join(pluginsDir, entry.name); + const indexPath = path.join(pluginPath, "index.js"); + + if (!fs.existsSync(indexPath)) { + continue; + } + + let plugin; + + try { + plugin = require(indexPath); + } catch (e) { + console.warn(`[plugins] Failed to load ${entry.name}: ${e.message}`); + continue; + } + + if (!plugin.id || !plugin.name || typeof plugin.register !== "function") { + console.warn( + `[plugins] Skipping ${entry.name}: missing id, name, or register`, + ); + continue; + } + + let bundledPluginId = null; + + if (plugin.obsidianPlugin) { + try { + const manifest = JSON.parse( + fs.readFileSync( + path.join(plugin.obsidianPlugin, "manifest.json"), + "utf-8", + ), + ); + bundledPluginId = manifest.id; + } catch { + // No valid bundled plugin manifest + } + } + + discovered.set(plugin.id, { + id: plugin.id, + name: plugin.name, + description: plugin.description || "", + obsidianPlugin: plugin.obsidianPlugin || null, + bundledPluginId, + module: plugin, + }); + + console.log(`[plugins] Discovered: ${plugin.name}`); + } + + return discovered; +} + +module.exports = { discoverPlugins }; diff --git a/server/plugin-system/manager.js b/server/plugin-system/manager.js new file mode 100644 index 0000000..e1a84c2 --- /dev/null +++ b/server/plugin-system/manager.js @@ -0,0 +1,283 @@ +const fs = require("fs"); +const path = require("path"); +const express = require("express"); +const { discoverPlugins } = require("./discovery"); +const configStore = require("./config-store"); +const { + installObsidianPlugin, + removeObsidianPlugin, +} = require("./obsidian-plugin"); + +let discoveredPlugins = new Map(); +const loadedPlugins = new Map(); +const pluginRouters = new Map(); +let pluginConfig = {}; +let configPath = ""; +let serverCtx = null; + +async function initPlugins(ctx) { + serverCtx = ctx; + configPath = path.join(ctx.config.dataRoot, "plugin-config.json"); + + ctx.app.use("/api/ext/:pluginId", (req, res, next) => { + const router = pluginRouters.get(req.params.pluginId); + + if (router) { + router(req, res, next); + } else { + next(); + } + }); + + const pluginsDir = path.join(__dirname, "..", "plugins"); + discoveredPlugins = discoverPlugins(pluginsDir); + pluginConfig = await configStore.load(configPath); + + for (const [pluginId] of discoveredPlugins) { + const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId); + + if (enabledVaults.length === 0) { + continue; + } + + try { + await loadPlugin(pluginId); + + for (const vaultId of enabledVaults) { + const vaultPath = ctx.config.getVaultPath(vaultId); + + if (!vaultPath) { + continue; + } + + const discovered = discoveredPlugins.get(pluginId); + + if (discovered.obsidianPlugin) { + try { + await installObsidianPlugin(discovered.obsidianPlugin, vaultPath); + } catch (e) { + console.error( + `[plugins] Failed to verify bundled plugin for ${pluginId} in ${vaultId}: ${e.message}`, + ); + } + } + + const loaded = loadedPlugins.get(pluginId); + + if (loaded?.module?.onVaultEnabled) { + await loaded.module.onVaultEnabled(vaultId, vaultPath); + } + } + } catch (e) { + console.error(`[plugins] Failed to load ${pluginId}: ${e.message}`); + console.error(e.stack); + } + } +} + +async function shutdownPlugins() { + console.log("[plugins] Shutting down all plugins..."); + + for (const [pluginId, loaded] of loadedPlugins) { + if (loaded.shutdown) { + try { + console.log(`[plugins] Shutting down: ${loaded.name}`); + await loaded.shutdown(); + } catch (e) { + console.error( + `[plugins] Error shutting down ${loaded.name}: ${e.message}`, + ); + } + } + } + + loadedPlugins.clear(); + pluginRouters.clear(); + console.log("[plugins] All plugins shut down"); +} + +async function loadPlugin(pluginId) { + if (loadedPlugins.has(pluginId)) { + return; + } + + const discovered = discoveredPlugins.get(pluginId); + + if (!discovered) { + throw new Error(`Plugin not found: ${pluginId}`); + } + + const plugin = discovered.module; + const dataDir = path.join(serverCtx.config.dataRoot, "plugins", pluginId); + + await fs.promises.mkdir(dataDir, { recursive: true }); + + const router = express.Router(); + + const pluginCtx = { + config: serverCtx.config, + wss: serverCtx.wss, + watcher: serverCtx.watcher, + router, + log: (msg) => console.log(`[plugin:${pluginId}] ${msg}`), + dataDir, + getEnabledVaults: () => + configStore.getEnabledVaults(pluginConfig, pluginId), + }; + + await plugin.register(pluginCtx); + + pluginRouters.set(pluginId, router); + + loadedPlugins.set(pluginId, { + id: pluginId, + name: discovered.name, + module: plugin, + ctx: pluginCtx, + shutdown: plugin.shutdown ? plugin.shutdown.bind(plugin) : null, + }); + + console.log(`[plugins] Loaded: ${discovered.name}`); +} + +async function unloadPlugin(pluginId) { + const loaded = loadedPlugins.get(pluginId); + + if (!loaded) { + return; + } + + if (loaded.shutdown) { + console.log(`[plugins] Shutting down: ${loaded.name}`); + await loaded.shutdown(); + } + + pluginRouters.delete(pluginId); + loadedPlugins.delete(pluginId); + console.log(`[plugins] Unloaded: ${loaded.name}`); +} + +async function enablePluginForVault(pluginId, vaultId) { + const discovered = discoveredPlugins.get(pluginId); + + if (!discovered) { + throw new Error(`Plugin not found: ${pluginId}`); + } + + const vaultPath = serverCtx.config.getVaultPath(vaultId); + + if (!vaultPath) { + throw new Error(`Vault not found: ${vaultId}`); + } + + const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId); + + if (!enabledVaults.includes(vaultId)) { + enabledVaults.push(vaultId); + configStore.setEnabledVaults(pluginConfig, pluginId, enabledVaults); + await configStore.save(configPath, pluginConfig); + } + + if (!loadedPlugins.has(pluginId)) { + await loadPlugin(pluginId); + } + + if (discovered.obsidianPlugin) { + try { + const result = await installObsidianPlugin( + discovered.obsidianPlugin, + vaultPath, + ); + + if (result.installed) { + console.log( + `[plugins] Installed bundled Obsidian plugin for ${pluginId} in vault: ${vaultId}`, + ); + } + } catch (e) { + console.error( + `[plugins] Failed to install bundled plugin for ${pluginId}: ${e.message}`, + ); + } + } + + const loaded = loadedPlugins.get(pluginId); + + if (loaded?.module?.onVaultEnabled) { + await loaded.module.onVaultEnabled(vaultId, vaultPath); + } +} + +async function disablePluginForVault(pluginId, vaultId) { + const discovered = discoveredPlugins.get(pluginId); + + if (!discovered) { + throw new Error(`Plugin not found: ${pluginId}`); + } + + const vaultPath = serverCtx.config.getVaultPath(vaultId); + + if (!vaultPath) { + throw new Error(`Vault not found: ${vaultId}`); + } + + const loaded = loadedPlugins.get(pluginId); + + if (loaded?.module?.onVaultDisabled) { + await loaded.module.onVaultDisabled(vaultId, vaultPath); + } + + if (discovered.obsidianPlugin) { + try { + const result = await removeObsidianPlugin( + discovered.obsidianPlugin, + vaultPath, + ); + + if (result.removed) { + console.log( + `[plugins] Removed bundled Obsidian plugin for ${pluginId} from vault: ${vaultId}`, + ); + } + } catch (e) { + console.error( + `[plugins] Failed to remove bundled plugin for ${pluginId}: ${e.message}`, + ); + } + } + + const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId); + const updated = enabledVaults.filter((id) => id !== vaultId); + configStore.setEnabledVaults(pluginConfig, pluginId, updated); + await configStore.save(configPath, pluginConfig); + + if (updated.length === 0) { + await unloadPlugin(pluginId); + } +} + +function getDiscoveredPlugins() { + const result = []; + + for (const [pluginId, discovered] of discoveredPlugins) { + result.push({ + id: discovered.id, + name: discovered.name, + description: discovered.description, + hasBundledPlugin: !!discovered.obsidianPlugin, + bundledPluginId: discovered.bundledPluginId, + enabledVaults: configStore.getEnabledVaults(pluginConfig, pluginId), + loaded: loadedPlugins.has(pluginId), + }); + } + + return result; +} + +module.exports = { + initPlugins, + shutdownPlugins, + enablePluginForVault, + disablePluginForVault, + getDiscoveredPlugins, +}; diff --git a/server/plugins/.gitkeep b/server/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/routes/plugins.js b/server/routes/plugins.js new file mode 100644 index 0000000..c788781 --- /dev/null +++ b/server/routes/plugins.js @@ -0,0 +1,44 @@ +const express = require("express"); +const { + getDiscoveredPlugins, + enablePluginForVault, + disablePluginForVault, +} = require("../plugin-system/manager"); + +const router = express.Router(); + +router.get("/", (req, res) => { + res.json(getDiscoveredPlugins()); +}); + +router.post("/:pluginId/enable", async (req, res) => { + const vaultId = req.body?.vault; + + if (!vaultId) { + return res.status(400).json({ error: "Missing vault ID" }); + } + + try { + await enablePluginForVault(req.params.pluginId, vaultId); + res.json({ ok: true }); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}); + +router.post("/:pluginId/disable", async (req, res) => { + const vaultId = req.body?.vault; + + if (!vaultId) { + return res.status(400).json({ error: "Missing vault ID" }); + } + + try { + await disablePluginForVault(req.params.pluginId, vaultId); + res.json({ ok: true }); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}); + +module.exports = router;