implement server plugin system

This commit is contained in:
Nystik
2026-03-26 23:55:12 +01:00
parent 80bf7436d9
commit c32ade2f65
7 changed files with 446 additions and 1 deletions

View File

@@ -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;
},

View File

@@ -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/<vault-id>/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);

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
};

0
server/plugins/.gitkeep Normal file
View File

44
server/routes/plugins.js Normal file
View File

@@ -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;