| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { mkdir, cp, rm, rename, realpath, readFile } from "fs/promises";
|
| import { join, dirname, resolve, sep } 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,
|
| updatePluginConfig,
|
| deletePlugin as dbDeletePlugin,
|
| pluginExists,
|
| type PluginRow,
|
| } from "../db/plugins";
|
| import type { PluginManifestWithDefaults } from "./manifest";
|
|
|
| const log = logger("PLUGIN_MANAGER");
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export function compareSemver(a: string, b: string): number {
|
|
|
| const stripPreRelease = (v: string) => v.replace(/-.*$/, "");
|
| const parse = (v: string) =>
|
| stripPreRelease(v)
|
| .split(".")
|
| .map((s) => {
|
| const n = Number(s);
|
| return Number.isNaN(n) ? 0 : n;
|
| });
|
| const [aMaj, aMin, aPat] = parse(a);
|
| const [bMaj, bMin, bPat] = parse(b);
|
| if (aMaj !== bMaj) return aMaj - bMaj;
|
| if (aMin !== bMin) return aMin - bMin;
|
| return aPat - bPat;
|
| }
|
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| function assertWithinPluginDir(pluginRoot: string, target: string): void {
|
| const root = resolve(pluginRoot);
|
| const t = resolve(target);
|
| if (t !== root && !t.startsWith(root + sep)) {
|
| throw new Error(
|
| `Refusing to delete a path outside the plugin directory: "${t}" is not under "${root}"`
|
| );
|
| }
|
| }
|
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| function assertEntryPointWithinDest(destDir: string, entryPoint: string): void {
|
| const root = resolve(destDir);
|
| const ep = resolve(entryPoint);
|
| if (!ep.startsWith(root + sep)) {
|
| throw new Error(
|
| `Plugin manifest.main resolves outside plugin directory: "${ep}" escapes "${root}"`
|
| );
|
| }
|
| }
|
|
|
| class PluginManager {
|
| private static instance: PluginManager;
|
| private loadedPlugins: Map<string, LoadedPlugin> = new Map();
|
| private pluginDir: string;
|
|
|
| private constructor() {
|
| this.pluginDir = getDefaultPluginDir();
|
| }
|
|
|
| static getInstance(): PluginManager {
|
| if (!PluginManager.instance) {
|
| PluginManager.instance = new PluginManager();
|
| }
|
| return PluginManager.instance;
|
| }
|
|
|
| |
| |
| |
| |
|
|
| async install(sourceDir: string): Promise<PluginRow> {
|
|
|
| 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;
|
|
|
|
|
| if (pluginExists(name)) {
|
| const existing = getPluginByName(name)!;
|
| if (compareSemver(manifest.version, existing.version) > 0) {
|
|
|
| return this.upgrade(sourceDir);
|
| }
|
| throw new Error(
|
| `Plugin '${name}' is already installed (${existing.version}) and source version ${manifest.version} is not newer`
|
| );
|
| }
|
|
|
|
|
| const destDir = join(this.pluginDir, name);
|
| const stagingDir = `${destDir}.staging-${randomUUID()}`;
|
| await mkdir(dirname(destDir), { recursive: true });
|
|
|
|
|
|
|
|
|
|
|
| assertWithinPluginDir(this.pluginDir, destDir);
|
| await rm(destDir, { recursive: true, force: true }).catch(() => {});
|
| await cp(srcDir, stagingDir, { recursive: true });
|
|
|
| try {
|
|
|
| const entryPoint = join(stagingDir, manifest.main || "index.js");
|
| assertEntryPointWithinDest(stagingDir, entryPoint);
|
|
|
|
|
| await rename(stagingDir, destDir);
|
| } catch (err) {
|
|
|
| await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
| throw err;
|
| }
|
|
|
|
|
| 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<string, unknown>,
|
| configSchema: manifest.configSchema as unknown as Record<string, unknown>,
|
| 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 });
|
|
|
|
|
| if (manifest.enabledByDefault) {
|
| await this.activate(name);
|
| }
|
|
|
| return row;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| async upgrade(sourceDir: string): Promise<PluginRow> {
|
|
|
| const { safeValidateManifest } = await import("./manifest");
|
| const { readFile: readFileFs } = await import("fs/promises");
|
|
|
| let discovered: { name: string; manifest: any; pluginDir: 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) {
|
| discovered = { name: result.data.name, manifest: result.data, pluginDir: sourceDir };
|
| }
|
| } catch {}
|
|
|
| if (!discovered) {
|
| const { plugins, errors } = await scanPluginDir(sourceDir);
|
| if (plugins.length === 0) {
|
| throw new Error(
|
| `No valid plugin found in ${sourceDir}: ${errors.map((e) => e.error).join(", ")}`
|
| );
|
| }
|
| discovered = plugins[0];
|
| }
|
|
|
| const { name, manifest } = discovered;
|
|
|
|
|
| if (!pluginExists(name)) {
|
| throw new Error(`Plugin '${name}' is not installed — use install() instead`);
|
| }
|
|
|
| const existing = getPluginByName(name)!;
|
|
|
|
|
| if (compareSemver(manifest.version, existing.version) <= 0) {
|
| throw new Error(
|
| `Plugin '${name}' upgrade rejected: source version ${manifest.version} is not newer than installed ${existing.version}`
|
| );
|
| }
|
|
|
| log.info("manager.upgrading", { name, from: existing.version, to: manifest.version });
|
|
|
|
|
| if (existing.status === "active") {
|
| await this.deactivate(name);
|
| }
|
|
|
|
|
| const destDir = join(this.pluginDir, name);
|
| const stagingDir = `${destDir}.staging-${randomUUID()}`;
|
| await mkdir(dirname(destDir), { recursive: true });
|
| await cp(discovered.pluginDir, stagingDir, { recursive: true });
|
|
|
| try {
|
|
|
| const entryPoint = join(stagingDir, manifest.main || "index.js");
|
| assertEntryPointWithinDest(stagingDir, entryPoint);
|
|
|
|
|
| assertWithinPluginDir(this.pluginDir, existing.pluginDir);
|
|
|
|
|
| try {
|
| await rm(existing.pluginDir, { recursive: true, force: true });
|
| } catch (err: any) {
|
| log.warn("manager.upgrade_dir_error", { name, error: err.message });
|
| }
|
| dbDeletePlugin(name);
|
|
|
|
|
| await rename(stagingDir, destDir);
|
| } catch (err) {
|
|
|
| await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
| throw err;
|
| }
|
|
|
| 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<string, unknown>,
|
| configSchema: manifest.configSchema as unknown as Record<string, unknown>,
|
| 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.upgraded", { name, version: manifest.version });
|
|
|
| if (manifest.enabledByDefault) {
|
| await this.activate(name);
|
| }
|
|
|
| return row;
|
| }
|
|
|
| |
| |
|
|
| async activate(name: string): Promise<void> {
|
| 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;
|
|
|
|
|
| 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);
|
|
|
|
|
| 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<void>);
|
| }
|
| }
|
|
|
| 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;
|
| }
|
| }
|
|
|
| |
| |
|
|
| async deactivate(name: string): Promise<void> {
|
| const loaded = this.loadedPlugins.get(name);
|
| if (loaded) {
|
| unregisterHooks(name);
|
| loaded.cleanup();
|
| this.loadedPlugins.delete(name);
|
| }
|
|
|
| updatePluginStatus(name, "inactive");
|
| log.info("manager.deactivated", { name });
|
| }
|
|
|
| |
| |
|
|
| async uninstall(name: string): Promise<void> {
|
| const row = getPluginByName(name);
|
| if (!row) throw new Error(`Plugin '${name}' not found`);
|
|
|
|
|
| if (row.status === "active") {
|
| await this.deactivate(name);
|
| }
|
|
|
|
|
|
|
|
|
| assertWithinPluginDir(this.pluginDir, row.pluginDir);
|
|
|
|
|
| try {
|
| await rm(row.pluginDir, { recursive: true, force: true });
|
| } catch (err: any) {
|
| log.warn("manager.uninstall_dir_error", { name, error: err.message });
|
| }
|
|
|
|
|
| dbDeletePlugin(name);
|
| log.info("manager.uninstalled", { name });
|
| }
|
|
|
| |
| |
| |
|
|
| async scan(): Promise<{ discovered: number; errors: Array<{ name: string; error: string }> }> {
|
| const { plugins, errors } = await scanPluginDir(this.pluginDir);
|
|
|
|
|
| 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<string, unknown>,
|
| configSchema: discovered.manifest.configSchema as unknown as Record<string, unknown>,
|
| 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 };
|
| }
|
|
|
| |
| |
|
|
| async loadAll(): Promise<void> {
|
| 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 });
|
| }
|
| }
|
| }
|
|
|
| |
| |
|
|
| getLoaded(name: string): LoadedPlugin | undefined {
|
| return this.loadedPlugins.get(name);
|
| }
|
|
|
| |
| |
|
|
| listAll(): PluginRow[] {
|
| return dbListPlugins();
|
| }
|
|
|
| |
| |
|
|
| getPlugin(name: string): PluginRow | null {
|
| return getPluginByName(name);
|
| }
|
| }
|
|
|
| export const pluginManager = PluginManager.getInstance();
|
|
|