/** * 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 }; }