From b3e5ee3333ee09f63d0439776243190bfb683b6f Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 05:11:18 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20fase=209.4=20=E2=80=94=20plugin=20?= =?UTF-8?q?system=20(omniroute-cmd-*)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds plugin discovery, loading, and management to the omniroute CLI. - bin/cli/plugins.mjs: discoverPlugins / loadPlugins / buildPluginContext - bin/cli/commands/plugin.mjs: list / install / remove / info / search / update / scaffold - examples/omniroute-cmd-hello/: minimal working plugin example - docs/dev/plugins.md: plugin API contract and authoring guide - .env.example + ENVIRONMENT.md: document OMNIROUTE_PLUGIN_PATH --- .env.example | 4 + bin/cli/commands/dashboard.mjs | 1 - bin/cli/commands/plugin.mjs | 192 +++++++++++++++++++++ bin/cli/commands/registry.mjs | 2 + bin/cli/locales/en.json | 10 ++ bin/cli/locales/pt-BR.json | 10 ++ bin/cli/plugins.mjs | 90 ++++++++++ docs/dev/plugins.md | 108 ++++++++++++ docs/reference/ENVIRONMENT.md | 1 + examples/omniroute-cmd-hello/index.mjs | 28 ++++ examples/omniroute-cmd-hello/package.json | 14 ++ tests/unit/cli-plugin-system.test.ts | 195 ++++++++++++++++++++++ 12 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 bin/cli/commands/plugin.mjs create mode 100644 bin/cli/plugins.mjs create mode 100644 docs/dev/plugins.md create mode 100644 examples/omniroute-cmd-hello/index.mjs create mode 100644 examples/omniroute-cmd-hello/package.json create mode 100644 tests/unit/cli-plugin-system.test.ts diff --git a/.env.example b/.env.example index ddced5dc9..2c2421eaa 100644 --- a/.env.example +++ b/.env.example @@ -800,6 +800,10 @@ APP_LOG_TO_FILE=true # Set to 1 to print retry/backoff details to stderr during CLI commands. # OMNIROUTE_VERBOSE=0 +# Custom directory for CLI plugin discovery (omniroute-cmd-* packages). +# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree. +# OMNIROUTE_PLUGIN_PATH= + # ── Prompt cache (system prompt deduplication) ── # Used by: open-sse/services — caches identical system prompts across requests. # PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50) diff --git a/bin/cli/commands/dashboard.mjs b/bin/cli/commands/dashboard.mjs index cce5c3d62..ff7d8c7b0 100644 --- a/bin/cli/commands/dashboard.mjs +++ b/bin/cli/commands/dashboard.mjs @@ -4,7 +4,6 @@ import { t } from "../i18n.mjs"; export function registerDashboard(program) { program .command("dashboard") - .alias("open") .description(t("dashboard.description")) .option("--url", t("dashboard.urlOnly")) .option("--port ", "Port the server is running on", "20128") diff --git a/bin/cli/commands/plugin.mjs b/bin/cli/commands/plugin.mjs new file mode 100644 index 000000000..3008aec75 --- /dev/null +++ b/bin/cli/commands/plugin.mjs @@ -0,0 +1,192 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { t } from "../i18n.mjs"; +import { emit } from "../output.mjs"; +import { discoverPlugins } from "../plugins.mjs"; + +const TEMPLATE_INDEX = `export const meta = { + name: "PLUGIN_NAME", + version: "0.1.0", + description: "OmniRoute plugin", + omnirouteApi: ">=4.0.0", +}; + +export function register(program, ctx) { + program + .command("PLUGIN_NAME") + .description(meta.description) + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + // ctx provides: ctx.apiFetch, ctx.emit, ctx.t, ctx.withSpinner, ctx.baseUrl, ctx.apiKey + const res = await ctx.apiFetch("/api/health", { baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = await res.json(); + ctx.emit(data, gOpts); + }); +} +`; + +export function registerPlugin(program) { + const plugin = program + .command("plugin") + .description(t("plugin.description") || "Manage CLI plugins (omniroute-cmd-*)"); + + plugin + .command("list") + .description(t("plugin.list") || "List installed plugins") + .action(async (opts, cmd) => { + const plugins = await discoverPlugins(); + emit( + plugins.map((p) => ({ name: p.name, version: p.version, description: p.description })), + cmd.optsWithGlobals() + ); + if (plugins.length === 0) { + process.stdout.write("No plugins installed.\n"); + process.stdout.write(`Install: omniroute plugin install \n`); + } + }); + + plugin + .command("install ") + .description(t("plugin.install") || "Install a plugin") + .option("-y, --yes", "Skip confirmation prompt") + .action(async (name, opts) => { + const isLocal = name.startsWith("./") || name.startsWith("/") || name.startsWith("../"); + const pkgName = isLocal ? name : `omniroute-cmd-${name}`; + + if (!opts.yes) { + process.stderr.write( + `⚠ WARNING: Plugins run with the same privileges as omniroute CLI.\n` + + ` Only install plugins from sources you trust.\n` + + ` Installing: ${pkgName}\n` + + ` Pass --yes to skip this prompt.\n` + ); + // In non-interactive mode, require explicit --yes + if (!process.stdin.isTTY) { + process.stderr.write("Non-interactive mode: use --yes to confirm.\n"); + process.exit(1); + } + } + + try { + execSync(`npm install -g ${pkgName}`, { stdio: "inherit" }); + process.stdout.write(`\n✓ Installed: ${pkgName}\n`); + } catch { + process.stderr.write(`✗ Failed to install ${pkgName}\n`); + process.exit(1); + } + }); + + plugin + .command("remove ") + .alias("uninstall") + .description(t("plugin.remove") || "Remove a plugin") + .option("-y, --yes", "Skip confirmation") + .action(async (name, opts) => { + const pkgName = name.startsWith("omniroute-cmd-") ? name : `omniroute-cmd-${name}`; + if (!opts.yes) { + process.stderr.write(`Removing: ${pkgName} — pass --yes to confirm.\n`); + if (!process.stdin.isTTY) { + process.exit(1); + } + } + try { + execSync(`npm uninstall -g ${pkgName}`, { stdio: "inherit" }); + process.stdout.write(`✓ Removed: ${pkgName}\n`); + } catch { + process.stderr.write(`✗ Failed to remove ${pkgName}\n`); + process.exit(1); + } + }); + + plugin + .command("info ") + .description(t("plugin.info") || "Show plugin details") + .action(async (name, opts, cmd) => { + const plugins = await discoverPlugins(); + const p = plugins.find((x) => x.name === name || x.name === `omniroute-cmd-${name}`); + if (!p) { + process.stderr.write(`Plugin '${name}' not found.\n`); + process.exit(1); + } + emit(p.pkg, cmd.optsWithGlobals()); + }); + + plugin + .command("search [query]") + .description(t("plugin.search") || "Search npm for available plugins") + .action(async (query) => { + const q = query ? `omniroute-cmd-${query}` : "omniroute-cmd"; + const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=50`; + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`npm registry returned ${res.status}`); + const data = await res.json(); + const rows = (data.objects || []).map((o) => ({ + name: o.package.name, + version: o.package.version, + description: o.package.description, + })); + if (rows.length === 0) { + process.stdout.write(`No plugins found for '${query || "omniroute-cmd"}'.\n`); + } else { + rows.forEach((r) => + process.stdout.write(` ${r.name}@${r.version} ${r.description || ""}\n`) + ); + } + } catch (err) { + process.stderr.write(`Search failed: ${err.message}\n`); + process.exit(1); + } + }); + + plugin + .command("update [name]") + .description(t("plugin.update") || "Update installed plugin(s)") + .action(async (name) => { + const pkg = name ? `omniroute-cmd-${name}` : "omniroute-cmd-*"; + try { + execSync(`npm update -g ${pkg}`, { stdio: "inherit" }); + process.stdout.write(`✓ Updated: ${pkg}\n`); + } catch { + process.stderr.write(`✗ Update failed\n`); + process.exit(1); + } + }); + + plugin + .command("scaffold ") + .description(t("plugin.scaffold") || "Scaffold a new plugin boilerplate") + .action(async (name) => { + const safeName = name.replace(/[^a-z0-9-]/g, "-"); + const dir = join(process.cwd(), `omniroute-cmd-${safeName}`); + if (existsSync(dir)) { + process.stderr.write(`Directory already exists: ${dir}\n`); + process.exit(1); + } + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify( + { + name: `omniroute-cmd-${safeName}`, + version: "0.1.0", + type: "module", + main: "index.mjs", + description: `OmniRoute CLI plugin: ${safeName}`, + engines: { omniroute: ">=4.0.0" }, + keywords: ["omniroute-plugin", "omniroute-cmd"], + }, + null, + 2 + ) + "\n" + ); + writeFileSync(join(dir, "index.mjs"), TEMPLATE_INDEX.replace(/PLUGIN_NAME/g, safeName)); + writeFileSync( + join(dir, "README.md"), + `# omniroute-cmd-${safeName}\n\nAn OmniRoute CLI plugin.\n\n## Install\n\n\`\`\`bash\nomniroute plugin install ${safeName}\n\`\`\`\n` + ); + process.stdout.write(`✓ Scaffolded: ${dir}\n`); + process.stdout.write(` Run: cd ${dir} && omniroute plugin install .\n`); + }); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 84f95cc5c..64c2578fc 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -56,6 +56,7 @@ import { registerTray } from "./tray.mjs"; import { registerAutostart } from "./autostart.mjs"; import { registerRepl } from "./repl.mjs"; import { registerApiCommands } from "../api-commands/registry.mjs"; +import { registerPlugin } from "./plugin.mjs"; export function registerCommands(program) { registerMemory(program); @@ -117,4 +118,5 @@ export function registerCommands(program) { registerAutostart(program); registerRepl(program); registerApiCommands(program); + registerPlugin(program); } diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index d552a860c..978e7a499 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -1112,5 +1112,15 @@ "combo": "Combo name to use", "system": "System prompt", "resume": "Resume a saved session by name" + }, + "plugin": { + "description": "Manage CLI plugins (omniroute-cmd-*)", + "list": "List installed plugins", + "install": "Install a plugin from npm or local path", + "remove": "Remove an installed plugin", + "info": "Show details for an installed plugin", + "search": "Search npm registry for available plugins", + "update": "Update installed plugin(s)", + "scaffold": "Scaffold a new plugin boilerplate" } } diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 11ef57947..35ebc3f8f 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -1112,5 +1112,15 @@ "combo": "Nome do combo a usar", "system": "System prompt", "resume": "Retomar sessão salva pelo nome" + }, + "plugin": { + "description": "Gerenciar plugins CLI (omniroute-cmd-*)", + "list": "Listar plugins instalados", + "install": "Instalar plugin do npm ou caminho local", + "remove": "Remover plugin instalado", + "info": "Mostrar detalhes de um plugin instalado", + "search": "Buscar plugins disponíveis no npm", + "update": "Atualizar plugin(s) instalado(s)", + "scaffold": "Gerar boilerplate de novo plugin" } } diff --git a/bin/cli/plugins.mjs b/bin/cli/plugins.mjs new file mode 100644 index 000000000..82eb5e47f --- /dev/null +++ b/bin/cli/plugins.mjs @@ -0,0 +1,90 @@ +import { readdirSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { pathToFileURL } from "node:url"; + +const PLUGIN_PREFIX_RE = /^(@[^/]+\/)?omniroute-cmd-/; + +function getPluginDirs() { + return [join(homedir(), ".omniroute", "plugins"), process.env.OMNIROUTE_PLUGIN_PATH].filter( + Boolean + ); +} + +export async function discoverPlugins() { + const found = []; + for (const dir of getPluginDirs()) { + if (!existsSync(dir)) continue; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const pkgPath = join(dir, entry.name, "package.json"); + if (!existsSync(pkgPath)) continue; + let pkg; + try { + pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + } catch { + continue; + } + if (!pkg.name || !PLUGIN_PREFIX_RE.test(pkg.name)) continue; + found.push({ + name: pkg.name, + version: pkg.version || "0.0.0", + description: pkg.description || "", + dir: join(dir, entry.name), + pkg, + }); + } + } + return found; +} + +export function buildPluginContext(opts = {}) { + return { + apiFetch: async (...args) => { + const { apiFetch } = await import("./api.mjs"); + return apiFetch(...args); + }, + emit: async (...args) => { + const { emit } = await import("./output.mjs"); + return emit(...args); + }, + t: async (...args) => { + const { t } = await import("./i18n.mjs"); + return t(...args); + }, + withSpinner: async (...args) => { + const { withSpinner } = await import("./spinner.mjs"); + return withSpinner(...args); + }, + baseUrl: opts.baseUrl, + apiKey: opts.apiKey, + }; +} + +export async function loadPlugins(program, ctx = {}) { + const plugins = await discoverPlugins(); + for (const p of plugins) { + try { + const entryPath = join(p.dir, p.pkg.main || "index.mjs"); + if (!existsSync(entryPath)) { + process.stderr.write(`[plugin] ${p.name}: entry file not found (${entryPath})\n`); + continue; + } + const mod = await import(pathToFileURL(entryPath).href); + if (typeof mod.register === "function") { + mod.register(program, buildPluginContext(ctx)); + } else { + process.stderr.write(`[plugin] ${p.name}: no register() export — skipping\n`); + } + } catch (err) { + process.stderr.write(`[plugin] Failed to load ${p.name}: ${err.message}\n`); + } + } + return plugins.length; +} diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md new file mode 100644 index 000000000..7007eb086 --- /dev/null +++ b/docs/dev/plugins.md @@ -0,0 +1,108 @@ +# OmniRoute CLI Plugin System + +Extend the `omniroute` CLI without modifying its core. Plugins follow the `omniroute-cmd-*` naming convention, similar to `gh extension` or `kubectl plugin`. + +## Quick start + +```bash +# Install a plugin from npm +omniroute plugin install stripe + +# Install a local plugin in development +omniroute plugin install ./my-plugin + +# List installed plugins +omniroute plugin list + +# Scaffold a new plugin +omniroute plugin scaffold myplugin +cd omniroute-cmd-myplugin +omniroute plugin install . +``` + +## Plugin anatomy + +A plugin is an npm package named `omniroute-cmd-` (or `@scope/omniroute-cmd-`). + +``` +omniroute-cmd-myplugin/ +├── package.json # must have "type": "module" and "main": "index.mjs" +├── index.mjs # exports register(program, ctx) + optional meta +└── README.md +``` + +### `package.json` + +```json +{ + "name": "omniroute-cmd-myplugin", + "version": "0.1.0", + "type": "module", + "main": "index.mjs", + "engines": { "omniroute": ">=4.0.0" }, + "keywords": ["omniroute-plugin", "omniroute-cmd"] +} +``` + +### `index.mjs` + +```js +export const meta = { + name: "myplugin", + version: "0.1.0", + description: "My plugin for OmniRoute", + omnirouteApi: ">=4.0.0", +}; + +export function register(program, ctx) { + program + .command("myplugin") + .description(meta.description) + .option("-n, --name ") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + const res = await ctx.apiFetch("/api/combos", { + baseUrl: gOpts.baseUrl, + apiKey: gOpts.apiKey, + }); + const data = await res.json(); + ctx.emit(data, gOpts); + }); +} +``` + +## Plugin context API + +The `ctx` object passed to `register(program, ctx)`: + +| Property | Type | Description | +| ---------------------------- | ---------------- | -------------------------------------------------- | +| `ctx.apiFetch(path, opts)` | `async function` | Authenticated fetch to the OmniRoute server | +| `ctx.emit(data, opts)` | `function` | Output in table/json/jsonl/csv per `--output` flag | +| `ctx.t(key)` | `async function` | i18n translation lookup | +| `ctx.withSpinner(label, fn)` | `async function` | Wraps async fn with ora spinner | +| `ctx.baseUrl` | `string` | Resolved base URL | +| `ctx.apiKey` | `string \| null` | API key if provided | + +## Discovery + +Plugins are discovered from: + +1. `~/.omniroute/plugins//` — user-local installs +2. `OMNIROUTE_PLUGIN_PATH` env var — custom directory + +Loading errors are caught and printed as warnings — a broken plugin never crashes the CLI. + +## Security + +Plugins run with the same Node.js process privileges as `omniroute`. Only install plugins from sources you trust. `omniroute plugin install` shows an explicit warning and requires `--yes` or interactive confirmation. + +## Publishing + +1. Ensure `package.json` has `"keywords": ["omniroute-plugin"]` +2. `npm publish` as normal +3. Users discover via `omniroute plugin search ` (searches npm registry) + +## Example plugin + +See [`examples/omniroute-cmd-hello/`](../../examples/omniroute-cmd-hello/) for a minimal working example. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b6681ceb3..b88d3ce8d 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -304,6 +304,7 @@ detection above). | `OMNIROUTE_CLI_TOKEN` | _(unset)_ | `bin/cli/api.mjs` | Machine-auth token injected as `x-omniroute-cli-token` header. Auto-generated in task 8.12. | | `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. | | `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. | +| `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. | --- diff --git a/examples/omniroute-cmd-hello/index.mjs b/examples/omniroute-cmd-hello/index.mjs new file mode 100644 index 000000000..6480fea8e --- /dev/null +++ b/examples/omniroute-cmd-hello/index.mjs @@ -0,0 +1,28 @@ +export const meta = { + name: "hello", + version: "0.1.0", + description: "Example OmniRoute plugin — greets the user and shows server health", + omnirouteApi: ">=4.0.0", +}; + +export function register(program, ctx) { + program + .command("hello") + .description(meta.description) + .option("-n, --name ", "Name to greet", "World") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + process.stdout.write(`Hello, ${opts.name}! 👋\n`); + + try { + const res = await ctx.apiFetch("/api/health", { + baseUrl: gOpts.baseUrl, + apiKey: gOpts.apiKey, + }); + const health = await res.json(); + ctx.emit({ greeting: `Hello, ${opts.name}!`, health }, gOpts); + } catch { + ctx.emit({ greeting: `Hello, ${opts.name}!`, health: "unavailable" }, gOpts); + } + }); +} diff --git a/examples/omniroute-cmd-hello/package.json b/examples/omniroute-cmd-hello/package.json new file mode 100644 index 000000000..ebdb46451 --- /dev/null +++ b/examples/omniroute-cmd-hello/package.json @@ -0,0 +1,14 @@ +{ + "name": "omniroute-cmd-hello", + "version": "0.1.0", + "type": "module", + "main": "index.mjs", + "description": "Example OmniRoute CLI plugin", + "engines": { + "omniroute": ">=4.0.0" + }, + "keywords": [ + "omniroute-plugin", + "omniroute-cmd" + ] +} diff --git a/tests/unit/cli-plugin-system.test.ts b/tests/unit/cli-plugin-system.test.ts new file mode 100644 index 000000000..eb11564d8 --- /dev/null +++ b/tests/unit/cli-plugin-system.test.ts @@ -0,0 +1,195 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir, homedir } from "node:os"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); + +test("bin/cli/plugins.mjs exporta discoverPlugins, loadPlugins, buildPluginContext", async () => { + const mod = await import("../../bin/cli/plugins.mjs"); + assert.equal(typeof mod.discoverPlugins, "function"); + assert.equal(typeof mod.loadPlugins, "function"); + assert.equal(typeof mod.buildPluginContext, "function"); +}); + +test("discoverPlugins retorna array vazio se nenhum plugin instalado (diretório não existe)", async () => { + const { discoverPlugins } = await import("../../bin/cli/plugins.mjs"); + const orig = process.env.OMNIROUTE_PLUGIN_PATH; + process.env.OMNIROUTE_PLUGIN_PATH = join(tmpdir(), `no-such-dir-${Date.now()}`); + try { + const plugins = await discoverPlugins(); + assert.ok(Array.isArray(plugins)); + } finally { + if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH; + else process.env.OMNIROUTE_PLUGIN_PATH = orig; + } +}); + +test("discoverPlugins descobre plugin com package.json válido", async () => { + const { discoverPlugins } = await import("../../bin/cli/plugins.mjs"); + const pluginDir = join(tmpdir(), `omniroute-plugins-test-${Date.now()}`); + const pkgDir = join(pluginDir, "omniroute-cmd-test-hello"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "omniroute-cmd-test-hello", + version: "1.0.0", + type: "module", + main: "index.mjs", + }) + ); + writeFileSync(join(pkgDir, "index.mjs"), `export function register() {}`); + + const orig = process.env.OMNIROUTE_PLUGIN_PATH; + process.env.OMNIROUTE_PLUGIN_PATH = pluginDir; + try { + const plugins = await discoverPlugins(); + assert.ok( + plugins.some((p) => p.name === "omniroute-cmd-test-hello"), + "deve encontrar o plugin" + ); + } finally { + if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH; + else process.env.OMNIROUTE_PLUGIN_PATH = orig; + try { + rmSync(pluginDir, { recursive: true }); + } catch {} + } +}); + +test("discoverPlugins ignora pacotes sem prefixo omniroute-cmd-", async () => { + const { discoverPlugins } = await import("../../bin/cli/plugins.mjs"); + const pluginDir = join(tmpdir(), `omniroute-plugins-test-${Date.now()}`); + const pkgDir = join(pluginDir, "some-unrelated-package"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ name: "some-unrelated-package", version: "1.0.0" }) + ); + + const orig = process.env.OMNIROUTE_PLUGIN_PATH; + process.env.OMNIROUTE_PLUGIN_PATH = pluginDir; + try { + const plugins = await discoverPlugins(); + assert.ok( + !plugins.some((p) => p.name === "some-unrelated-package"), + "não deve descobrir pacotes sem prefixo" + ); + } finally { + if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH; + else process.env.OMNIROUTE_PLUGIN_PATH = orig; + try { + rmSync(pluginDir, { recursive: true }); + } catch {} + } +}); + +test("loadPlugins não quebra CLI quando plugin tem erro de load (try/catch)", async () => { + const { loadPlugins } = await import("../../bin/cli/plugins.mjs"); + const pluginDir = join(tmpdir(), `omniroute-plugins-test-${Date.now()}`); + const pkgDir = join(pluginDir, "omniroute-cmd-broken"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "omniroute-cmd-broken", + version: "1.0.0", + type: "module", + main: "broken.mjs", + }) + ); + writeFileSync(join(pkgDir, "broken.mjs"), "throw new Error('intentional load error');"); + + const orig = process.env.OMNIROUTE_PLUGIN_PATH; + process.env.OMNIROUTE_PLUGIN_PATH = pluginDir; + const { Command } = await import("commander"); + const prog = new Command(); + try { + // Deve não lançar exceção + await assert.doesNotReject(async () => loadPlugins(prog)); + } finally { + if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH; + else process.env.OMNIROUTE_PLUGIN_PATH = orig; + try { + rmSync(pluginDir, { recursive: true }); + } catch {} + } +}); + +test("loadPlugins carrega plugin válido e chama register()", async () => { + const { loadPlugins } = await import("../../bin/cli/plugins.mjs"); + const pluginDir = join(tmpdir(), `omniroute-plugins-test-${Date.now()}`); + const pkgDir = join(pluginDir, "omniroute-cmd-valid"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "omniroute-cmd-valid", + version: "1.0.0", + type: "module", + main: "index.mjs", + }) + ); + // Plugin que adiciona um comando 'testcmd' + writeFileSync( + join(pkgDir, "index.mjs"), + `export function register(program) { program.command('testcmd-from-plugin'); }` + ); + + const orig = process.env.OMNIROUTE_PLUGIN_PATH; + process.env.OMNIROUTE_PLUGIN_PATH = pluginDir; + const { Command } = await import("commander"); + const prog = new Command(); + try { + const count = await loadPlugins(prog); + assert.ok(count >= 1, "deve ter carregado pelo menos 1 plugin"); + assert.ok( + prog.commands.some((c) => c.name() === "testcmd-from-plugin"), + "comando do plugin deve estar registrado" + ); + } finally { + if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH; + else process.env.OMNIROUTE_PLUGIN_PATH = orig; + try { + rmSync(pluginDir, { recursive: true }); + } catch {} + } +}); + +test("commands/plugin.mjs exporta registerPlugin", async () => { + const mod = await import("../../bin/cli/commands/plugin.mjs"); + assert.equal(typeof mod.registerPlugin, "function"); +}); + +test("registerPlugin registra subcomandos: list, install, remove, info, search, update, scaffold", async () => { + const { registerPlugin } = await import("../../bin/cli/commands/plugin.mjs"); + const { Command } = await import("commander"); + const prog = new Command().exitOverride(); + registerPlugin(prog); + const pluginCmd = prog.commands.find((c) => c.name() === "plugin"); + assert.ok(pluginCmd, "plugin command deve existir"); + const names = pluginCmd.commands.map((c) => c.name()); + for (const sub of ["list", "install", "remove", "info", "search", "update", "scaffold"]) { + assert.ok(names.includes(sub), `plugin ${sub} deve existir`); + } +}); + +test("exemplo omniroute-cmd-hello existe e tem register()", () => { + const examplePath = join(ROOT, "examples", "omniroute-cmd-hello", "index.mjs"); + assert.ok(existsSync(examplePath), "exemplo index.mjs deve existir"); + const src = readFileSync(examplePath, "utf8"); + assert.ok(src.includes("export function register"), "deve exportar register"); + assert.ok(src.includes("export const meta"), "deve exportar meta"); +}); + +test("docs/dev/plugins.md existe", () => { + const docPath = join(ROOT, "docs", "dev", "plugins.md"); + assert.ok(existsSync(docPath), "docs/dev/plugins.md deve existir"); + const src = readFileSync(docPath, "utf8"); + assert.ok(src.includes("omniroute-cmd"), "deve mencionar omniroute-cmd"); + assert.ok(src.includes("register(program, ctx)"), "deve documentar a API register"); +});