diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 49ebfcdc6c3..aef0ac63e23 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1525,6 +1525,51 @@ export async function handleChatCore({ }; let tokensCompressed: number | null = null; body = injectSystemPrompt(body); + // ── Plugin onRequest hook ── + // Dynamic import cached by Node.js after first call — minimal overhead + try { + const { runOnRequest } = await import("@/lib/plugins/index"); + const pluginCtx = { + requestId: traceId, + body, + model, + provider, + apiKeyInfo, + metadata: {}, + }; + const pluginResult = await runOnRequest(pluginCtx); + if (pluginResult?.blocked) { + log?.info?.("PLUGIN", `Request blocked by plugin`); + return { + success: false, + status: 403, + error: "Request blocked by plugin", + response: pluginResult.response + ? new Response(JSON.stringify(pluginResult.response), { + status: 403, + headers: { "Content-Type": "application/json" }, + }) + : new Response( + JSON.stringify({ + error: { message: "Request blocked by plugin", type: "plugin_block" }, + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + } + ), + }; + } + if (pluginResult?.ctx && "body" in pluginResult.ctx) { + body = (pluginResult.ctx as unknown as Record).body; + } + } catch (pluginErr) { + log?.debug?.( + "PLUGIN", + `onRequest hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}` + ); + } + type EffectiveServiceTier = "standard" | CodexServiceTier; let effectiveServiceTier: EffectiveServiceTier = "standard"; const resolveEffectiveServiceTier = (requestBody?: unknown): EffectiveServiceTier => { @@ -3199,6 +3244,20 @@ export async function handleChatCore({ ); } } catch (error) { + // ── Plugin onError hook ── + try { + const { runOnError } = await import("@/lib/plugins/index"); + await runOnError( + { requestId: traceId, body, model, provider, apiKeyInfo, metadata: {} }, + error instanceof Error ? error : new Error(String(error)) + ); + } catch (pluginErr) { + log?.debug?.( + "PLUGIN", + `onError hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}` + ); + } + const parsedStatus = Number(error?.statusCode); const statusCode = Number.isInteger(parsedStatus) && parsedStatus >= 400 && parsedStatus <= 599 diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index c24757d3d66..7b75448745e 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -75,6 +75,7 @@ import { } from "./tools/advancedTools.ts"; import { memoryTools } from "./tools/memoryTools.ts"; import { skillTools } from "./tools/skillTools.ts"; +import { pluginTools } from "./tools/pluginTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; import { gamificationTools } from "./tools/gamificationTools.ts"; import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts"; @@ -102,7 +103,8 @@ const TOTAL_MCP_TOOL_COUNT = MCP_TOOLS.length + Object.keys(memoryTools).length + Object.keys(skillTools).length + - gamificationTools.length; + gamificationTools.length + + pluginTools.length; type JsonRecord = Record; @@ -1003,6 +1005,29 @@ export function createMcpServer(): McpServer { ); }); + // ── Plugin Tools ────────────────────────────── + pluginTools.forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement(toolDef.name, async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-ignore: handler expected specific object + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }) + ); + }); + // ── Compression Tools ───────────────────────── Object.values(compressionTools).forEach((toolDef) => { server.registerTool( diff --git a/open-sse/mcp-server/tools/pluginTools.ts b/open-sse/mcp-server/tools/pluginTools.ts new file mode 100644 index 00000000000..a6d4a3f9b15 --- /dev/null +++ b/open-sse/mcp-server/tools/pluginTools.ts @@ -0,0 +1,149 @@ +/** + * MCP Plugin Tools — 8 tools for plugin management. + * + * @module mcp-server/tools/pluginTools + */ + +import { z } from "zod"; +import { listPlugins, getPluginByName, updatePluginConfig } from "../../../src/lib/db/plugins"; +import { pluginManager } from "../../../src/lib/plugins/manager"; + +export const pluginTools = [ + { + name: "plugin_list", + description: "List all installed plugins with their status, hooks, and metadata.", + inputSchema: z.object({ + status: z + .enum(["installed", "active", "inactive", "error"]) + .optional() + .describe("Filter by plugin status"), + }), + handler: async (args: { status?: string }) => { + const plugins = listPlugins(args.status as any); + return { + plugins: plugins.map((p) => ({ + name: p.name, + version: p.version, + description: p.description, + status: p.status, + enabled: p.enabled === 1, + hooks: JSON.parse(p.hooks || "[]"), + permissions: JSON.parse(p.permissions || "[]"), + installedAt: p.installedAt, + activatedAt: p.activatedAt, + })), + }; + }, + }, + + { + name: "plugin_install", + description: "Install a plugin from a local directory path.", + inputSchema: z.object({ + path: z.string().describe("Absolute path to the plugin directory containing plugin.json"), + }), + handler: async (args: { path: string }) => { + const plugin = await pluginManager.install(args.path); + return { + success: true, + plugin: { + name: plugin.name, + version: plugin.version, + status: plugin.status, + }, + }; + }, + }, + + { + name: "plugin_activate", + description: "Activate an installed plugin (loads hooks into the request pipeline).", + inputSchema: z.object({ + name: z.string().describe("Plugin name (kebab-case)"), + }), + handler: async (args: { name: string }) => { + await pluginManager.activate(args.name); + return { success: true, message: `Plugin '${args.name}' activated` }; + }, + }, + + { + name: "plugin_deactivate", + description: "Deactivate an active plugin (unloads hooks from the request pipeline).", + inputSchema: z.object({ + name: z.string().describe("Plugin name (kebab-case)"), + }), + handler: async (args: { name: string }) => { + await pluginManager.deactivate(args.name); + return { success: true, message: `Plugin '${args.name}' deactivated` }; + }, + }, + + { + name: "plugin_uninstall", + description: "Uninstall a plugin (deactivates, removes files, removes from DB).", + inputSchema: z.object({ + name: z.string().describe("Plugin name (kebab-case)"), + }), + handler: async (args: { name: string }) => { + await pluginManager.uninstall(args.name); + return { success: true, message: `Plugin '${args.name}' uninstalled` }; + }, + }, + + { + name: "plugin_configure", + description: "Get or update a plugin's configuration.", + inputSchema: z.object({ + name: z.string().describe("Plugin name"), + config: z + .record(z.string(), z.unknown()) + .optional() + .describe("New config values to merge (omit to just read current config)"), + }), + handler: async (args: { name: string; config?: Record }) => { + const plugin = getPluginByName(args.name); + if (!plugin) throw new Error(`Plugin '${args.name}' not found`); + + if (args.config) { + const current = JSON.parse(plugin.config || "{}"); + const merged = { ...current, ...args.config }; + updatePluginConfig(args.name, merged); + return { success: true, config: merged }; + } + + return { + config: JSON.parse(plugin.config || "{}"), + configSchema: JSON.parse(plugin.configSchema || "{}"), + }; + }, + }, + + { + name: "plugin_executions", + description: "View plugin execution history (from skill_executions table).", + inputSchema: z.object({ + name: z.string().optional().describe("Filter by plugin name"), + limit: z.number().min(1).max(100).default(20).describe("Max results to return"), + }), + handler: async (args: { name?: string; limit?: number }) => { + // Plugin executions are tracked via the skills system + const { skillExecutor } = await import("../../../src/lib/skills/executor"); + const executions = skillExecutor.listExecutions(undefined, args.limit || 20); + return { executions }; + }, + }, + + { + name: "plugin_scan", + description: "Scan the plugin directory for new plugins and sync with DB.", + inputSchema: z.object({}), + handler: async () => { + const result = await pluginManager.scan(); + return { + discovered: result.discovered, + errors: result.errors, + }; + }, + }, +]; diff --git a/src/app/api/plugins/[name]/activate/route.ts b/src/app/api/plugins/[name]/activate/route.ts new file mode 100644 index 00000000000..d86c32f379d --- /dev/null +++ b/src/app/api/plugins/[name]/activate/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * POST /api/plugins/[name]/activate — Activate a plugin + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + + try { + await pluginManager.activate(name); + return NextResponse.json( + { success: true, message: `Plugin '${name}' activated` }, + { headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } +} diff --git a/src/app/api/plugins/[name]/config/route.ts b/src/app/api/plugins/[name]/config/route.ts new file mode 100644 index 00000000000..5a9100c7cc9 --- /dev/null +++ b/src/app/api/plugins/[name]/config/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { z } from "zod"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /api/plugins/[name]/config — Get plugin configuration + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + const plugin = getPluginByName(name); + + if (!plugin) { + return NextResponse.json( + { error: `Plugin '${name}' not found` }, + { status: 404, headers: CORS_HEADERS } + ); + } + + return NextResponse.json( + { + config: JSON.parse(plugin.config || "{}"), + configSchema: JSON.parse(plugin.configSchema || "{}"), + }, + { headers: CORS_HEADERS } + ); +} + +/** + * PUT /api/plugins/[name]/config — Update plugin configuration + */ +export async function PUT(request: NextRequest, { params }: { params: Promise<{ name: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + const body = await request.json(); + + const schema = z.object({ + config: z.record(z.string(), z.unknown()), + }); + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.issues }, + { status: 400, headers: CORS_HEADERS } + ); + } + + const plugin = getPluginByName(name); + if (!plugin) { + return NextResponse.json( + { error: `Plugin '${name}' not found` }, + { status: 404, headers: CORS_HEADERS } + ); + } + + // Validate config values against configSchema if defined + const configSchema = JSON.parse(plugin.configSchema || "{}"); + if (Object.keys(configSchema).length > 0) { + for (const [key, value] of Object.entries(parsed.data.config)) { + const field = configSchema[key]; + if (!field) continue; // Allow extra keys + if (field.type === "number" && typeof value === "number") { + if (field.min !== undefined && value < field.min) { + return NextResponse.json( + { error: `Config '${key}' must be >= ${field.min}` }, + { status: 400, headers: CORS_HEADERS } + ); + } + if (field.max !== undefined && value > field.max) { + return NextResponse.json( + { error: `Config '${key}' must be <= ${field.max}` }, + { status: 400, headers: CORS_HEADERS } + ); + } + } + if (field.type === "select" && field.enum && !field.enum.includes(String(value))) { + return NextResponse.json( + { error: `Config '${key}' must be one of: ${field.enum.join(", ")}` }, + { status: 400, headers: CORS_HEADERS } + ); + } + } + } + + updatePluginConfig(name, parsed.data.config); + + return NextResponse.json( + { success: true, config: parsed.data.config }, + { headers: CORS_HEADERS } + ); +} diff --git a/src/app/api/plugins/[name]/deactivate/route.ts b/src/app/api/plugins/[name]/deactivate/route.ts new file mode 100644 index 00000000000..5e68edf60ef --- /dev/null +++ b/src/app/api/plugins/[name]/deactivate/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * POST /api/plugins/[name]/deactivate — Deactivate a plugin + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + + try { + await pluginManager.deactivate(name); + return NextResponse.json( + { success: true, message: `Plugin '${name}' deactivated` }, + { headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } +} diff --git a/src/app/api/plugins/[name]/route.ts b/src/app/api/plugins/[name]/route.ts new file mode 100644 index 00000000000..d7dd54a16e3 --- /dev/null +++ b/src/app/api/plugins/[name]/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { getPluginByName } from "@/lib/db/plugins"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /api/plugins/[name] — Get plugin details + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + const plugin = getPluginByName(name); + + if (!plugin) { + return NextResponse.json( + { error: `Plugin '${name}' not found` }, + { status: 404, headers: CORS_HEADERS } + ); + } + + return NextResponse.json( + { + plugin: { + id: plugin.id, + name: plugin.name, + version: plugin.version, + description: plugin.description, + author: plugin.author, + license: plugin.license, + main: plugin.main, + source: plugin.source, + tags: JSON.parse(plugin.tags || "[]"), + status: plugin.status, + enabled: plugin.enabled === 1, + config: JSON.parse(plugin.config || "{}"), + configSchema: JSON.parse(plugin.configSchema || "{}"), + hooks: JSON.parse(plugin.hooks || "[]"), + permissions: JSON.parse(plugin.permissions || "[]"), + pluginDir: plugin.pluginDir, + errorMessage: plugin.errorMessage, + installedAt: plugin.installedAt, + updatedAt: plugin.updatedAt, + activatedAt: plugin.activatedAt, + }, + }, + { headers: CORS_HEADERS } + ); +} + +/** + * DELETE /api/plugins/[name] — Uninstall a plugin + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const { name } = await params; + + try { + await pluginManager.uninstall(name); + return NextResponse.json( + { success: true, message: `Plugin '${name}' uninstalled` }, + { headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } +} diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts new file mode 100644 index 00000000000..12d8a954f58 --- /dev/null +++ b/src/app/api/plugins/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { listPlugins } from "@/lib/db/plugins"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { z } from "zod"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /api/plugins — List all installed plugins + */ +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const url = new URL(request.url); + const status = url.searchParams.get("status") as any; + + try { + const plugins = listPlugins(status || undefined); + return NextResponse.json({ plugins: plugins.map(formatPlugin) }, { headers: CORS_HEADERS }); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 500, headers: CORS_HEADERS }); + } +} + +/** + * POST /api/plugins — Install a plugin from a local path + */ +export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + const body = await request.json(); + const schema = z.object({ + path: z.string().min(1), + }); + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.issues }, + { status: 400, headers: CORS_HEADERS } + ); + } + + try { + const plugin = await pluginManager.install(parsed.data.path); + return NextResponse.json( + { plugin: formatPlugin(plugin) }, + { status: 201, headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } +} + +function formatPlugin(row: any) { + return { + id: row.id, + name: row.name, + version: row.version, + description: row.description, + author: row.author, + status: row.status, + enabled: row.enabled === 1, + hooks: JSON.parse(row.hooks || "[]"), + permissions: JSON.parse(row.permissions || "[]"), + installedAt: row.installedAt, + updatedAt: row.updatedAt, + activatedAt: row.activatedAt, + }; +} diff --git a/src/app/api/plugins/scan/route.ts b/src/app/api/plugins/scan/route.ts new file mode 100644 index 00000000000..70510188b7c --- /dev/null +++ b/src/app/api/plugins/scan/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { pluginManager } from "@/lib/plugins/manager"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * POST /api/plugins/scan — Scan plugin directory for new plugins + */ +export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { + const result = await pluginManager.scan(); + return NextResponse.json( + { discovered: result.discovered, errors: result.errors }, + { headers: CORS_HEADERS } + ); + } catch (err: any) { + return NextResponse.json({ error: err.message }, { status: 500, headers: CORS_HEADERS }); + } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d2add6bca12..a75806abebf 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -7288,5 +7288,41 @@ "policyLabel": "السياسة:", "resetIn": "إعادة تعيين في", "quotaTotal": "المجموع" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index e8060136dc2..a179837ef2d 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -7288,5 +7288,41 @@ "policyLabel": "Siyasət:", "resetIn": "sıfırlayın", "quotaTotal": "cəmi" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 454e8543119..a0b3c950b3a 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -7288,5 +7288,41 @@ "policyLabel": "Политика:", "resetIn": "нулиране в", "quotaTotal": "общо" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index e726ebb1dd8..f5b9f18ed9c 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -7288,5 +7288,41 @@ "policyLabel": "নীতি:", "resetIn": "রিসেট করুন", "quotaTotal": "মোট" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 64a937e43b5..64b16738d3c 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -7288,5 +7288,41 @@ "policyLabel": "Zásady:", "resetIn": "resetovat", "quotaTotal": "celkem" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index a6e03fdc730..e3a4e0eff0f 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politik:", "resetIn": "nulstilles ind", "quotaTotal": "i alt" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index fd306048321..bd3c426d430 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -7288,5 +7288,41 @@ "policyLabel": "Richtlinie:", "resetIn": "Zurücksetzung in", "quotaTotal": "Gesamt" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6536c49d1e8..1b6dd03b2a2 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7295,5 +7295,41 @@ "policyLabel": "Policy:", "resetIn": "reset in", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 2e877fc0113..8471e8cd566 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -7288,5 +7288,41 @@ "policyLabel": "Política:", "resetIn": "restablecer en", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index a69ded5c11b..bb99bdb8d68 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -7288,5 +7288,41 @@ "policyLabel": "سیاست:", "resetIn": "بازنشانی در", "quotaTotal": "کل" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 927e7ee30d8..f6623e067cb 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -7288,5 +7288,41 @@ "policyLabel": "Käytäntö:", "resetIn": "nollata sisään", "quotaTotal": "yhteensä" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 79ccc194eb9..4e5319ab067 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politique :", "resetIn": "réinitialiser dans", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 363e6bdda09..a964ff72c8e 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -7288,5 +7288,41 @@ "policyLabel": "નીતિ:", "resetIn": "માં રીસેટ કરો", "quotaTotal": "કુલ" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 72993dbe954..75c30cc4bd0 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -7288,5 +7288,41 @@ "policyLabel": "מדיניות:", "resetIn": "לאפס פנימה", "quotaTotal": "סך הכל" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 7de1e0112cf..047fecbf2d6 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -7288,5 +7288,41 @@ "policyLabel": "नीति:", "resetIn": "में रीसेट करें", "quotaTotal": "कुल" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 49f1ac54799..132779fe5d2 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -7288,5 +7288,41 @@ "policyLabel": "Szabályzat:", "resetIn": "visszaállítani", "quotaTotal": "összesen" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 8603d2a8a22..38cfc2ff0e9 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -7288,5 +7288,41 @@ "policyLabel": "Kebijakan:", "resetIn": "ulang masuk", "quotaTotal": "jumlah" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index bea8d8dc160..54b0175c714 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -7288,5 +7288,41 @@ "policyLabel": "Kebijakan:", "resetIn": "ulang masuk", "quotaTotal": "jumlah" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 71d4226e076..f346240dba8 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politica:", "resetIn": "reimpostato", "quotaTotal": "totale" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c46830e8ca2..cf03760967d 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -7288,5 +7288,41 @@ "policyLabel": "ポリシー:", "resetIn": "リセットして", "quotaTotal": "合計" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ad7706730f6..f9787ae2d66 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -7288,5 +7288,41 @@ "policyLabel": "정책:", "resetIn": "재설정", "quotaTotal": "합계" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 95b12dfae58..f32bdffd716 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -7288,5 +7288,41 @@ "policyLabel": "धोरण:", "resetIn": "मध्ये रीसेट करा", "quotaTotal": "एकूण" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 228396d6a60..5e0051c6545 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -7288,5 +7288,41 @@ "policyLabel": "Dasar:", "resetIn": "set semula masuk", "quotaTotal": "jumlah" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index ceb077a56b4..02df59074ef 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -7288,5 +7288,41 @@ "policyLabel": "Beleid:", "resetIn": "opnieuw instellen", "quotaTotal": "totaal" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index e2f741185cb..456a05d145f 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -7288,5 +7288,41 @@ "policyLabel": "Retningslinjer:", "resetIn": "tilbakestille inn", "quotaTotal": "totalt" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 5413e5010da..47fd1f2267d 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -7288,5 +7288,41 @@ "policyLabel": "Patakaran:", "resetIn": "i-reset sa", "quotaTotal": "kabuuan" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 28d4f603900..528656674e4 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -7288,5 +7288,41 @@ "policyLabel": "Polityka:", "resetIn": "zresetuj w", "quotaTotal": "łącznie" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index d4282a4d0a7..a9c8cb1b883 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -347,9 +347,9 @@ "keyCreatedNote": "Copie e armazene esta chave agora — ela não será mostrada novamente.", "done": "Pronto", "savePermissions": "Salvar Permissões", - "endpointRestrictions": "__MISSING__:Allowed Endpoints", - "allEndpointsAllowed": "__MISSING__:This key can access all API endpoints.", - "endpointsRestricted": "__MISSING__:Restricted to {count} endpoint{count, plural, one {} other {s}}.", + "endpointRestrictions": "Endpoints Permitidos", + "allEndpointsAllowed": "Esta chave pode acessar todos os endpoints de API.", + "endpointsRestricted": "Restrito a {count} endpoint{count, plural, one {} other {s}}.", "autoResolve": "Auto-Resolve", "autoResolveDesc": "Resolve automaticamente nomes ambíguos de modelo para o provedor nativo desta API key.", "keyActive": "Chave Ativa", @@ -437,10 +437,7 @@ "filterTypeRestricted": "Restrita", "shownOf": "{shown} de {total} exibidas", "emptyFilterTitle": "Nenhuma chave corresponde aos filtros", - "emptyFilterClear": "Limpar filtros", - "endpointRestrictions": "Endpoints Permitidos", - "allEndpointsAllowed": "Esta chave pode acessar todos os endpoints de API.", - "endpointsRestricted": "Restrito a {count} endpoint{count, plural, one {} other {s}}." + "emptyFilterClear": "Limpar filtros" }, "auditLog": { "title": "Log de Auditoria", @@ -7291,5 +7288,41 @@ "chatIdHint": "O identificador numérico único ou nome de usuário público do chat/canal.", "tutorial": "Tutorial" } + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 980b42953aa..e64052bc39c 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -7288,5 +7288,41 @@ "policyLabel": "Política:", "resetIn": "redefinir em", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 86c028f3ce4..ef75e5d7357 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politica:", "resetIn": "resetează", "quotaTotal": "total" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 0f444aa5510..0c8075816fb 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -7288,5 +7288,41 @@ "policyLabel": "Политика:", "resetIn": "сброс в", "quotaTotal": "всего" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index a4505e452a6..1604ebfecc5 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -7288,5 +7288,41 @@ "policyLabel": "Pravidlá:", "resetIn": "resetovať v", "quotaTotal": "celkom" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index d9da4bd3183..5c05ae17290 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -7288,5 +7288,41 @@ "policyLabel": "Policy:", "resetIn": "återställ in", "quotaTotal": "totalt" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 6183d59ca71..d7c29320233 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -7288,5 +7288,41 @@ "policyLabel": "Sera:", "resetIn": "weka upya", "quotaTotal": "jumla" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 06a56309be8..287238bc6ef 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -7288,5 +7288,41 @@ "policyLabel": "கொள்கை:", "resetIn": "மீட்டமை", "quotaTotal": "மொத்தம்" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 6c9eb797773..e55f2aed112 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -7288,5 +7288,41 @@ "policyLabel": "విధానం:", "resetIn": "రీసెట్ చేయండి", "quotaTotal": "మొత్తం" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index afccf15c340..439dcec4a93 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -7288,5 +7288,41 @@ "policyLabel": "นโยบาย:", "resetIn": "รีเซ็ตใน", "quotaTotal": "ทั้งหมด" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index e5be8910315..d21a36f54cf 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -7288,5 +7288,41 @@ "policyLabel": "Politika:", "resetIn": "sıfırlamak", "quotaTotal": "toplam" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 1fa9bcfff33..0e7b99daf3d 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -7288,5 +7288,41 @@ "policyLabel": "Політика:", "resetIn": "скинути в", "quotaTotal": "всього" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index dbdece1d6d2..e1b025cdf7b 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -7288,5 +7288,41 @@ "policyLabel": "پالیسی:", "resetIn": "میں دوبارہ ترتیب دیں", "quotaTotal": "کل" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 865059d2498..f251af62b9e 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -7288,5 +7288,41 @@ "policyLabel": "Chính sách:", "resetIn": "đặt lại trong", "quotaTotal": "tổng cộng" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 4824b775fac..abd9d56f8f0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -7288,5 +7288,41 @@ "policyLabel": "政策:", "resetIn": "重置于", "quotaTotal": "总计" + }, + "plugins": { + "title": "Plugins", + "description": "Install and manage plugins for extending OmniRoute functionality", + "loading": "Loading plugins...", + "scanning": "Scanning...", + "scanForPlugins": "Scan for Plugins", + "noPlugins": "No plugins installed", + "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", + "activate": "Activate", + "deactivate": "Deactivate", + "uninstall": "Uninstall", + "uninstallConfirm": "Uninstall plugin \"{name}\"?", + "pluginScanComplete": "Plugin scan complete", + "pluginScanFailed": "Plugin scan failed", + "activated": "{name} activated", + "deactivated": "{name} deactivated", + "activateFailed": "Failed to activate {name}", + "deactivateFailed": "Failed to deactivate {name}", + "uninstalled": "{name} uninstalled", + "uninstallFailed": "Failed to uninstall {name}", + "configure": "Configure: {name}", + "configurePlugin": "Configure", + "noConfigSettings": "This plugin has no configurable settings.", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "configurationSaved": "Configuration saved", + "saveConfigurationFailed": "Failed to save configuration", + "pluginNotFound": "Plugin not found", + "version": "Version", + "author": "Author", + "description_label": "Description", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "hooks": "Hooks" } } diff --git a/src/lib/db/migrations/059_create_plugins.sql b/src/lib/db/migrations/059_create_plugins.sql new file mode 100644 index 00000000000..c4bf8e3f030 --- /dev/null +++ b/src/lib/db/migrations/059_create_plugins.sql @@ -0,0 +1,31 @@ +-- 059: Plugin system tables +-- WordPress-style plugin management with lifecycle tracking + +CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + version TEXT NOT NULL DEFAULT '1.0.0', + description TEXT, + author TEXT, + license TEXT DEFAULT 'MIT', + main TEXT NOT NULL DEFAULT 'index.js', + source TEXT NOT NULL DEFAULT 'local', + tags TEXT DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'installed' + CHECK (status IN ('installed', 'active', 'inactive', 'error')), + enabled INTEGER NOT NULL DEFAULT 0, + manifest TEXT NOT NULL, + config TEXT DEFAULT '{}', + config_schema TEXT DEFAULT '{}', + hooks TEXT DEFAULT '[]', + permissions TEXT DEFAULT '[]', + plugin_dir TEXT NOT NULL, + error_message TEXT, + installed_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + activated_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins(status); +CREATE INDEX IF NOT EXISTS idx_plugins_enabled ON plugins(enabled); +CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name); diff --git a/src/lib/db/migrations/076_create_plugins.sql b/src/lib/db/migrations/076_create_plugins.sql new file mode 100644 index 00000000000..c4bf8e3f030 --- /dev/null +++ b/src/lib/db/migrations/076_create_plugins.sql @@ -0,0 +1,31 @@ +-- 059: Plugin system tables +-- WordPress-style plugin management with lifecycle tracking + +CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + version TEXT NOT NULL DEFAULT '1.0.0', + description TEXT, + author TEXT, + license TEXT DEFAULT 'MIT', + main TEXT NOT NULL DEFAULT 'index.js', + source TEXT NOT NULL DEFAULT 'local', + tags TEXT DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'installed' + CHECK (status IN ('installed', 'active', 'inactive', 'error')), + enabled INTEGER NOT NULL DEFAULT 0, + manifest TEXT NOT NULL, + config TEXT DEFAULT '{}', + config_schema TEXT DEFAULT '{}', + hooks TEXT DEFAULT '[]', + permissions TEXT DEFAULT '[]', + plugin_dir TEXT NOT NULL, + error_message TEXT, + installed_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + activated_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins(status); +CREATE INDEX IF NOT EXISTS idx_plugins_enabled ON plugins(enabled); +CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name); diff --git a/src/lib/db/plugins.ts b/src/lib/db/plugins.ts new file mode 100644 index 00000000000..e13aee1e42c --- /dev/null +++ b/src/lib/db/plugins.ts @@ -0,0 +1,195 @@ +/** + * Plugin DB module — CRUD operations for the plugins table. + * + * @module db/plugins + */ + +import { getDbInstance } from "./core"; +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("DB_PLUGINS"); + +// ── Types ── + +export interface PluginRow { + id: string; + name: string; + version: string; + description: string | null; + author: string | null; + license: string; + main: string; + source: string; + tags: string; // JSON array + status: "installed" | "active" | "inactive" | "error"; + enabled: number; // 0 | 1 + manifest: string; // JSON + config: string; // JSON + configSchema: string; // JSON + hooks: string; // JSON array + permissions: string; // JSON array + pluginDir: string; + errorMessage: string | null; + installedAt: string; + updatedAt: string; + activatedAt: string | null; +} + +export interface PluginCreateInput { + id: string; + name: string; + version: string; + description?: string; + author?: string; + license?: string; + main: string; + source?: string; + tags?: string[]; + status?: PluginRow["status"]; + enabled?: boolean; + manifest: Record; + config?: Record; + configSchema?: Record; + hooks?: string[]; + permissions?: string[]; + pluginDir: string; +} + +// ── Helpers ── + +function rowToPlugin(row: any): PluginRow { + return { + id: row.id, + name: row.name, + version: row.version, + description: row.description, + author: row.author, + license: row.license, + main: row.main, + source: row.source, + tags: row.tags, + status: row.status, + enabled: row.enabled, + manifest: row.manifest, + config: row.config, + configSchema: row.config_schema, + hooks: row.hooks, + permissions: row.permissions, + pluginDir: row.plugin_dir, + errorMessage: row.error_message, + installedAt: row.installed_at, + updatedAt: row.updated_at, + activatedAt: row.activated_at, + }; +} + +// ── CRUD ── + +export function insertPlugin(input: PluginCreateInput): PluginRow { + const db = getDbInstance(); + const now = new Date().toISOString(); + + db.prepare( + `INSERT INTO plugins ( + id, name, version, description, author, license, main, source, tags, + status, enabled, manifest, config, config_schema, hooks, permissions, + plugin_dir, installed_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + input.id, + input.name, + input.version, + input.description ?? null, + input.author ?? null, + input.license ?? "MIT", + input.main, + input.source ?? "local", + JSON.stringify(input.tags ?? []), + input.status ?? "installed", + input.enabled ? 1 : 0, + JSON.stringify(input.manifest), + JSON.stringify(input.config ?? {}), + JSON.stringify(input.configSchema ?? {}), + JSON.stringify(input.hooks ?? []), + JSON.stringify(input.permissions ?? []), + input.pluginDir, + now, + now + ); + + log.info("plugin.inserted", { id: input.id, name: input.name }); + const plugin = getPluginByName(input.name); + if (!plugin) { + throw new Error(`Failed to retrieve plugin '${input.name}' after insertion`); + } + return plugin; +} + +export function getPluginById(id: string): PluginRow | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM plugins WHERE id = ?").get(id); + return row ? rowToPlugin(row) : null; +} + +export function getPluginByName(name: string): PluginRow | null { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM plugins WHERE name = ?").get(name); + return row ? rowToPlugin(row) : null; +} + +export function listPlugins(status?: PluginRow["status"]): PluginRow[] { + const db = getDbInstance(); + const rows = status + ? db.prepare("SELECT * FROM plugins WHERE status = ? ORDER BY name").all(status) + : db.prepare("SELECT * FROM plugins ORDER BY name").all(); + return rows.map(rowToPlugin); +} + +export function updatePluginStatus( + name: string, + status: PluginRow["status"], + errorMessage?: string +): boolean { + const db = getDbInstance(); + const now = new Date().toISOString(); + const activatedAt = status === "active" ? now : null; + + const result = db + .prepare( + `UPDATE plugins SET status = ?, enabled = ?, error_message = ?, + updated_at = ?, activated_at = COALESCE(?, activated_at) + WHERE name = ?` + ) + .run(status, status === "active" ? 1 : 0, errorMessage ?? null, now, activatedAt, name); + + if (result.changes > 0) { + log.info("plugin.status_updated", { name, status }); + } + return result.changes > 0; +} + +export function updatePluginConfig(name: string, config: Record): boolean { + const db = getDbInstance(); + const now = new Date().toISOString(); + + const result = db + .prepare("UPDATE plugins SET config = ?, updated_at = ? WHERE name = ?") + .run(JSON.stringify(config), now, name); + + return result.changes > 0; +} + +export function deletePlugin(name: string): boolean { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM plugins WHERE name = ?").run(name); + if (result.changes > 0) { + log.info("plugin.deleted", { name }); + } + return result.changes > 0; +} + +export function pluginExists(name: string): boolean { + const db = getDbInstance(); + const row = db.prepare("SELECT 1 FROM plugins WHERE name = ?").get(name); + return !!row; +} diff --git a/src/lib/discovery/index.ts b/src/lib/discovery/index.ts index 369a67a9f4e..74b0aba0df4 100644 --- a/src/lib/discovery/index.ts +++ b/src/lib/discovery/index.ts @@ -1,17 +1,26 @@ /** - * Discovery Service — Automated Provider Discovery + * Plugin Discovery Tool — Automated provider scanning. * - * Stub implementation for Phase 1. Scans LLM providers for free/unlimited - * access methods and reports findings. + * Scans LLM providers for free/unlimited access methods and reports findings. + * Integrated into OmniRoute as an opt-in service (default off). * - * Default: disabled (opt-in via settings) + * Phase 1: Stub with types and config. + * Phase 2: Full scanning engine. + * + * @module discovery */ +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("DISCOVERY"); + +// ── Types ── + export interface DiscoveryConfig { enabled: boolean; - scanInterval: number; + scanInterval: number; // ms between scans (default: 24h) maxConcurrentScans: number; - targetProviders: string[]; + targetProviders: string[]; // empty = scan all known notificationWebhook?: string; } @@ -31,16 +40,19 @@ export interface DiscoveryResult { verifiedAt?: string; } -const DEFAULT_CONFIG: DiscoveryConfig = { +// ── Default Config ── + +export const DEFAULT_DISCOVERY_CONFIG: DiscoveryConfig = { enabled: false, scanInterval: 24 * 60 * 60 * 1000, // 24 hours maxConcurrentScans: 3, targetProviders: [], }; +// ── Probe ── + /** * Probe a single URL for API availability. - * Returns basic endpoint info if accessible. */ export async function probeEndpoint( url: string, @@ -62,16 +74,20 @@ export async function probeEndpoint( } } +// ── Scan ── + /** * Scan a provider for free access methods. - * Stub implementation — returns placeholder data. + * Phase 1 stub — returns placeholder. Phase 2 will implement real scanning. */ export async function scanProvider( providerId: string, _config: Partial = {} ): Promise { - // Phase 1 stub — returns empty results - // Phase 2 will implement actual scanning logic + log.info("discovery.scan_stub", { + providerId, + note: "Phase 1 stub — implement real scanning in Phase 2", + }); return [ { providerId, @@ -86,20 +102,20 @@ export async function scanProvider( ]; } +// ── Results ── + /** - * Get discovery results from the database. - * Stub implementation — returns empty array. + * Get discovery results. Phase 1 stub — returns empty array. */ export function getDiscoveryResults(_providerId?: string): DiscoveryResult[] { - // Phase 1 stub — Phase 2 will query SQLite return []; } +// ── Config ── + /** * Check if discovery service is enabled. */ export function isDiscoveryEnabled(): boolean { - return DEFAULT_CONFIG.enabled; + return DEFAULT_DISCOVERY_CONFIG.enabled; } - -export { DEFAULT_CONFIG }; diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3ac0ca78477..9f3c51b906a 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -527,3 +527,16 @@ export type { UpsertTokenLimitInput, TokenWindowState, } from "./db/tokenLimits"; + +export { + insertPlugin, + getPluginById, + getPluginByName, + listPlugins, + updatePluginStatus, + updatePluginConfig, + deletePlugin, + pluginExists, +} from "./db/plugins"; + +export type { PluginRow, PluginCreateInput } from "./db/plugins"; diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts new file mode 100644 index 00000000000..8bbcf7b357d --- /dev/null +++ b/src/lib/plugins/hooks.ts @@ -0,0 +1,265 @@ +/** + * Custom hook registry — event-driven plugin hook system. + * + * Plugins can register handlers for any OmniRoute event. Built-in events + * cover the full request lifecycle plus routing, rate limiting, and errors. + * + * @module plugins/hooks + */ + +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("PLUGIN_HOOKS"); + +// ── Types ── + +export type BlockingHookResult = { + blocked?: boolean; + response?: unknown; + body?: unknown; + metadata?: Record; +}; + +export type HookHandler = ( + payload: unknown +) => void | Promise | BlockingHookResult | Promise; + +export interface HookRegistration { + pluginName: string; + handler: HookHandler; + priority: number; +} + +// ── Built-in events ── + +export const BUILTIN_EVENTS = [ + "onRequest", + "onResponse", + "onError", + "onModelSelect", + "onComboResolve", + "onRateLimit", + "onQuotaExhaust", + "onProviderError", + "onStreamStart", + "onStreamEnd", +] as const; + +export type BuiltinEvent = (typeof BUILTIN_EVENTS)[number]; + +// ── Registry ── + +const hooks: Map = new Map(); + +/** + * Register a handler for an event. + */ +export function registerHook( + event: string, + pluginName: string, + handler: HookHandler, + priority: number = 100 +): void { + if (!hooks.has(event)) { + hooks.set(event, []); + } + const list = hooks.get(event)!; + + // Prevent duplicate registration + if (list.some((r) => r.pluginName === pluginName && r.handler === handler)) { + return; + } + + list.push({ pluginName, handler, priority }); + list.sort((a, b) => a.priority - b.priority); + + log.info("hook.registered", { event, pluginName, priority }); +} + +/** + * Unregister all handlers for a plugin. + */ +export function unregisterHooks(pluginName: string): void { + for (const [event, list] of hooks.entries()) { + const before = list.length; + const filtered = list.filter((r) => r.pluginName !== pluginName); + if (filtered.length !== before) { + hooks.set(event, filtered); + log.info("hook.unregistered", { event, pluginName, removed: before - filtered.length }); + } + } +} + +/** + * Unregister a specific handler. + */ +export function unregisterHook(event: string, pluginName: string): void { + const list = hooks.get(event); + if (!list) return; + const before = list.length; + const filtered = list.filter((r) => r.pluginName !== pluginName); + hooks.set(event, filtered); + if (before !== filtered.length) { + log.info("hook.unregistered", { event, pluginName }); + } +} + +/** + * Emit an event — fire all registered handlers. + * Handler errors are logged but don't block other handlers. + */ +export async function emitHook(event: string, payload: unknown): Promise { + const list = hooks.get(event); + if (!list || list.length === 0) return; + + for (const reg of list) { + try { + await reg.handler(payload); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log.error("hook.handler_error", { + event, + pluginName: reg.pluginName, + error: message, + }); + } + } +} + +/** + * Emit a blocking event — fire handlers with body/metadata chaining. + * Returns blocking result from the first handler that blocks, or merged body/metadata. + * Used for onRequest and onResponse where plugins can modify or block the request. + */ +export async function emitHookBlocking( + event: string, + payload: unknown +): Promise<{ + blocked?: boolean; + response?: unknown; + body?: unknown; + metadata?: Record; +}> { + const list = hooks.get(event) || []; + const ctx = (payload || {}) as Record; + let mergedBody: unknown = ctx.body; + let mergedMetadata: Record = (ctx.metadata as Record) || {}; + + for (const reg of list) { + try { + const result = await reg.handler(payload); + if (result && typeof result === "object") { + if ("body" in result) mergedBody = (result as Record).body; + if ("metadata" in result) + mergedMetadata = { + ...mergedMetadata, + ...(((result as Record).metadata as Record) || {}), + }; + if ("blocked" in result && (result as BlockingHookResult).blocked) { + return { + ...result, + body: (result as BlockingHookResult).body ?? mergedBody, + metadata: { ...mergedMetadata, ...((result as BlockingHookResult).metadata || {}) }, + }; + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log.error("hook.blocking_handler_error", { + event, + pluginName: reg.pluginName, + error: message, + }); + } + } + return { body: mergedBody, metadata: mergedMetadata }; +} + +// ── Lifecycle wrappers (for chatCore.ts convenience) ── + +export interface PluginContext { + requestId: string; + body: unknown; + model: string; + provider: string; + apiKeyInfo?: unknown; + metadata: Record; +} + +export interface PluginResult { + blocked?: boolean; + response?: unknown; + body?: unknown; + metadata?: Record; +} + +// ── Plugin interface (for loader/manager compatibility) ── + +export interface Plugin { + name: string; + priority?: number; + enabled?: boolean; + onRequest?: (ctx: PluginContext) => Promise | PluginResult | void; + onResponse?: (ctx: PluginContext, response: unknown) => Promise | unknown | void; + onError?: (ctx: PluginContext, error: Error) => Promise | unknown | void; +} + +/** + * Run onRequest hooks — blocking. Plugins can modify body/metadata or block with 403. + */ +export async function runOnRequest(ctx: PluginContext): Promise { + return emitHookBlocking("onRequest", ctx); +} + +/** + * Run onResponse hooks — chains response through plugins. Each plugin can modify the response. + */ +export async function runOnResponse(ctx: PluginContext, response: unknown): Promise { + let currentResponse = response; + const list = hooks.get("onResponse") || []; + for (const reg of list) { + try { + const result = await reg.handler({ ...ctx, response: currentResponse }); + if ( + result !== undefined && + result !== null && + typeof result === "object" && + "response" in result + ) { + currentResponse = (result as { response: unknown }).response; + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log.error("hook.response_handler_error", { pluginName: reg.pluginName, error: message }); + } + } + return currentResponse; +} + +/** + * Run onError hooks — fire-and-forget notification. + */ +export async function runOnError(ctx: PluginContext, error: Error): Promise { + await emitHook("onError", { ...ctx, error }); +} + +/** + * Get all registered hooks for an event. + */ +export function getHooks(event: string): HookRegistration[] { + return hooks.get(event) ?? []; +} + +/** + * Get all events that have registered handlers. + */ +export function getActiveEvents(): string[] { + return [...hooks.entries()].filter(([, list]) => list.length > 0).map(([event]) => event); +} + +/** + * Reset all hooks (for testing). + */ +export function resetHooks(): void { + hooks.clear(); +} diff --git a/src/lib/plugins/index.ts b/src/lib/plugins/index.ts index 54de2269a41..43e7dd3dc20 100644 --- a/src/lib/plugins/index.ts +++ b/src/lib/plugins/index.ts @@ -15,6 +15,10 @@ // ── Types ── +import { logger } from "../../../open-sse/utils/logger.ts"; + +const log = logger("PLUGINS"); + export interface PluginContext { /** Unique request ID */ requestId: string; @@ -75,9 +79,11 @@ export function registerPlugin(plugin: Plugin): void { _plugins.push(plugin); _plugins.sort((a, b) => (a.priority || 100) - (b.priority || 100)); - console.log( - `[Plugins] Registered "${plugin.name}" (priority: ${plugin.priority}, enabled: ${plugin.enabled})` - ); + log.info("plugin.registered", { + name: plugin.name, + priority: plugin.priority, + enabled: plugin.enabled, + }); } /** @@ -139,7 +145,7 @@ export async function runOnRequest( const result = await plugin.onRequest(currentCtx); if (result) { if (result.blocked) { - console.log(`[Plugins] Request blocked by "${plugin.name}"`); + log.info("plugin.request_blocked", { name: plugin.name }); return { blocked: true, response: result.response, ctx: currentCtx }; } if (result.body) currentCtx.body = result.body; @@ -148,7 +154,10 @@ export async function runOnRequest( } } } catch (err: any) { - console.error(`[Plugins] onRequest error in "${plugin.name}": ${err.message}`); + log.error("plugin.onRequest_error", { + name: plugin.name, + error: err instanceof Error ? err.message : String(err), + }); // Plugin errors don't block the pipeline by default } } @@ -171,7 +180,10 @@ export async function runOnResponse(ctx: PluginContext, response: any): Promise< currentResponse = modified; } } catch (err: any) { - console.error(`[Plugins] onResponse error in "${plugin.name}": ${err.message}`); + log.error("plugin.onResponse_error", { + name: plugin.name, + error: err instanceof Error ? err.message : String(err), + }); } } @@ -189,11 +201,14 @@ export async function runOnError(ctx: PluginContext, error: Error): Promise void; +} + +// ── Plugin host script (runs in child process via fork) ── +// Uses process.send()/process.on("message") — NOT worker_threads. +// Written as .mjs to force ESM execution regardless of package.json. + +const PLUGIN_HOST_SCRIPT = ` +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); + +const pluginPath = process.argv[2]; +const plugin = await import(pluginPath); +const exports = plugin.default || plugin; + +// Send ready signal +process.send({ type: "ready", hooks: Object.keys(exports).filter(k => typeof exports[k] === "function") }); + +// Handle messages from parent +process.on("message", async (msg) => { + if (msg.type === "call") { + try { + const handler = exports[msg.hook]; + if (typeof handler !== "function") { + process.send({ type: "result", id: msg.id, error: "Hook not found" }); + return; + } + const result = await handler(msg.payload); + process.send({ type: "result", id: msg.id, result }); + } catch (err) { + process.send({ type: "result", id: msg.id, error: err.message }); + } + } +}); +`; + +/** + * Load a plugin in an isolated child process. + * Returns the plugin interface with hooks that communicate via IPC. + */ +export async function loadPlugin( + entryPoint: string, + manifest: PluginManifestWithDefaults +): Promise { + const permissions = manifest.requires.permissions; + const hostId = randomUUID(); + // .mjs extension forces ESM execution + const hostScriptPath = join(tmpdir(), `omniroute-plugin-host-${hostId}.mjs`); + + await writeFile(hostScriptPath, PLUGIN_HOST_SCRIPT, "utf-8"); + + const env: Record = { + ...getFilteredEnv(permissions), + PLUGIN_ENTRY: entryPoint, + PLUGIN_NAME: manifest.name, + }; + + const child = fork(hostScriptPath, [entryPoint], { + env, + stdio: ["pipe", "pipe", "pipe", "ipc"], + execArgv: ["--no-warnings"], + }); + + // Track pending calls with timeout support + const pendingCalls: Map< + string, + { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + timer: ReturnType; + } + > = new Map(); + let callCounter = 0; + + child.on( + "message", + (msg: { type: string; id?: string; hooks?: string[]; result?: unknown; error?: string }) => { + if (msg.type === "ready") { + log.info("loader.process_ready", { name: manifest.name, hooks: msg.hooks }); + } else if (msg.type === "result" && msg.id) { + const pending = pendingCalls.get(msg.id); + if (pending) { + clearTimeout(pending.timer); + pendingCalls.delete(msg.id); + if (msg.error) { + pending.reject(new Error(msg.error)); + } else { + pending.resolve(msg.result); + } + } + } + } + ); + + child.on("error", (err) => { + log.error("loader.process_error", { name: manifest.name, error: err.message }); + }); + + child.on("exit", (code) => { + log.info("loader.process_exit", { name: manifest.name, code }); + for (const [, pending] of pendingCalls) { + clearTimeout(pending.timer); + pending.reject(new Error(`Plugin process exited with code ${code}`)); + } + pendingCalls.clear(); + rm(hostScriptPath, { force: true }).catch(() => {}); + }); + + // Call a hook in the child process with timeout + SIGTERM + SIGKILL escalation + const callHook = ( + hook: string, + payload: unknown, + timeout = DEFAULT_HOOK_TIMEOUT + ): Promise => { + return new Promise((resolve, reject) => { + const id = String(++callCounter); + const timer = setTimeout(() => { + pendingCalls.delete(id); + child.kill("SIGTERM"); + // Escalate to SIGKILL if plugin ignores SIGTERM + const killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, SIGKILL_GRACE_MS); + child.once("exit", () => clearTimeout(killTimer)); + reject(new Error(`Plugin hook '${hook}' timed out after ${timeout}ms`)); + }, timeout); + + pendingCalls.set(id, { resolve, reject, timer }); + child.send({ type: "call", id, hook, payload }); + }); + }; + + // Build Plugin interface + const plugin: Plugin = { + name: manifest.name, + priority: 100, + enabled: true, + }; + + plugin.onRequest = async (ctx: PluginContext): Promise => { + try { + const result = await callHook("onRequest", ctx); + return result as PluginResult | void; + } catch (err: unknown) { + log.error("plugin.onRequest_error", { + name: manifest.name, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + plugin.onResponse = async (ctx: PluginContext, response: unknown): Promise => { + try { + return await callHook("onResponse", { ctx, response }); + } catch (err: unknown) { + log.error("plugin.onResponse_error", { + name: manifest.name, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + plugin.onError = async (ctx: PluginContext, error: Error): Promise => { + try { + return await callHook("onError", { ctx, error: error.message }); + } catch (err: unknown) { + log.error("plugin.onError_error", { + name: manifest.name, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + log.info("loader.loaded", { + name: manifest.name, + hooks: ["onRequest", "onResponse", "onError"], + pid: child.pid, + }); + + const cleanup = () => { + child.kill("SIGTERM"); + // Escalate to SIGKILL after grace period + const killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, SIGKILL_GRACE_MS); + child.once("exit", () => clearTimeout(killTimer)); + rm(hostScriptPath, { force: true }).catch(() => {}); + log.info("loader.cleanup", { name: manifest.name }); + }; + + return { name: manifest.name, manifest, plugin, cleanup }; +} + +/** + * Filter environment variables based on permissions. + * Uses allowlist approach — only pass explicitly safe vars. + */ +function getFilteredEnv(permissions: Permission[]): Record { + const safeKeys = ["PATH", "HOME", "USER", "LANG", "LC_ALL", "NODE_ENV"]; + const extendedSafeKeys = [...safeKeys, "PORT", "HOSTNAME", "TZ", "TMPDIR"]; + const allowedKeys = permissions.includes("env") ? extendedSafeKeys : safeKeys; + const env: Record = {}; + + for (const key of allowedKeys) { + if (process.env[key] !== undefined) env[key] = process.env[key]!; + } + + return env; +} diff --git a/src/lib/plugins/manager.ts b/src/lib/plugins/manager.ts new file mode 100644 index 00000000000..e83e159b110 --- /dev/null +++ b/src/lib/plugins/manager.ts @@ -0,0 +1,300 @@ +/** + * Plugin manager — lifecycle management for plugins. + * + * Singleton that coordinates scanner, loader, DB, and hook registry. + * Handles install, activate, deactivate, uninstall, scan, and startup loading. + * + * @module plugins/manager + */ + +import { mkdir, cp, rm, realpath, readFile } from "fs/promises"; +import { join, dirname } from "path"; +import { randomUUID } from "crypto"; +import { logger } from "../../../open-sse/utils/logger.ts"; +import { getDefaultPluginDir, scanPluginDir } from "./scanner"; +import { loadPlugin, type LoadedPlugin } from "./loader"; +import { registerHook, unregisterHooks } from "./hooks"; +import { + insertPlugin, + getPluginByName, + listPlugins as dbListPlugins, + updatePluginStatus, + deletePlugin as dbDeletePlugin, + pluginExists, + type PluginRow, +} from "../db/plugins"; +import type { PluginManifestWithDefaults } from "./manifest"; + +const log = logger("PLUGIN_MANAGER"); + +class PluginManager { + private static instance: PluginManager; + private loadedPlugins: Map = new Map(); + private pluginDir: string; + + private constructor() { + this.pluginDir = getDefaultPluginDir(); + } + + static getInstance(): PluginManager { + if (!PluginManager.instance) { + PluginManager.instance = new PluginManager(); + } + return PluginManager.instance; + } + + /** + * Install a plugin from a source directory. + * Copies to plugin dir, validates manifest, registers in DB. + */ + async install(sourceDir: string): Promise { + // Check if sourceDir itself contains plugin.json (direct plugin dir) + const { safeValidateManifest } = await import("./manifest"); + const { readFile: readFileFs } = await import("fs/promises"); + let directPlugin: { + name: string; + manifest: any; + pluginDir: string; + entryPoint: string; + } | null = null; + + try { + const manifestPath = join(sourceDir, "plugin.json"); + const raw = await readFileFs(manifestPath, "utf-8"); + const parsed = JSON.parse(raw); + const result = safeValidateManifest(parsed); + if (result.success) { + const entryPoint = join(sourceDir, result.data.main); + directPlugin = { + name: result.data.name, + manifest: result.data, + pluginDir: sourceDir, + entryPoint, + }; + } + } catch {} + + const { plugins, errors } = directPlugin + ? { plugins: [directPlugin], errors: [] } + : await scanPluginDir(sourceDir); + + if (plugins.length === 0) { + throw new Error( + `No valid plugin found in ${sourceDir}: ${errors.map((e) => e.error).join(", ")}` + ); + } + + const discovered = plugins[0]; + const { name, manifest, pluginDir: srcDir } = discovered; + + // Check if already installed + if (pluginExists(name)) { + throw new Error(`Plugin '${name}' is already installed`); + } + + // Copy to plugin directory + const destDir = join(this.pluginDir, name); + await mkdir(dirname(destDir), { recursive: true }); + await cp(srcDir, destDir, { recursive: true }); + + // Register in DB + const row = insertPlugin({ + id: randomUUID(), + name, + version: manifest.version, + description: manifest.description, + author: manifest.author, + license: manifest.license, + main: manifest.main, + source: manifest.source, + tags: manifest.tags, + manifest: manifest as unknown as Record, + configSchema: manifest.configSchema as unknown as Record, + hooks: [ + manifest.hooks.onRequest && "onRequest", + manifest.hooks.onResponse && "onResponse", + manifest.hooks.onError && "onError", + ].filter(Boolean) as string[], + permissions: manifest.requires.permissions, + pluginDir: destDir, + enabled: manifest.enabledByDefault, + }); + + log.info("manager.installed", { name, version: manifest.version }); + + // Auto-activate if enabledByDefault + if (manifest.enabledByDefault) { + await this.activate(name); + } + + return row; + } + + /** + * Activate a plugin — load into VM, register hooks, update DB. + */ + async activate(name: string): Promise { + const row = getPluginByName(name); + if (!row) throw new Error(`Plugin '${name}' not found`); + if (row.status === "active") return; + + const manifest = JSON.parse(row.manifest) as PluginManifestWithDefaults; + + // Path traversal guard: use realpath to resolve symlinks + const entryPoint = join(row.pluginDir, manifest.main); + let resolvedPluginDir: string; + try { + resolvedPluginDir = await realpath(row.pluginDir); + } catch { + throw new Error(`Plugin directory '${row.pluginDir}' does not exist`); + } + const resolvedEntry = await realpath(entryPoint).catch(() => null); + if ( + !resolvedEntry || + (!resolvedEntry.startsWith(resolvedPluginDir + "/") && resolvedEntry !== resolvedPluginDir) + ) { + throw new Error(`Plugin '${name}' entry point escapes plugin directory`); + } + + try { + const loaded = await loadPlugin(entryPoint, manifest); + + // Register hooks individually via registerHook + const hookNames = ["onRequest", "onResponse", "onError"] as const; + for (const hookName of hookNames) { + const handler = loaded.plugin[hookName]; + if (typeof handler === "function") { + registerHook(hookName, name, handler as (payload: unknown) => void | Promise); + } + } + + this.loadedPlugins.set(name, loaded); + updatePluginStatus(name, "active"); + + log.info("manager.activated", { name }); + } catch (err: any) { + updatePluginStatus(name, "error", err.message); + log.error("manager.activate_failed", { name, error: err.message }); + throw err; + } + } + + /** + * Deactivate a plugin — unregister hooks, update DB. + */ + async deactivate(name: string): Promise { + const loaded = this.loadedPlugins.get(name); + if (loaded) { + unregisterHooks(name); + loaded.cleanup(); + this.loadedPlugins.delete(name); + } + + updatePluginStatus(name, "inactive"); + log.info("manager.deactivated", { name }); + } + + /** + * Uninstall a plugin — deactivate, delete directory, remove from DB. + */ + async uninstall(name: string): Promise { + const row = getPluginByName(name); + if (!row) throw new Error(`Plugin '${name}' not found`); + + // Deactivate first if active + if (row.status === "active") { + await this.deactivate(name); + } + + // Delete plugin directory + try { + await rm(row.pluginDir, { recursive: true, force: true }); + } catch (err: any) { + log.warn("manager.uninstall_dir_error", { name, error: err.message }); + } + + // Remove from DB + dbDeletePlugin(name); + log.info("manager.uninstalled", { name }); + } + + /** + * Scan plugin directory and sync with DB. + * Discovers new plugins and marks missing ones. + */ + async scan(): Promise<{ discovered: number; errors: Array<{ name: string; error: string }> }> { + const { plugins, errors } = await scanPluginDir(this.pluginDir); + + // Register newly discovered plugins that aren't in DB + for (const discovered of plugins) { + if (!pluginExists(discovered.name)) { + try { + insertPlugin({ + id: randomUUID(), + name: discovered.name, + version: discovered.manifest.version, + description: discovered.manifest.description, + author: discovered.manifest.author, + license: discovered.manifest.license, + main: discovered.manifest.main, + source: discovered.manifest.source, + tags: discovered.manifest.tags, + manifest: discovered.manifest as unknown as Record, + configSchema: discovered.manifest.configSchema as unknown as Record, + hooks: [ + discovered.manifest.hooks.onRequest && "onRequest", + discovered.manifest.hooks.onResponse && "onResponse", + discovered.manifest.hooks.onError && "onError", + ].filter(Boolean) as string[], + permissions: discovered.manifest.requires.permissions, + pluginDir: discovered.pluginDir, + enabled: discovered.manifest.enabledByDefault, + }); + } catch (err: any) { + errors.push({ name: discovered.name, error: `DB insert failed: ${err.message}` }); + } + } + } + + return { discovered: plugins.length, errors }; + } + + /** + * Load all active plugins on startup. + */ + async loadAll(): Promise { + const rows = dbListPlugins("active"); + log.info("manager.loadAll", { count: rows.length }); + + for (const row of rows) { + try { + await this.activate(row.name); + } catch (err: any) { + log.error("manager.loadAll_failed", { name: row.name, error: err.message }); + } + } + } + + /** + * Get a loaded plugin by name. + */ + getLoaded(name: string): LoadedPlugin | undefined { + return this.loadedPlugins.get(name); + } + + /** + * List all plugins from DB. + */ + listAll(): PluginRow[] { + return dbListPlugins(); + } + + /** + * Get plugin by name from DB. + */ + getPlugin(name: string): PluginRow | null { + return getPluginByName(name); + } +} + +export const pluginManager = PluginManager.getInstance(); diff --git a/src/lib/plugins/manifest.ts b/src/lib/plugins/manifest.ts new file mode 100644 index 00000000000..c3b5fb7815c --- /dev/null +++ b/src/lib/plugins/manifest.ts @@ -0,0 +1,129 @@ +/** + * Plugin manifest validator — Zod schema for plugin.json files. + * + * @module plugins/manifest + */ + +import { z } from "zod"; + +// ── Permission enum ── + +export const PermissionSchema = z.enum(["network", "file-read", "file-write", "env", "exec"]); +export type Permission = z.infer; + +// ── Skill definition in manifest ── + +export const ManifestSkillSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + input: z.record(z.string(), z.unknown()).optional(), + output: z.record(z.string(), z.unknown()).optional(), +}); +export type ManifestSkill = z.infer; + +// ── Config schema field ── + +export const ConfigFieldSchema = z.object({ + type: z.enum(["string", "number", "boolean", "select"]), + default: z.unknown().optional(), + min: z.number().optional(), + max: z.number().optional(), + enum: z.array(z.string()).optional(), + description: z.string().optional(), +}); +export type ConfigField = z.infer; + +// ── Hooks ── + +export const HooksSchema = z.object({ + onRequest: z.boolean().optional(), + onResponse: z.boolean().optional(), + onError: z.boolean().optional(), +}); + +// ── Requires ── + +export const RequiresSchema = z.object({ + omniroute: z.string().optional(), + permissions: z.array(PermissionSchema).optional(), +}); + +// ── Full manifest ── + +export const PluginManifestSchema = z.object({ + name: z + .string() + .min(1) + .max(100) + .regex(/^[a-z0-9-]+$/, "Name must be kebab-case (lowercase, hyphens only)"), + version: z.string().regex(/^\d+\.\d+\.\d+$/, "Version must be semver (e.g. 1.0.0)"), + description: z.string().max(500).optional(), + author: z.string().max(200).optional(), + license: z.string().optional(), + main: z.string().optional(), + source: z.enum(["local", "marketplace"]).optional(), + tags: z.array(z.string()).optional(), + requires: RequiresSchema.optional(), + hooks: HooksSchema.optional(), + skills: z.array(ManifestSkillSchema).optional(), + enabledByDefault: z.boolean().optional(), + configSchema: z.record(z.string(), ConfigFieldSchema).optional(), +}); + +export type PluginManifest = z.infer; + +// ── Defaults applied after parsing ── + +export interface PluginManifestWithDefaults extends PluginManifest { + license: string; + main: string; + source: "local" | "marketplace"; + tags: string[]; + requires: { omniroute?: string; permissions: Permission[] }; + hooks: { onRequest: boolean; onResponse: boolean; onError: boolean }; + skills: ManifestSkill[]; + enabledByDefault: boolean; + configSchema: Record; +} + +export function applyDefaults(manifest: PluginManifest): PluginManifestWithDefaults { + return { + ...manifest, + license: manifest.license ?? "MIT", + main: manifest.main ?? "index.js", + source: manifest.source ?? "local", + tags: manifest.tags ?? [], + requires: { + omniroute: manifest.requires?.omniroute, + permissions: manifest.requires?.permissions ?? [], + }, + hooks: { + onRequest: manifest.hooks?.onRequest ?? false, + onResponse: manifest.hooks?.onResponse ?? false, + onError: manifest.hooks?.onError ?? false, + }, + skills: manifest.skills ?? [], + enabledByDefault: manifest.enabledByDefault ?? false, + configSchema: manifest.configSchema ?? {}, + }; +} + +// ── Validation ── + +export function validateManifest(raw: unknown): PluginManifestWithDefaults { + const parsed = PluginManifestSchema.parse(raw); + return applyDefaults(parsed); +} + +export function safeValidateManifest( + raw: unknown +): { success: true; data: PluginManifestWithDefaults } | { success: false; errors: string[] } { + const result = PluginManifestSchema.safeParse(raw); + if (result.success) { + return { success: true, data: applyDefaults(result.data) }; + } + return { + success: false, + errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`), + }; +} diff --git a/src/lib/plugins/scanner.ts b/src/lib/plugins/scanner.ts new file mode 100644 index 00000000000..b9beaf40000 --- /dev/null +++ b/src/lib/plugins/scanner.ts @@ -0,0 +1,110 @@ +/** + * Plugin scanner — discovers plugins from the filesystem. + * + * Scans ~/.omniroute/plugins/ for subdirectories containing plugin.json manifests. + * Returns validated manifests with directory paths. + * + * @module plugins/scanner + */ + +import { readdir, stat, readFile } from "fs/promises"; +import { join } from "path"; +import { logger } from "../../../open-sse/utils/logger.ts"; +import { safeValidateManifest, type PluginManifestWithDefaults } from "./manifest"; + +const log = logger("PLUGIN_SCANNER"); + +export interface DiscoveredPlugin { + name: string; + manifest: PluginManifestWithDefaults; + pluginDir: string; + entryPoint: string; +} + +/** + * Get the default plugin directory: ~/.omniroute/plugins/ + */ +export function getDefaultPluginDir(): string { + const home = process.env.HOME || process.env.USERPROFILE || "/tmp"; + return join(home, ".omniroute", "plugins"); +} + +/** + * Scan a directory for plugin subdirectories containing plugin.json. + * Skips hidden directories (.xxx) and non-directories. + */ +export async function scanPluginDir( + dir: string +): Promise<{ plugins: DiscoveredPlugin[]; errors: Array<{ name: string; error: string }> }> { + const plugins: DiscoveredPlugin[] = []; + const errors: Array<{ name: string; error: string }> = []; + + let entries: string[]; + try { + const dirEntries = await readdir(dir, { withFileTypes: true }); + entries = dirEntries + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => e.name); + } catch (err: any) { + if (err.code === "ENOENT") { + log.info("scanner.dir_not_found", { dir }); + return { plugins: [], errors: [] }; + } + throw err; + } + + for (const entry of entries) { + const pluginDir = join(dir, entry); + const manifestPath = join(pluginDir, "plugin.json"); + + try { + const manifestStat = await stat(manifestPath); + if (!manifestStat.isFile()) { + errors.push({ name: entry, error: "plugin.json is not a file" }); + continue; + } + } catch { + errors.push({ name: entry, error: "no plugin.json found" }); + continue; + } + + try { + const raw = await readFile(manifestPath, "utf-8"); + const parsed = JSON.parse(raw); + const result = safeValidateManifest(parsed); + + if (!result.success) { + const failResult = result as { success: false; errors: string[] }; + errors.push({ name: entry, error: `invalid manifest: ${failResult.errors.join("; ")}` }); + continue; + } + + const manifest = result.data; + const entryPoint = join(pluginDir, manifest.main); + + // Verify entry point exists + try { + await stat(entryPoint); + } catch { + errors.push({ + name: entry, + error: `entry point not found: ${manifest.main}`, + }); + continue; + } + + plugins.push({ + name: manifest.name, + manifest, + pluginDir, + entryPoint, + }); + + log.info("scanner.discovered", { name: manifest.name, version: manifest.version }); + } catch (err: any) { + errors.push({ name: entry, error: `failed to read manifest: ${err.message}` }); + } + } + + return { plugins, errors }; +} diff --git a/tests/unit/plugins-loader.test.ts b/tests/unit/plugins-loader.test.ts new file mode 100644 index 00000000000..8fbe097e842 --- /dev/null +++ b/tests/unit/plugins-loader.test.ts @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Loader uses child_process.fork() — we test the module exports and types, +// not actual fork behavior (that requires integration tests with real plugins). + +import type { LoadedPlugin } from "../../src/lib/plugins/loader.ts"; +import type { Plugin, PluginContext, PluginResult } from "../../src/lib/plugins/index.ts"; + +// ── Type checks ── + +test("LoadedPlugin interface has required fields", () => { + // Verify the type structure exists by checking the module exports + const mock: LoadedPlugin = { + name: "test", + manifest: { + name: "test", + version: "1.0.0", + license: "MIT", + main: "index.js", + source: "local", + tags: [], + requires: { permissions: [] }, + hooks: { onRequest: false, onResponse: false, onError: false }, + skills: [], + enabledByDefault: false, + configSchema: {}, + }, + plugin: { name: "test" }, + cleanup: () => {}, + }; + assert.equal(mock.name, "test"); + assert.equal(typeof mock.cleanup, "function"); +}); + +test("Plugin interface supports lifecycle hooks", () => { + const plugin: Plugin = { + name: "test", + onRequest: async (_ctx: PluginContext): Promise => { + return { blocked: false }; + }, + onResponse: async (_ctx: PluginContext, response: any) => response, + onError: async (_ctx: PluginContext, _error: Error) => null, + }; + assert.equal(typeof plugin.onRequest, "function"); + assert.equal(typeof plugin.onResponse, "function"); + assert.equal(typeof plugin.onError, "function"); +}); + +test("PluginContext has required fields", () => { + const ctx: PluginContext = { + requestId: "test-123", + body: { model: "gpt-4" }, + model: "gpt-4", + provider: "openai", + metadata: {}, + }; + assert.equal(ctx.requestId, "test-123"); + assert.equal(ctx.model, "gpt-4"); +}); + +test("PluginResult supports blocking", () => { + const blocked: PluginResult = { + blocked: true, + response: { error: "denied" }, + }; + assert.ok(blocked.blocked); + assert.deepEqual(blocked.response, { error: "denied" }); +}); + +test("PluginResult supports body modification", () => { + const modified: PluginResult = { + body: { model: "gpt-4-turbo" }, + metadata: { plugin: "model-switcher" }, + }; + assert.equal(modified.body.model, "gpt-4-turbo"); + assert.equal(modified.metadata?.plugin, "model-switcher"); +}); diff --git a/tests/unit/plugins-manager.test.ts b/tests/unit/plugins-manager.test.ts new file mode 100644 index 00000000000..acb14dffca2 --- /dev/null +++ b/tests/unit/plugins-manager.test.ts @@ -0,0 +1,51 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Manager is a singleton that depends on DB, scanner, and loader. +// We test the module structure and type contracts here. +// Full lifecycle tests require integration setup with SQLite. + +import { pluginManager } from "../../src/lib/plugins/manager.ts"; + +// ── Singleton ── + +test("pluginManager is a singleton", () => { + const a = pluginManager; + const b = pluginManager; + assert.strictEqual(a, b); +}); + +test("pluginManager has all lifecycle methods", () => { + assert.equal(typeof pluginManager.install, "function"); + assert.equal(typeof pluginManager.activate, "function"); + assert.equal(typeof pluginManager.deactivate, "function"); + assert.equal(typeof pluginManager.uninstall, "function"); + assert.equal(typeof pluginManager.scan, "function"); + assert.equal(typeof pluginManager.loadAll, "function"); + assert.equal(typeof pluginManager.getLoaded, "function"); + assert.equal(typeof pluginManager.listAll, "function"); + assert.equal(typeof pluginManager.getPlugin, "function"); +}); + +test("pluginManager.getLoaded returns undefined for unknown plugin", () => { + const result = pluginManager.getLoaded("nonexistent-plugin"); + assert.equal(result, undefined); +}); + +test("pluginManager.install throws for invalid directory", async () => { + await assert.rejects( + () => pluginManager.install("/nonexistent/path"), + (err: Error) => { + assert.ok(err.message.includes("No valid plugin found")); + return true; + } + ); +}); + +test("pluginManager.activate throws for unknown plugin", async () => { + await assert.rejects(() => pluginManager.activate("nonexistent-plugin")); +}); + +test("pluginManager.uninstall throws for unknown plugin", async () => { + await assert.rejects(() => pluginManager.uninstall("nonexistent-plugin")); +}); diff --git a/tests/unit/plugins-scanner.test.ts b/tests/unit/plugins-scanner.test.ts new file mode 100644 index 00000000000..2d9926f79d7 --- /dev/null +++ b/tests/unit/plugins-scanner.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, mkdir, rm } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { scanPluginDir, getDefaultPluginDir } from "../../src/lib/plugins/scanner.ts"; + +let tmpDir: string; + +test.beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "plugin-scan-test-")); +}); + +test.afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ── getDefaultPluginDir ── + +test("getDefaultPluginDir returns ~/.omniroute/plugins", () => { + const dir = getDefaultPluginDir(); + assert.ok(dir.endsWith(".omniroute/plugins")); +}); + +// ── scanPluginDir ── + +test("returns empty for non-existent directory", async () => { + const result = await scanPluginDir("/nonexistent/path"); + assert.deepEqual(result.plugins, []); + assert.deepEqual(result.errors, []); +}); + +test("returns empty for empty directory", async () => { + const result = await scanPluginDir(tmpDir); + assert.deepEqual(result.plugins, []); + assert.deepEqual(result.errors, []); +}); + +test("skips hidden directories", async () => { + await mkdir(join(tmpDir, ".hidden")); + await writeFile( + join(tmpDir, ".hidden", "plugin.json"), + JSON.stringify({ name: "hidden", version: "1.0.0" }) + ); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 0); +}); + +test("discovers valid plugin", async () => { + const pluginDir = join(tmpDir, "my-plugin"); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "my-plugin", version: "1.0.0" }) + ); + await writeFile(join(pluginDir, "index.js"), "module.exports = {};"); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 1); + assert.equal(result.plugins[0].name, "my-plugin"); + assert.equal(result.plugins[0].manifest.version, "1.0.0"); +}); + +test("reports error for missing plugin.json", async () => { + await mkdir(join(tmpDir, "no-manifest")); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 0); + assert.equal(result.errors.length, 1); + assert.ok(result.errors[0].error.includes("no plugin.json")); +}); + +test("reports error for invalid manifest", async () => { + const pluginDir = join(tmpDir, "bad-manifest"); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "BAD NAME!", version: "nope" }) + ); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 0); + assert.equal(result.errors.length, 1); + assert.ok(result.errors[0].error.includes("invalid manifest")); +}); + +test("reports error for missing entry point", async () => { + const pluginDir = join(tmpDir, "no-entry"); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, "plugin.json"), + JSON.stringify({ name: "no-entry", version: "1.0.0", main: "missing.js" }) + ); + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 0); + assert.equal(result.errors.length, 1); + assert.ok(result.errors[0].error.includes("entry point not found")); +}); + +test("discovers multiple plugins", async () => { + for (const name of ["plugin-a", "plugin-b"]) { + const d = join(tmpDir, name); + await mkdir(d); + await writeFile(join(d, "plugin.json"), JSON.stringify({ name, version: "1.0.0" })); + await writeFile(join(d, "index.js"), "module.exports = {};"); + } + const result = await scanPluginDir(tmpDir); + assert.equal(result.plugins.length, 2); +});