// ============================================================ // Clube67 — Plugin Loader // ============================================================ // Scans /plugins directory, validates manifests, resolves // dependency order, and activates each plugin. import fs from 'fs'; import path from 'path'; import os from 'os'; import { Express } from 'express'; import multer from 'multer'; import { spawnSync } from 'child_process'; import { PluginManifest, PluginInstance, PluginContext } from './types'; import { pluginRegistry } from './plugin-registry'; import { hooks } from './hooks'; import { logger } from '../utils/logger'; import { db } from '../config/database'; import { redis } from '../config/redis'; import { authenticate } from '../middleware/auth'; import { authorize } from '../middleware/rbac'; // Source plugins dir (manifests + SQL migrations) const PROJECT_ROOT = path.resolve(__dirname, '../../../../'); const PLUGINS_SOURCE_DIR = path.join(PROJECT_ROOT, 'plugins'); // Compiled plugins dir (JS modules) const PLUGINS_COMPILED_DIR = path.resolve(__dirname, '../../../plugins'); // ── Manifest Loading ──────────────────────────────────────── function loadManifest(pluginDir: string): PluginManifest | null { const manifestPath = path.join(pluginDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { logger.warn(`[PluginLoader] No manifest.json in ${path.basename(pluginDir)}`); return null; } try { const raw = fs.readFileSync(manifestPath, 'utf-8'); return JSON.parse(raw) as PluginManifest; } catch (err: any) { logger.error(`[PluginLoader] Invalid manifest in ${path.basename(pluginDir)}: ${err.message}`); return null; } } // ── Dependency Resolution (Topological Sort) ──────────────── function resolveDependencyOrder(manifests: PluginManifest[]): PluginManifest[] { const byName = new Map(manifests.map(m => [m.name, m])); const visited = new Set(); const sorted: PluginManifest[] = []; function visit(name: string, stack: Set) { if (visited.has(name)) return; if (stack.has(name)) { throw new Error(`[PluginLoader] Circular dependency detected involving "${name}"`); } stack.add(name); const manifest = byName.get(name); if (!manifest) return; for (const dep of manifest.dependencies) { visit(dep, stack); } stack.delete(name); visited.add(name); sorted.push(manifest); } for (const m of manifests) { visit(m.name, new Set()); } return sorted; } // ── Plugin Instance Loading ───────────────────────────────── function loadPluginInstance(pluginName: string, manifest: PluginManifest): PluginInstance | null { // Try compiled JS first, then source TS const compiledDir = path.join(PLUGINS_COMPILED_DIR, pluginName); const sourceDir = path.join(PLUGINS_SOURCE_DIR, pluginName); const candidates = [ path.join(compiledDir, 'index.js'), path.join(sourceDir, 'index.js'), path.join(sourceDir, 'index.ts'), ]; let modulePath: string | null = null; for (const candidate of candidates) { if (fs.existsSync(candidate)) { modulePath = candidate; break; } } if (!modulePath) { logger.warn(`[PluginLoader] No index file in plugin "${manifest.name}"`); return null; } try { // eslint-disable-next-line @typescript-eslint/no-var-requires const mod = require(modulePath); const instance: PluginInstance = mod.default || mod; instance.manifest = manifest; return instance; } catch (err: any) { logger.error(`[PluginLoader] Failed to load "${manifest.name}": ${err.message}`); return null; } } function getPluginDirByName(name: string): { dirName: string; fullPath: string; manifest: PluginManifest } | null { if (!fs.existsSync(PLUGINS_SOURCE_DIR)) return null; const pluginDirs = fs.readdirSync(PLUGINS_SOURCE_DIR) .map(dirName => ({ dirName, fullPath: path.join(PLUGINS_SOURCE_DIR, dirName) })) .filter(p => fs.existsSync(path.join(p.fullPath, 'manifest.json'))); for (const p of pluginDirs) { const manifest = loadManifest(p.fullPath); if (manifest && manifest.name === name) { return { dirName: p.dirName, fullPath: p.fullPath, manifest }; } } return null; } // ── Migration Runner ──────────────────────────────────────── async function runMigrations(pluginName: string, manifest: PluginManifest): Promise { if (!manifest.backend?.hasMigrations) return; // Always read migrations from SOURCE directory (SQL files aren't compiled) const migrationsDir = path.join(PLUGINS_SOURCE_DIR, pluginName, 'backend', 'migrations'); if (!fs.existsSync(migrationsDir)) return; const migrationFiles = fs.readdirSync(migrationsDir) .filter(f => f.endsWith('.sql')) .sort(); for (const file of migrationFiles) { const filePath = path.join(migrationsDir, file); const sql = fs.readFileSync(filePath, 'utf-8'); // Check if migration was already applied const migrationKey = `${manifest.name}:${file}`; try { if (db.client.config.client === 'pg') { await db.raw(` CREATE TABLE IF NOT EXISTS plugin_migrations ( id SERIAL PRIMARY KEY, plugin_name VARCHAR(255) NOT NULL, migration_file VARCHAR(255) NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_migration UNIQUE (plugin_name, migration_file) ) `); } else { await db.raw(` CREATE TABLE IF NOT EXISTS plugin_migrations ( id INT AUTO_INCREMENT PRIMARY KEY, plugin_name VARCHAR(255) NOT NULL, migration_file VARCHAR(255) NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY unique_migration (plugin_name, migration_file) ) `); } const existing = await db('plugin_migrations') .select('id') .where({ plugin_name: manifest.name, migration_file: file }) .first(); if (existing) { logger.info(`[PluginLoader] Migration ${migrationKey} already applied, skipping.`); continue; } // Run migration — each statement is fault-tolerant for idempotent patterns const statements = sql.split(';').filter(s => s.trim().length > 0); for (const stmt of statements) { try { let finalStmt = stmt; if (db.client.config.client === 'pg') { // If it's a MySQL session variable script or procedural blocks, skip it if (stmt.toLowerCase().includes('set @') || stmt.toLowerCase().includes('prepare ') || stmt.toLowerCase().includes('execute ') || stmt.toLowerCase().includes('deallocate ')) { continue; } // Replace common MySQL types/keywords finalStmt = finalStmt .replace(/int\s+auto_increment/gi, 'SERIAL') .replace(/auto_increment/gi, '') .replace(/int\(\d+\)/gi, 'integer') .replace(/varchar\(\d+\)/gi, 'varchar') .replace(/text/gi, 'text') .replace(/datetime/gi, 'timestamp') .replace(/timestamp\s+default\s+current_timestamp\s+on\s+update\s+current_timestamp/gi, 'timestamp default current_timestamp') .replace(/on\s+update\s+current_timestamp/gi, '') .replace(/unique\s+key\s+\w+\s+\(([^)]+)\)/gi, 'CONSTRAINT unique_$1 UNIQUE ($1)') .replace(/index\s+\w+\s+\(([^)]+)\)/gi, '') .replace(/foreign\s+key\s+\(([^)]+)\)\s+references\s+(\w+)\(([^)]+)\)/gi, 'FOREIGN KEY ($1) REFERENCES $2($3)') .replace(/engine\s*=\s*\w+/gi, '') .replace(/default\s+charset\s*=\s*\w+/gi, '') .replace(/collate\s*=\s*\w+/gi, '') .replace(/modify\s+column/gi, 'ALTER COLUMN') .replace(/add\s+column\s+if\s+not\s+exists/gi, 'ADD COLUMN') .replace(/add\s+column/gi, 'ADD COLUMN') .replace(/if\s+not\s+exists/gi, '') .replace(/enum\([^)]+\)/gi, 'varchar(255)'); } await db.raw(finalStmt); } catch (stmtErr: any) { if (db.client.config.client === 'pg') { const pgErrorMsgs = [ 'already exists', 'duplicate key', 'does not exist', 'already is a member' ]; if (pgErrorMsgs.some(msg => stmtErr.message.toLowerCase().includes(msg))) { logger.info(`[PluginLoader] ⏭️ PostgreSQL skipped: ${stmtErr.message.substring(0, 80)}`); } else { logger.warn(`[PluginLoader] ⚠️ PostgreSQL migration statement failed (continuing): ${stmtErr.message}`); } } else { const toleratedCodes = [1060, 1061, 1050, 1091]; if (toleratedCodes.includes(stmtErr.errno)) { logger.info(`[PluginLoader] ⏭️ Skipped (already exists): ${stmtErr.message.substring(0, 80)}`); } else { throw stmtErr; } } } } // Record migration await db('plugin_migrations').insert({ plugin_name: manifest.name, migration_file: file }); logger.info(`[PluginLoader] ✅ Applied migration ${migrationKey}`); } catch (err: any) { logger.error(`[PluginLoader] ❌ Migration ${migrationKey} failed: ${err.message}`); throw err; } } } function buildPluginContext(app: Express, io: any, manifest: PluginManifest): PluginContext { return { app, io, db, hooks, config: {}, logger: { info: (msg, meta) => logger.info(`[Plugin:${manifest.name}] ${msg}`, meta), warn: (msg, meta) => logger.warn(`[Plugin:${manifest.name}] ${msg}`, meta), error: (msg, meta) => logger.error(`[Plugin:${manifest.name}] ${msg}`, meta), }, }; } function clearRequireCache(targetDir: string): void { const normalized = path.resolve(targetDir); Object.keys(require.cache).forEach(key => { if (key.startsWith(normalized)) { delete require.cache[key]; } }); } async function unloadPlugin(app: Express, io: any, name: string): Promise { const entry = pluginRegistry.get(name); if (!entry) return; const ctx = buildPluginContext(app, io, entry.manifest); try { await entry.instance.deactivate(ctx); } catch (err: any) { logger.warn(`[PluginLoader] Deactivate "${name}" failed: ${err.message}`); } const layers = entry.layers || []; const routerStack = (app as any)?._router?.stack; if (Array.isArray(routerStack) && layers.length > 0) { (app as any)._router.stack = routerStack.filter((l: any) => !layers.includes(l)); } pluginRegistry.remove(name); } async function loadPluginFromDir(app: Express, io: any, dirName: string, manifest: PluginManifest): Promise { const instance = loadPluginInstance(dirName, manifest); if (!instance) { pluginRegistry.register(manifest, { manifest, activate: async () => { }, deactivate: async () => { } }); pluginRegistry.markError(manifest.name, 'Failed to load plugin module'); return; } pluginRegistry.register(manifest, instance); try { await runMigrations(dirName, manifest); } catch (err: any) { pluginRegistry.markError(manifest.name, `Migration failed: ${err.message}`); return; } const ctx = buildPluginContext(app, io, manifest); const beforeStack = ((app as any)?._router?.stack || []).slice(); try { await instance.activate(ctx); pluginRegistry.markActive(manifest.name); const afterStack = (app as any)?._router?.stack || []; const newLayers = afterStack.filter((l: any) => !beforeStack.includes(l)); pluginRegistry.setLayers(manifest.name, newLayers); logger.info(`[PluginLoader] ✅ Activated: ${manifest.displayName} v${manifest.version}`); } catch (err: any) { pluginRegistry.markError(manifest.name, err.message); logger.error(`[PluginLoader] ❌ Failed to activate "${manifest.name}": ${err.message}`); } } // ── Main Loader ───────────────────────────────────────────── export async function loadPlugins(app: Express, io: any): Promise { logger.info(`[PluginLoader] Scanning plugins source: ${PLUGINS_SOURCE_DIR}`); logger.info(`[PluginLoader] Compiled plugins at: ${PLUGINS_COMPILED_DIR}`); if (!fs.existsSync(PLUGINS_SOURCE_DIR)) { logger.info('[PluginLoader] No plugins directory found, creating...'); fs.mkdirSync(PLUGINS_SOURCE_DIR, { recursive: true }); return; } // 1. Scan and load manifests from source directory const pluginDirs = fs.readdirSync(PLUGINS_SOURCE_DIR) .map(name => ({ name, fullPath: path.join(PLUGINS_SOURCE_DIR, name) })) .filter(p => fs.statSync(p.fullPath).isDirectory() && p.name !== 'node_modules'); const manifests: PluginManifest[] = []; const pluginNames: Map = new Map(); for (const { name: dirName, fullPath } of pluginDirs) { const manifest = loadManifest(fullPath); if (manifest && manifest.enabled) { manifests.push(manifest); pluginNames.set(manifest.name, dirName); } } logger.info(`[PluginLoader] Found ${manifests.length} enabled plugins`); // 2. Resolve dependency order let sorted: PluginManifest[]; try { sorted = resolveDependencyOrder(manifests); } catch (err: any) { logger.error(err.message); return; } // 3. Activate plugins in order for (const manifest of sorted) { const dirName = pluginNames.get(manifest.name)!; // Check dependencies const depCheck = pluginRegistry.checkDependencies(manifest.name); // Register first so dependency check works for later plugins const instance = loadPluginInstance(dirName, manifest); if (!instance) { pluginRegistry.register(manifest, { manifest, activate: async () => { }, deactivate: async () => { } }); pluginRegistry.markError(manifest.name, 'Failed to load plugin module'); continue; } pluginRegistry.register(manifest, instance); // Run migrations before activation try { await runMigrations(dirName, manifest); } catch (err: any) { pluginRegistry.markError(manifest.name, `Migration failed: ${err.message}`); continue; } // Build plugin context const ctx: PluginContext = buildPluginContext(app, io, manifest); // Activate try { const beforeStack = ((app as any)?._router?.stack || []).slice(); await instance.activate(ctx); pluginRegistry.markActive(manifest.name); const afterStack = (app as any)?._router?.stack || []; const newLayers = afterStack.filter((l: any) => !beforeStack.includes(l)); pluginRegistry.setLayers(manifest.name, newLayers); logger.info(`[PluginLoader] ✅ Activated: ${manifest.displayName} v${manifest.version}`); } catch (err: any) { pluginRegistry.markError(manifest.name, err.message); logger.error(`[PluginLoader] ❌ Failed to activate "${manifest.name}": ${err.message}`); } } // 4. Summary const summary = pluginRegistry.getSummary(); logger.info(`[PluginLoader] ═══════════════════════════════════`); logger.info(`[PluginLoader] Plugins: ${summary.active} active, ${summary.error} errors, ${summary.inactive} inactive`); logger.info(`[PluginLoader] ═══════════════════════════════════`); // 5. Register admin API for plugin management registerPluginAdminRoutes(app, io); } // ── Admin API Routes ──────────────────────────────────────── function registerPluginAdminRoutes(app: Express, io: any): void { const adminOnly = [authenticate, authorize(['super_admin'])]; const uploadZip = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (file.originalname.toLowerCase().endsWith('.zip')) return cb(null, true); cb(new Error('Apenas arquivos .zip são permitidos')); }, }); const PLUGIN_LIST_CACHE_KEY = 'admin:plugins:list'; const PLUGIN_MENU_CACHE_PREFIX = 'admin:plugins:menu:'; const PLUGIN_CACHE_TTL_SECONDS = 30; const clearPluginCaches = async () => { try { await redis.del(PLUGIN_LIST_CACHE_KEY); const menuKeys = await redis.keys(`${PLUGIN_MENU_CACHE_PREFIX}*`); if (menuKeys.length) await redis.del(...menuKeys); } catch { // ignore cache errors } }; function copyDir(srcDir: string, destDir: string) { if (!fs.existsSync(srcDir)) return; fs.mkdirSync(destDir, { recursive: true }); const entries = fs.readdirSync(srcDir, { withFileTypes: true }); for (const entry of entries) { const src = path.join(srcDir, entry.name); const dest = path.join(destDir, entry.name); if (entry.isDirectory()) copyDir(src, dest); else fs.copyFileSync(src, dest); } } function buildExportZip(pluginDir: string, lite: boolean): Buffer { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-export-')); const stageDir = path.join(tmpDir, 'plugin'); fs.mkdirSync(stageDir, { recursive: true }); const manifestPath = path.join(pluginDir, 'manifest.json'); if (fs.existsSync(manifestPath)) fs.copyFileSync(manifestPath, path.join(stageDir, 'manifest.json')); const backendDir = path.join(pluginDir, 'backend'); const routesTs = path.join(backendDir, 'routes.ts'); const routesJs = path.join(backendDir, 'routes.js'); const serviceTs = path.join(backendDir, 'service.ts'); const serviceJs = path.join(backendDir, 'service.js'); const stageBackend = path.join(stageDir, 'backend'); fs.mkdirSync(stageBackend, { recursive: true }); if (fs.existsSync(routesTs)) fs.copyFileSync(routesTs, path.join(stageBackend, 'routes.ts')); if (fs.existsSync(routesJs)) fs.copyFileSync(routesJs, path.join(stageBackend, 'routes.js')); if (fs.existsSync(serviceTs)) fs.copyFileSync(serviceTs, path.join(stageBackend, 'service.ts')); if (fs.existsSync(serviceJs)) fs.copyFileSync(serviceJs, path.join(stageBackend, 'service.js')); copyDir(path.join(backendDir, 'models'), path.join(stageBackend, 'models')); if (!lite) { copyDir(path.join(backendDir, 'migrations'), path.join(stageBackend, 'migrations')); const readmePath = path.join(pluginDir, 'README.md'); if (fs.existsSync(readmePath)) fs.copyFileSync(readmePath, path.join(stageDir, 'README.md')); } const zipPath = path.join(tmpDir, 'plugin.zip'); const zipProc = spawnSync('zip', ['-r', zipPath, '.'], { cwd: stageDir }); if (zipProc.status !== 0) { throw new Error('Falha ao gerar ZIP'); } const buffer = fs.readFileSync(zipPath); fs.rmSync(tmpDir, { recursive: true, force: true }); return buffer; } async function importPluginFromZip(app: Express, io: any, buffer: Buffer, reloadAfter: boolean): Promise<{ name: string }> { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-import-')); const zipPath = path.join(tmpDir, 'plugin.zip'); fs.writeFileSync(zipPath, buffer); const listProc = spawnSync('unzip', ['-Z1', zipPath]); if (listProc.status !== 0) throw new Error('Arquivo ZIP inválido'); const entries = listProc.stdout.toString('utf-8').split('\n').filter(Boolean); for (const name of entries) { const clean = name.replace(/\\/g, '/'); if (clean.startsWith('/') || clean.includes('..') || clean.includes(':')) { throw new Error('Arquivo ZIP inválido'); } } const extractDir = path.join(tmpDir, 'extract'); fs.mkdirSync(extractDir, { recursive: true }); const unzipProc = spawnSync('unzip', ['-o', zipPath, '-d', extractDir]); if (unzipProc.status !== 0) throw new Error('Falha ao extrair ZIP'); const findManifest = (dir: string): string | null => { const items = fs.readdirSync(dir, { withFileTypes: true }); for (const item of items) { const full = path.join(dir, item.name); if (item.isDirectory()) { const found = findManifest(full); if (found) return found; } else if (item.name === 'manifest.json') { return full; } } return null; }; const manifestPath = findManifest(extractDir); if (!manifestPath) throw new Error('manifest.json não encontrado no ZIP'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PluginManifest; if (!manifest?.name) throw new Error('manifest.json inválido'); const rootDir = path.dirname(manifestPath); const targetDir = path.join(PLUGINS_SOURCE_DIR, manifest.name); if (fs.existsSync(targetDir)) { await unloadPlugin(app, io, manifest.name); fs.rmSync(targetDir, { recursive: true, force: true }); } fs.mkdirSync(PLUGINS_SOURCE_DIR, { recursive: true }); fs.cpSync(rootDir, targetDir, { recursive: true }); clearRequireCache(path.join(PLUGINS_SOURCE_DIR, manifest.name)); clearRequireCache(path.join(PLUGINS_COMPILED_DIR, manifest.name)); const loaded = loadManifest(targetDir); if (!loaded) throw new Error('manifest.json inválido no destino'); await loadPluginFromDir(app, io, manifest.name, loaded); if (reloadAfter) { await unloadPlugin(app, io, manifest.name); clearRequireCache(path.join(PLUGINS_SOURCE_DIR, manifest.name)); clearRequireCache(path.join(PLUGINS_COMPILED_DIR, manifest.name)); await loadPluginFromDir(app, io, manifest.name, loaded); } fs.rmSync(tmpDir, { recursive: true, force: true }); await clearPluginCaches(); return { name: manifest.name }; } // GET /api/admin/plugins — list all plugins app.get('/api/admin/plugins', ...adminOnly, (req, res) => { (async () => { try { const cached = await redis.get(PLUGIN_LIST_CACHE_KEY); if (cached) { return res.json(JSON.parse(cached)); } } catch { // ignore cache errors } const plugins = pluginRegistry.getAll().map(entry => ({ name: entry.manifest.name, displayName: entry.manifest.displayName, version: entry.manifest.version, description: entry.manifest.description, category: entry.manifest.category, status: entry.status, error: entry.error, canDisable: entry.manifest.canDisable, dependencies: entry.manifest.dependencies, activatedAt: entry.activatedAt, menuItems: entry.manifest.frontend?.menuItems || [], })); const payload = { summary: pluginRegistry.getSummary(), plugins, }; try { await redis.set(PLUGIN_LIST_CACHE_KEY, JSON.stringify(payload), 'EX', PLUGIN_CACHE_TTL_SECONDS); } catch { // ignore cache errors } res.json(payload); })(); }); // GET /api/admin/plugins/menu — get sidebar menu items for current user app.get('/api/admin/plugins/menu', ...adminOnly, (req, res) => { (async () => { const role = (req as any).user?.role || 'user'; const cacheKey = `${PLUGIN_MENU_CACHE_PREFIX}${role}`; try { const cached = await redis.get(cacheKey); if (cached) { return res.json(JSON.parse(cached)); } } catch { // ignore cache errors } const activeManifests = pluginRegistry.getActiveManifests(); const menuItems = activeManifests .flatMap(m => (m.frontend?.menuItems || []).map(item => ({ ...item, pluginName: m.name, }))) .filter(item => item.roles.includes(role) || item.roles.includes('*')) .sort((a, b) => a.order - b.order); const payload = { menuItems }; try { await redis.set(cacheKey, JSON.stringify(payload), 'EX', PLUGIN_CACHE_TTL_SECONDS); } catch { // ignore cache errors } res.json(payload); })(); }); // GET /api/admin/plugins/hooks — list registered hooks app.get('/api/admin/plugins/hooks', ...adminOnly, (_req, res) => { const hooksList = hooks.listEvents().map(event => ({ event, handlerCount: hooks.handlerCount(event), })); res.json({ hooks: hooksList }); }); // GET /api/admin/plugins/:name/export — download plugin package app.get('/api/admin/plugins/:name/export', ...adminOnly, (req, res) => { const name = req.params.name; const info = getPluginDirByName(name); if (!info) return res.status(404).json({ error: 'Plugin não encontrado' }); const buffer = buildExportZip(info.fullPath, false); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${name}.zip"`); res.send(buffer); }); // GET /api/admin/plugins/:name/export-lite — lightweight package app.get('/api/admin/plugins/:name/export-lite', ...adminOnly, (req, res) => { const name = req.params.name; const info = getPluginDirByName(name); if (!info) return res.status(404).json({ error: 'Plugin não encontrado' }); const buffer = buildExportZip(info.fullPath, true); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${name}-lite.zip"`); res.send(buffer); }); // POST /api/admin/plugins/import — upload plugin ZIP app.post('/api/admin/plugins/import', ...adminOnly, uploadZip.single('file'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'Arquivo ZIP obrigatório' }); const result = await importPluginFromZip(app, io, req.file.buffer, false); await clearPluginCaches(); res.json({ message: 'Plugin importado', name: result.name }); } catch (err: any) { res.status(400).json({ error: err.message || 'Erro ao importar plugin' }); } }); // POST /api/admin/plugins/:name/reload — reload plugin app.post('/api/admin/plugins/:name/reload', ...adminOnly, async (req, res) => { const name = req.params.name; const info = getPluginDirByName(name); if (!info) return res.status(404).json({ error: 'Plugin não encontrado' }); try { await unloadPlugin(app, io, name); clearRequireCache(path.join(PLUGINS_SOURCE_DIR, info.dirName)); clearRequireCache(path.join(PLUGINS_COMPILED_DIR, info.dirName)); await loadPluginFromDir(app, io, info.dirName, info.manifest); await clearPluginCaches(); res.json({ message: 'Plugin recarregado', name }); } catch (err: any) { res.status(500).json({ error: err.message || 'Erro ao recarregar plugin' }); } }); // POST /api/admin/plugins/:name/update — update plugin via ZIP app.post('/api/admin/plugins/:name/update', ...adminOnly, uploadZip.single('file'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'Arquivo ZIP obrigatório' }); const result = await importPluginFromZip(app, io, req.file.buffer, true); await clearPluginCaches(); res.json({ message: 'Plugin atualizado', name: result.name }); } catch (err: any) { res.status(400).json({ error: err.message || 'Erro ao atualizar plugin' }); } }); }