mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-09 17:28:51 +00:00
feat(cli): fase 9.4 — plugin system (omniroute-cmd-*)
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
This commit is contained in:
parent
cd62899f31
commit
b3e5ee3333
12 changed files with 654 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>", "Port the server is running on", "20128")
|
||||
|
|
|
|||
192
bin/cli/commands/plugin.mjs
Normal file
192
bin/cli/commands/plugin.mjs
Normal file
|
|
@ -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 <name>\n`);
|
||||
}
|
||||
});
|
||||
|
||||
plugin
|
||||
.command("install <name>")
|
||||
.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 <name>")
|
||||
.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 <name>")
|
||||
.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 <name>")
|
||||
.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`);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
90
bin/cli/plugins.mjs
Normal file
90
bin/cli/plugins.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
108
docs/dev/plugins.md
Normal file
108
docs/dev/plugins.md
Normal file
|
|
@ -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-<name>` (or `@scope/omniroute-cmd-<name>`).
|
||||
|
||||
```
|
||||
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 <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/<name>/` — 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 <query>` (searches npm registry)
|
||||
|
||||
## Example plugin
|
||||
|
||||
See [`examples/omniroute-cmd-hello/`](../../examples/omniroute-cmd-hello/) for a minimal working example.
|
||||
|
|
@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
28
examples/omniroute-cmd-hello/index.mjs
Normal file
28
examples/omniroute-cmd-hello/index.mjs
Normal file
|
|
@ -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>", "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);
|
||||
}
|
||||
});
|
||||
}
|
||||
14
examples/omniroute-cmd-hello/package.json
Normal file
14
examples/omniroute-cmd-hello/package.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
195
tests/unit/cli-plugin-system.test.ts
Normal file
195
tests/unit/cli-plugin-system.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue