mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
implement server plugin system
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
30
server/plugin-system/config-store.js
Normal file
30
server/plugin-system/config-store.js
Normal 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 };
|
||||
74
server/plugin-system/discovery.js
Normal file
74
server/plugin-system/discovery.js
Normal 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 };
|
||||
283
server/plugin-system/manager.js
Normal file
283
server/plugin-system/manager.js
Normal 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
0
server/plugins/.gitkeep
Normal file
44
server/routes/plugins.js
Normal file
44
server/routes/plugins.js
Normal 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;
|
||||
Reference in New Issue
Block a user