174 lines
5.6 KiB
TypeScript
174 lines
5.6 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { Express, Router } from 'express';
|
|
import db from '../config/database';
|
|
import logger from '../utils/logger';
|
|
|
|
export interface PluginManifest {
|
|
name: string;
|
|
slug: string;
|
|
version: string;
|
|
description: string;
|
|
author: string;
|
|
entryPoint: string;
|
|
dependencies?: string[];
|
|
hooks?: string[];
|
|
}
|
|
|
|
export interface PluginInstance {
|
|
manifest: PluginManifest;
|
|
router?: Router;
|
|
hooks: Record<string, Function[]>;
|
|
activate: () => Promise<void>;
|
|
deactivate: () => Promise<void>;
|
|
}
|
|
|
|
const loadedPlugins: Map<string, PluginInstance> = new Map();
|
|
const hookRegistry: Record<string, Function[]> = {};
|
|
|
|
const PLUGINS_DIR = path.resolve(__dirname, '../../../plugins');
|
|
|
|
export const pluginService = {
|
|
async loadAll(app: Express) {
|
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
return;
|
|
}
|
|
|
|
const activePlugins = await db('plugins').where('active', true);
|
|
for (const pluginRecord of activePlugins) {
|
|
try {
|
|
await this.load(app, pluginRecord.slug);
|
|
} catch (err) {
|
|
logger.error(`Failed to load plugin: ${pluginRecord.slug}`, err);
|
|
}
|
|
}
|
|
logger.info(`Loaded ${loadedPlugins.size} plugins`);
|
|
},
|
|
|
|
async load(app: Express, slug: string) {
|
|
const pluginDir = path.join(PLUGINS_DIR, slug);
|
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
|
|
|
if (!fs.existsSync(manifestPath)) {
|
|
throw new Error(`Plugin manifest not found: ${manifestPath}`);
|
|
}
|
|
|
|
const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
const entryPath = path.join(pluginDir, manifest.entryPoint || 'index.js');
|
|
|
|
if (!fs.existsSync(entryPath)) {
|
|
throw new Error(`Plugin entry point not found: ${entryPath}`);
|
|
}
|
|
|
|
const pluginModule = require(entryPath);
|
|
const instance: PluginInstance = {
|
|
manifest,
|
|
router: pluginModule.router,
|
|
hooks: pluginModule.hooks || {},
|
|
activate: pluginModule.activate || (async () => { }),
|
|
deactivate: pluginModule.deactivate || (async () => { }),
|
|
};
|
|
|
|
// Register routes
|
|
if (instance.router) {
|
|
app.use(`/api/plugins/${slug}`, instance.router);
|
|
}
|
|
|
|
// Register hooks
|
|
for (const [hookName, handlers] of Object.entries(instance.hooks)) {
|
|
if (!hookRegistry[hookName]) hookRegistry[hookName] = [];
|
|
if (Array.isArray(handlers)) {
|
|
hookRegistry[hookName].push(...handlers);
|
|
}
|
|
}
|
|
|
|
await instance.activate();
|
|
loadedPlugins.set(slug, instance);
|
|
logger.info(`Plugin loaded: ${manifest.name} v${manifest.version}`);
|
|
},
|
|
|
|
async unload(slug: string) {
|
|
const instance = loadedPlugins.get(slug);
|
|
if (instance) {
|
|
await instance.deactivate();
|
|
loadedPlugins.delete(slug);
|
|
logger.info(`Plugin unloaded: ${slug}`);
|
|
}
|
|
},
|
|
|
|
async install(slug: string) {
|
|
const pluginDir = path.join(PLUGINS_DIR, slug);
|
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
|
if (!fs.existsSync(manifestPath)) {
|
|
throw new Error('Manifest not found');
|
|
}
|
|
const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
|
|
const existing = await db('plugins').where('slug', slug).first();
|
|
if (existing) {
|
|
await db('plugins').where('slug', slug).update({
|
|
version: manifest.version,
|
|
description: manifest.description,
|
|
author: manifest.author,
|
|
entry_point: manifest.entryPoint,
|
|
dependencies: JSON.stringify(manifest.dependencies || []),
|
|
hooks: JSON.stringify(manifest.hooks || []),
|
|
});
|
|
} else {
|
|
await db('plugins').insert({
|
|
name: manifest.name,
|
|
slug: manifest.slug,
|
|
version: manifest.version,
|
|
description: manifest.description,
|
|
author: manifest.author,
|
|
entry_point: manifest.entryPoint,
|
|
active: false,
|
|
dependencies: JSON.stringify(manifest.dependencies || []),
|
|
hooks: JSON.stringify(manifest.hooks || []),
|
|
directory: pluginDir,
|
|
});
|
|
}
|
|
return manifest;
|
|
},
|
|
|
|
async activate(app: Express, slug: string) {
|
|
await db('plugins').where('slug', slug).update({ active: true });
|
|
await this.load(app, slug);
|
|
},
|
|
|
|
async deactivate(slug: string) {
|
|
await db('plugins').where('slug', slug).update({ active: false });
|
|
await this.unload(slug);
|
|
},
|
|
|
|
async getAll() {
|
|
return db('plugins').orderBy('name');
|
|
},
|
|
|
|
async remove(slug: string) {
|
|
await this.unload(slug);
|
|
await db('plugins').where('slug', slug).delete();
|
|
// Note: does not delete plugin files for safety
|
|
},
|
|
|
|
async triggerHook(hookName: string, data: any) {
|
|
const handlers = hookRegistry[hookName] || [];
|
|
for (const handler of handlers) {
|
|
try {
|
|
await handler(data);
|
|
} catch (err) {
|
|
logger.error(`Hook error [${hookName}]:`, err);
|
|
}
|
|
}
|
|
},
|
|
|
|
getLoadedPlugins() {
|
|
return Array.from(loadedPlugins.entries()).map(([slug, instance]) => ({
|
|
slug,
|
|
name: instance.manifest.name,
|
|
version: instance.manifest.version,
|
|
}));
|
|
},
|
|
};
|