implement server plugin system
This commit is contained in:
parent
80bf7436d9
commit
c32ade2f65
7 changed files with 446 additions and 1 deletions
|
|
@ -6,13 +6,22 @@ const fs = require("fs");
|
||||||
const vaultRoot =
|
const vaultRoot =
|
||||||
process.env.VAULT_ROOT || path.join(__dirname, "..", "vaults");
|
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 {
|
try {
|
||||||
fs.mkdirSync(vaultRoot, { recursive: true });
|
fs.mkdirSync(vaultRoot, { recursive: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[config] Failed to create VAULT_ROOT:", vaultRoot, e.message);
|
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() {
|
function discoverVaults() {
|
||||||
const vaults = {};
|
const vaults = {};
|
||||||
|
|
||||||
|
|
@ -52,6 +61,7 @@ let vaults = discoverVaults();
|
||||||
module.exports = {
|
module.exports = {
|
||||||
port: process.env.PORT || 8080,
|
port: process.env.PORT || 8080,
|
||||||
vaultRoot,
|
vaultRoot,
|
||||||
|
dataRoot,
|
||||||
get vaults() {
|
get vaults() {
|
||||||
return vaults;
|
return vaults;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ const config = require("./config");
|
||||||
const { setupWebSocket } = require("./ws");
|
const { setupWebSocket } = require("./ws");
|
||||||
const watcher = require("./watcher");
|
const watcher = require("./watcher");
|
||||||
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
|
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
|
||||||
|
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
|
||||||
|
const pluginRoutes = require("./routes/plugins");
|
||||||
|
|
||||||
const ANSI_RED = "\x1b[31m";
|
const ANSI_RED = "\x1b[31m";
|
||||||
const ANSI_YELLOW = "\x1b[33m";
|
const ANSI_YELLOW = "\x1b[33m";
|
||||||
|
|
@ -52,6 +54,7 @@ app.use("/api/fs", fsRoutes);
|
||||||
app.use("/api/vault", vaultRoutes);
|
app.use("/api/vault", vaultRoutes);
|
||||||
app.use("/api/proxy", proxyRoutes);
|
app.use("/api/proxy", proxyRoutes);
|
||||||
app.use("/api/version", versionRoutes);
|
app.use("/api/version", versionRoutes);
|
||||||
|
app.use("/api/plugins", pluginRoutes);
|
||||||
|
|
||||||
// Serve vault files for resource URLs (images, attachments, etc.)
|
// Serve vault files for resource URLs (images, attachments, etc.)
|
||||||
// Vault ID is the first path segment: /vault-files/<vault-id>/path/to/file
|
// 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(", ")}`);
|
console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`);
|
||||||
|
|
||||||
await updateBridgePluginInAllVaults(config.vaultRoot);
|
await updateBridgePluginInAllVaults(config.vaultRoot);
|
||||||
|
await initPlugins({ app, config, wss, watcher });
|
||||||
});
|
});
|
||||||
|
|
||||||
const wss = setupWebSocket(server);
|
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;
|
||||||
Loading…
Add table
Reference in a new issue