From 31031422d334f5b46de37917c95c0833bfda8c92 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 14 May 2026 22:09:53 -0300 Subject: [PATCH] feat(cli): adotar Commander.js como framework CLI (Fase 1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Instala commander@^14.0.0 com suporte nativo a ESM - Cria bin/cli/program.mjs: programa raiz com opções globais (--output, --quiet, --no-color, --timeout, --api-key, --base-url) - Cria bin/cli/commands/registry.mjs: adaptadores legados que delegam para os run*Command existentes sem quebrar compatibilidade - Cria bin/cli/commands/serve.mjs: ação padrão (isDefault: true) com lógica de spawn extraída de omniroute.mjs, imports de runtime lazy - Cria bin/cli/commands/reset-encrypted-columns.mjs: bypass de recuperação - Refatora bin/omniroute.mjs: ~500 → ~90 linhas, delega ao Commander - Adiciona strings i18n program.* e serve.description/port/no_open/daemon em en.json e pt-BR.json - Adiciona 21 testes em tests/unit/cli-program.test.ts --- bin/cli/commands/registry.mjs | 82 +++ bin/cli/commands/reset-encrypted-columns.mjs | 96 ++++ bin/cli/commands/serve.mjs | 205 ++++++++ bin/cli/locales/en.json | 16 +- bin/cli/locales/pt-BR.json | 16 +- bin/cli/program.mjs | 33 ++ bin/omniroute.mjs | 504 +------------------ package-lock.json | 18 +- package.json | 1 + tests/unit/cli-program.test.ts | 159 ++++++ 10 files changed, 643 insertions(+), 487 deletions(-) create mode 100644 bin/cli/commands/registry.mjs create mode 100644 bin/cli/commands/reset-encrypted-columns.mjs create mode 100644 bin/cli/commands/serve.mjs create mode 100644 bin/cli/program.mjs create mode 100644 tests/unit/cli-program.test.ts diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs new file mode 100644 index 0000000000..35d569903e --- /dev/null +++ b/bin/cli/commands/registry.mjs @@ -0,0 +1,82 @@ +import { registerServe } from "./serve.mjs"; +import { runDoctorCommand } from "./doctor.mjs"; +import { runSetupCommand } from "./setup.mjs"; +import { runProvidersCommand } from "./providers.mjs"; +import { runProviderCommand } from "./provider-cmd.mjs"; +import { runConfigCommand } from "./config.mjs"; +import { runStatusCommand } from "./status.mjs"; +import { runLogsCommand } from "./logs.mjs"; +import { runUpdateCommand } from "./update.mjs"; +import { t } from "../i18n.mjs"; + +function argvAfter(cmdName) { + const idx = process.argv.findIndex((a, i) => i >= 2 && a === cmdName); + return idx >= 0 ? process.argv.slice(idx + 1) : []; +} + +function legacyAction(name, handler) { + return async () => { + const exitCode = await handler(argvAfter(name)); + process.exit(exitCode ?? 0); + }; +} + +export function registerCommands(program) { + registerServe(program); + + program + .command("doctor") + .description(t("doctor.title")) + .allowUnknownOption() + .allowExcessArguments() + .action(legacyAction("doctor", runDoctorCommand)); + + program + .command("setup") + .description(t("setup.title")) + .allowUnknownOption() + .allowExcessArguments() + .action(legacyAction("setup", runSetupCommand)); + + program + .command("providers") + .description(t("providers.title")) + .allowUnknownOption() + .allowExcessArguments() + .action(legacyAction("providers", runProvidersCommand)); + + program + .command("provider") + .description(t("providers.title")) + .allowUnknownOption() + .allowExcessArguments() + .action(legacyAction("provider", runProviderCommand)); + + program + .command("config") + .description("Show or update CLI tool configuration") + .allowUnknownOption() + .allowExcessArguments() + .action(legacyAction("config", runConfigCommand)); + + program + .command("status") + .description("Show OmniRoute status dashboard") + .allowUnknownOption() + .allowExcessArguments() + .action(legacyAction("status", runStatusCommand)); + + program + .command("logs") + .description("Stream request logs") + .allowUnknownOption() + .allowExcessArguments() + .action(legacyAction("logs", runLogsCommand)); + + program + .command("update") + .description(t("update.checking")) + .allowUnknownOption() + .allowExcessArguments() + .action(legacyAction("update", runUpdateCommand)); +} diff --git a/bin/cli/commands/reset-encrypted-columns.mjs b/bin/cli/commands/reset-encrypted-columns.mjs new file mode 100644 index 0000000000..42803afcb9 --- /dev/null +++ b/bin/cli/commands/reset-encrypted-columns.mjs @@ -0,0 +1,96 @@ +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir, platform } from "node:os"; + +export async function runResetEncryptedColumns(argv) { + const dataDir = (() => { + const configured = process.env.DATA_DIR?.trim(); + if (configured) return configured; + if (platform() === "win32") { + const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming"); + return join(appData, "omniroute"); + } + const xdg = process.env.XDG_CONFIG_HOME?.trim(); + if (xdg) return join(xdg, "omniroute"); + return join(homedir(), ".omniroute"); + })(); + + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + console.log(`\x1b[33m⚠ No database found at ${dbPath}\x1b[0m`); + return 0; + } + + const force = argv.includes("--force"); + if (!force) { + console.log(` + \x1b[1m\x1b[33m⚠ WARNING: This will erase all encrypted credentials\x1b[0m + + This command will NULL out the following columns in provider_connections: + • api_key + • access_token + • refresh_token + • id_token + + Provider metadata (name, provider_id, settings) will be preserved. + You will need to re-authenticate all providers after this operation. + + Database: ${dbPath} + + \x1b[1mTo confirm, run:\x1b[0m + omniroute reset-encrypted-columns --force + `); + return 0; + } + + try { + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const countResult = db + .prepare( + `SELECT COUNT(*) as cnt FROM provider_connections + WHERE api_key LIKE 'enc:v1:%' + OR access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%'` + ) + .get(); + + const affected = countResult?.cnt ?? 0; + + if (affected === 0) { + console.log("\x1b[32m✔ No encrypted credentials found — nothing to reset.\x1b[0m"); + db.close(); + return 0; + } + + const result = db + .prepare( + `UPDATE provider_connections + SET api_key = NULL, + access_token = NULL, + refresh_token = NULL, + id_token = NULL + WHERE api_key LIKE 'enc:v1:%' + OR access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%'` + ) + .run(); + + db.close(); + + console.log( + `\x1b[32m✔ Reset ${result.changes} provider connection(s).\x1b[0m\n` + + ` Re-authenticate your providers in the dashboard or re-add API keys.\n` + ); + return 0; + } catch (err) { + console.error(`\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err.message || err}`); + return 1; + } +} diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs new file mode 100644 index 0000000000..192e742686 --- /dev/null +++ b/bin/cli/commands/serve.mjs @@ -0,0 +1,205 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { platform } from "node:os"; +import { t } from "../i18n.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", "..", ".."); +const APP_DIR = join(ROOT, "app"); + +function parsePort(value, fallback) { + const parsed = parseInt(String(value), 10); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; +} + +export function registerServe(program) { + program + .command("serve", { isDefault: true }) + .description(t("serve.description")) + .option("--port ", t("serve.port"), "20128") + .option("--no-open", t("serve.no_open")) + .option("--daemon", t("serve.daemon")) + .action(async (opts) => { + await runServe(opts); + }); +} + +async function runServe(opts = {}) { + const { isNativeBinaryCompatible } = + await import("../../../scripts/build/native-binary-compat.mjs"); + const { getNodeRuntimeSupport, getNodeRuntimeWarning } = + await import("../../nodeRuntimeSupport.mjs"); + + const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128); + const apiPort = parsePort(process.env.API_PORT ?? String(port), port); + const dashboardPort = parsePort(process.env.DASHBOARD_PORT ?? String(port), port); + const noOpen = opts.open === false; + + console.log(` +\x1b[36m ____ _ ____ _ + / __ \\ (_) __ \\ | | + | | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___ + | | | | '_ \` _ \\| '_ \\ | _ // _ \\| | | | __/ _ \\ + | |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/ + \\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___| +\x1b[0m`); + + const nodeSupport = getNodeRuntimeSupport(); + if (!nodeSupport.nodeCompatible) { + const runtimeWarning = getNodeRuntimeWarning() || "Unsupported Node.js runtime detected."; + console.warn(`\x1b[33m ⚠ Warning: You are running Node.js ${process.versions.node}. + ${runtimeWarning} + + Supported secure runtimes: ${nodeSupport.supportedDisplay} + Recommended: use Node.js ${nodeSupport.recommendedVersion} or newer on the 22.x LTS line. + Workaround: npm rebuild better-sqlite3\x1b[0m +`); + } + + const serverWsJs = join(APP_DIR, "server-ws.mjs"); + const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js"); + + if (!existsSync(serverJs)) { + console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs); + console.error(" The package may not have been built correctly."); + console.error(""); + const nodeExec = process.execPath || ""; + const isMise = nodeExec.includes("mise") || nodeExec.includes(".local/share/mise"); + const isNvm = nodeExec.includes(".nvm") || nodeExec.includes("nvm"); + if (isMise) { + console.error( + " \x1b[33m⚠ mise detected:\x1b[0m If you installed via `npm install -g omniroute`," + ); + console.error(" try: \x1b[36mnpx omniroute@latest\x1b[0m (downloads a fresh copy)"); + console.error(" or: \x1b[36mmise exec -- npx omniroute\x1b[0m"); + } else if (isNvm) { + console.error( + " \x1b[33m⚠ nvm detected:\x1b[0m Try reinstalling after loading the correct Node version:" + ); + console.error(" \x1b[36mnvm use --lts && npm install -g omniroute\x1b[0m"); + } else { + console.error(" Try: \x1b[36mnpm install -g omniroute\x1b[0m (reinstall)"); + console.error(" Or: \x1b[36mnpx omniroute@latest\x1b[0m"); + } + process.exit(1); + } + + const sqliteBinary = join( + APP_DIR, + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" + ); + if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) { + console.error( + "\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m" + ); + console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`); + if (platform() === "darwin") { + console.error(" If build tools are missing: xcode-select --install"); + } + process.exit(1); + } + + console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`); + + const rawMemory = parseInt(process.env.OMNIROUTE_MEMORY_MB || "512", 10); + const memoryLimit = + Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512; + + const env = { + ...process.env, + OMNIROUTE_PORT: String(port), + PORT: String(dashboardPort), + DASHBOARD_PORT: String(dashboardPort), + API_PORT: String(apiPort), + HOSTNAME: "0.0.0.0", + NODE_ENV: "production", + NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`, + }; + + const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], { + cwd: APP_DIR, + env, + stdio: "pipe", + }); + + let started = false; + + server.stdout.on("data", (data) => { + const text = data.toString(); + process.stdout.write(text); + if ( + !started && + (text.includes("Ready") || text.includes("started") || text.includes("listening")) + ) { + started = true; + onReady(dashboardPort, apiPort, noOpen); + } + }); + + server.stderr.on("data", (data) => { + process.stderr.write(data); + }); + + server.on("error", (err) => { + console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message); + process.exit(1); + }); + + server.on("exit", (code) => { + if (code !== 0 && code !== null) { + console.error(`\x1b[31m✖ Server exited with code ${code}\x1b[0m`); + } + process.exit(code ?? 0); + }); + + function shutdown() { + console.log("\n\x1b[33m⏹ Shutting down OmniRoute...\x1b[0m"); + server.kill("SIGTERM"); + setTimeout(() => { + server.kill("SIGKILL"); + process.exit(0); + }, 5000); + } + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + setTimeout(() => { + if (!started) { + started = true; + onReady(dashboardPort, apiPort, noOpen); + } + }, 15000); +} + +async function onReady(dashboardPort, apiPort, noOpen) { + const dashboardUrl = `http://localhost:${dashboardPort}`; + const apiUrl = `http://localhost:${apiPort}`; + + console.log(` + \x1b[32m✔ OmniRoute is running!\x1b[0m + + \x1b[1m Dashboard:\x1b[0m ${dashboardUrl} + \x1b[1m API Base:\x1b[0m ${apiUrl}/v1 + + \x1b[2m Point your CLI tool (Cursor, Cline, Codex) to:\x1b[0m + \x1b[33m ${apiUrl}/v1\x1b[0m + + \x1b[2m Press Ctrl+C to stop\x1b[0m + `); + + if (!noOpen) { + try { + const open = await import("open"); + await open.default(dashboardUrl); + } catch { + // open is optional — skip if unavailable + } + } +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 4711dd4ca2..d9693d2221 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -63,11 +63,15 @@ "confirmDelete": "Delete combo {name}?" }, "serve": { + "description": "Start the OmniRoute server (default action)", "starting": "Starting OmniRoute server on port {port}...", "ready": "Ready at http://localhost:{port}", "stopping": "Stopping server (PID {pid})...", "stopped": "Server stopped.", - "notRunning": "Server is not running." + "notRunning": "Server is not running.", + "port": "Port to listen on (default: 20128)", + "no_open": "Do not open browser automatically", + "daemon": "Run server as a background daemon" }, "backup": { "title": "Backup", @@ -102,5 +106,15 @@ "created": "Tunnel created: {url}", "stopped": "Tunnel stopped.", "confirmStop": "Stop tunnel {id}?" + }, + "program": { + "description": "OmniRoute — Smart AI Router with Auto Fallback", + "version": "Print version and exit", + "output": "Output format (table, json, jsonl, csv)", + "quiet": "Suppress non-essential output", + "no_color": "Disable colored output", + "timeout": "HTTP request timeout in milliseconds", + "api_key": "API key for OmniRoute server", + "base_url": "OmniRoute server base URL" } } diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index b2c0f2c07c..cca3c122b6 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -63,11 +63,15 @@ "confirmDelete": "Excluir combo {name}?" }, "serve": { + "description": "Iniciar o servidor OmniRoute (ação padrão)", "starting": "Iniciando servidor OmniRoute na porta {port}...", "ready": "Pronto em http://localhost:{port}", "stopping": "Parando servidor (PID {pid})...", "stopped": "Servidor parado.", - "notRunning": "Servidor não está em execução." + "notRunning": "Servidor não está em execução.", + "port": "Porta de escuta (padrão: 20128)", + "no_open": "Não abrir navegador automaticamente", + "daemon": "Executar servidor em segundo plano" }, "backup": { "title": "Backup", @@ -102,5 +106,15 @@ "created": "Túnel criado: {url}", "stopped": "Túnel parado.", "confirmStop": "Parar túnel {id}?" + }, + "program": { + "description": "OmniRoute — Roteador de IA com Fallback Automático", + "version": "Exibir versão e sair", + "output": "Formato de saída (table, json, jsonl, csv)", + "quiet": "Suprimir saída não essencial", + "no_color": "Desativar saída colorida", + "timeout": "Timeout de requisições HTTP em milissegundos", + "api_key": "Chave de API para o servidor OmniRoute", + "base_url": "URL base do servidor OmniRoute" } } diff --git a/bin/cli/program.mjs b/bin/cli/program.mjs new file mode 100644 index 0000000000..4433fc3430 --- /dev/null +++ b/bin/cli/program.mjs @@ -0,0 +1,33 @@ +import { Command, Option } from "commander"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { registerCommands } from "./commands/registry.mjs"; +import { t } from "./i18n.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf8")); + +export function createProgram() { + const program = new Command(); + + program + .name("omniroute") + .description(t("program.description")) + .version(pkg.version, "-v, --version", t("program.version")) + .addOption( + new Option("--output ", t("program.output")) + .choices(["table", "json", "jsonl", "csv"]) + .default("table") + ) + .addOption(new Option("-q, --quiet", t("program.quiet"))) + .addOption(new Option("--no-color", t("program.no_color"))) + .addOption(new Option("--timeout ", t("program.timeout")).default("30000")) + .addOption(new Option("--api-key ", t("program.api_key")).env("OMNIROUTE_API_KEY")) + .addOption(new Option("--base-url ", t("program.base_url")).env("OMNIROUTE_BASE_URL")) + .showHelpAfterError(true) + .exitOverride(); + + registerCommands(program); + return program; +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index b40f63d9ab..9049862ab1 100644 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -1,34 +1,23 @@ #!/usr/bin/env node /** - * OmniRoute CLI — Smart AI Router with Auto Fallback + * OmniRoute CLI entry point. * - * Usage: - * omniroute Start the server (default port 20128) - * omniroute --port 3000 Start on custom port - * omniroute --no-open Start without opening browser - * omniroute --mcp Start MCP server (stdio transport for IDEs) - * omniroute setup Interactive guided setup - * omniroute doctor Run local health checks - * omniroute providers available List supported providers - * omniroute providers list List configured providers - * omniroute reset-encrypted-columns Reset broken encrypted credentials - * omniroute --help Show help - * omniroute --version Show version + * Special bypasses (handled before Commander): + * --mcp Start MCP server over stdio + * reset-encrypted-columns Recovery tool for broken encrypted credentials + * + * All other commands are routed through Commander (bin/cli/program.mjs). */ -import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { homedir, platform } from "node:os"; -import { isNativeBinaryCompatible } from "../scripts/native-binary-compat.mjs"; -import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const ROOT = join(__dirname, ".."); -const APP_DIR = join(ROOT, "app"); function loadEnvFile() { const envPaths = []; @@ -77,287 +66,7 @@ function loadEnvFile() { loadEnvFile(); -const args = process.argv.slice(2); -const command = args[0]; -const CLI_COMMANDS = new Set([ - "doctor", - "providers", - "setup", - "config", - "status", - "logs", - "update", - "provider", -]); - -if (CLI_COMMANDS.has(command)) { - try { - const { runCliCommand } = await import(pathToFileURL(join(ROOT, "bin", "cli", "index.mjs")).href); - const exitCode = await runCliCommand(command, args.slice(1), { rootDir: ROOT }); - process.exit(exitCode ?? 0); - } catch (err) { - console.error("\x1b[31m✖ CLI command failed:\x1b[0m", err.message || err); - process.exit(1); - } -} - -if (args.includes("--help") || args.includes("-h")) { - console.log(` - \x1b[1m\x1b[36m⚡ OmniRoute\x1b[0m — Smart AI Router with Auto Fallback - - \x1b[1mUsage:\x1b[0m - omniroute Start the server - omniroute setup Interactive guided setup - omniroute doctor Run local health checks - omniroute providers available List supported providers - omniroute providers list List configured providers - omniroute --port Use custom API port (default: 20128) - omniroute --no-open Don't open browser automatically - omniroute --mcp Start MCP server (stdio transport for IDEs) - omniroute reset-encrypted-columns Reset encrypted credentials (recovery) - - \x1b[1mServer Management:\x1b[0m - omniroute serve Start the OmniRoute server - omniroute stop Stop the running server - omniroute restart Restart the server - omniroute dashboard Open dashboard in browser - omniroute open Alias for dashboard (same as dashboard) - - \x1b[1mCLI Integration Suite:\x1b[0m - omniroute setup Interactive wizard to configure CLI tools - omniroute doctor Run health diagnostics - omniroute status Show comprehensive status - omniroute logs Stream request logs (--json, --search, --follow) - omniroute config show Display current configuration - - \x1b[1mProvider & Keys:\x1b[0m - omniroute provider list List available providers - omniroute provider add Add OmniRoute as provider - omniroute keys add Add API key for provider - omniroute keys list List configured API keys - omniroute keys remove Remove API key - - \x1b[1mModels & Combos:\x1b[0m - omniroute models List available models (--json, --search) - omniroute models Filter models by provider - omniroute combo list List routing combos - omniroute combo switch Switch active combo - omniroute combo create Create new combo - omniroute combo delete Delete a combo - - \x1b[1mBackup & Restore:\x1b[0m - omniroute backup Create backup of config & DB - omniroute restore Restore from backup (list or specify timestamp) - - \x1b[1mMonitoring:\x1b[0m - omniroute health Detailed health (breakers, cache, memory) - omniroute quota Show provider quota usage - omniroute cache Show cache status - omniroute cache clear Clear semantic/signature cache - - \x1b[1mProtocols:\x1b[0m - omniroute mcp status MCP server status - omniroute mcp restart Restart MCP server - omniroute a2a status A2A server status - omniroute a2a card Show A2A agent card - - \x1b[1mTunnels & Network:\x1b[0m - omniroute tunnel list List active tunnels - omniroute tunnel create Create tunnel (cloudflare/tailscale/ngrok) - omniroute tunnel stop Stop a tunnel - - \x1b[1mEnvironment:\x1b[0m - omniroute env show Show environment variables - omniroute env get Get specific env var - omniroute env set Set env var (temporary) - - \x1b[1mTools & Utils:\x1b[0m - omniroute test Test provider connectivity - omniroute update Check for updates - omniroute completion Generate shell completion - - omniroute --help Show this help - omniroute --version Show version - - \x1b[1mMCP Integration:\x1b[0m - The --mcp flag starts an MCP server over stdio, exposing OmniRoute - tools for AI agents in VS Code, Cursor, Claude Desktop, and Copilot. - - Available tools: omniroute_get_health, omniroute_list_combos, - omniroute_check_quota, omniroute_route_request, and more. - - \x1b[1mConfig:\x1b[0m - Loads .env from: ~/.omniroute/.env or ./.env - Memory limit: OMNIROUTE_MEMORY_MB (default: 512) - - \x1b[1mSetup:\x1b[0m - omniroute setup --password - omniroute setup --add-provider --provider openai --api-key - omniroute setup --non-interactive - - \x1b[1mDoctor:\x1b[0m - omniroute doctor - omniroute doctor --json - omniroute doctor --no-liveness - - \x1b[1mProviders:\x1b[0m - omniroute providers available - omniroute providers available --search openai - omniroute providers available --category api-key - omniroute providers list - omniroute providers test - omniroute providers test-all - omniroute providers validate - - \x1b[1mCLI Tools:\x1b[0m - omniroute config list List CLI tool configuration status - omniroute config get Show config for a specific tool - omniroute config set Write config for a tool - omniroute config validate Validate config without writing - omniroute status Offline status dashboard - omniroute logs [--follow] [--filter] Stream usage logs - omniroute update [--check] [--dry-run] Check or apply OmniRoute update - omniroute provider add Add a provider connection - omniroute provider list List configured providers - omniroute provider test Test a provider connection - - \x1b[1mAfter starting:\x1b[0m - Dashboard: http://localhost: - API: http://localhost:/v1 - - \x1b[1mConnect your tools:\x1b[0m - Set your CLI tool (Cursor, Cline, Codex, etc.) to use: - \x1b[33mhttp://localhost:/v1\x1b[0m - `); - process.exit(0); -} - -if (args.includes("--version") || args.includes("-v")) { - try { - const { version } = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); - console.log(version); - } catch { - console.log("unknown"); - } - process.exit(0); -} - -// ── CLI Integration Suite subcommands ─────────────────────────────────────── -const subcommands = [ - "setup", "doctor", "status", "logs", "provider", "config", "test", "update", - "serve", "stop", "restart", - "keys", "models", "combo", - "completion", "dashboard", - "backup", "restore", "quota", "health", - "cache", "mcp", "a2a", "tunnel", - "env", "open" -]; -const subcommand = args[0]; - -if (subcommands.includes(subcommand)) { - const { runSubcommand } = await import("./cli-commands.mjs"); - await runSubcommand(subcommand, args.slice(1)); - process.exit(0); -} - -// ── reset-encrypted-columns subcommand ────────────────────────────────────── -// Recovery tool for users who lost STORAGE_ENCRYPTION_KEY after upgrade (#1622) -if (args.includes("reset-encrypted-columns")) { - const dataDir = (() => { - const configured = process.env.DATA_DIR?.trim(); - if (configured) return configured; - if (platform() === "win32") { - const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming"); - return join(appData, "omniroute"); - } - const xdg = process.env.XDG_CONFIG_HOME?.trim(); - if (xdg) return join(xdg, "omniroute"); - return join(homedir(), ".omniroute"); - })(); - - const dbPath = join(dataDir, "storage.sqlite"); - - if (!existsSync(dbPath)) { - console.log(`\x1b[33m⚠ No database found at ${dbPath}\x1b[0m`); - process.exit(0); - } - - const force = args.includes("--force"); - if (!force) { - console.log(` - \x1b[1m\x1b[33m⚠ WARNING: This will erase all encrypted credentials\x1b[0m - - This command will NULL out the following columns in provider_connections: - • api_key - • access_token - • refresh_token - • id_token - - Provider metadata (name, provider_id, settings) will be preserved. - You will need to re-authenticate all providers after this operation. - - Database: ${dbPath} - - \x1b[1mTo confirm, run:\x1b[0m - omniroute reset-encrypted-columns --force - `); - process.exit(0); - } - - try { - const { createRequire } = await import("node:module"); - const require = createRequire(import.meta.url); - const Database = require("better-sqlite3"); - const db = new Database(dbPath); - - const countResult = db - .prepare( - `SELECT COUNT(*) as cnt FROM provider_connections - WHERE api_key LIKE 'enc:v1:%' - OR access_token LIKE 'enc:v1:%' - OR refresh_token LIKE 'enc:v1:%' - OR id_token LIKE 'enc:v1:%'` - ) - .get(); - - const affected = countResult?.cnt ?? 0; - - if (affected === 0) { - console.log("\x1b[32m✔ No encrypted credentials found — nothing to reset.\x1b[0m"); - db.close(); - process.exit(0); - } - - const result = db - .prepare( - `UPDATE provider_connections - SET api_key = NULL, - access_token = NULL, - refresh_token = NULL, - id_token = NULL - WHERE api_key LIKE 'enc:v1:%' - OR access_token LIKE 'enc:v1:%' - OR refresh_token LIKE 'enc:v1:%' - OR id_token LIKE 'enc:v1:%'` - ) - .run(); - - db.close(); - - console.log( - `\x1b[32m✔ Reset ${result.changes} provider connection(s).\x1b[0m\n` + - ` Re-authenticate your providers in the dashboard or re-add API keys.\n` - ); - } catch (err) { - console.error( - `\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err.message || err}` - ); - process.exit(1); - } - process.exit(0); -} - -if (args.includes("--mcp")) { +if (process.argv.includes("--mcp")) { try { const { startMcpCli } = await import(pathToFileURL(join(ROOT, "bin", "mcp-server.mjs")).href); await startMcpCli(ROOT); @@ -368,189 +77,22 @@ if (args.includes("--mcp")) { process.exit(0); } -function parsePort(value, fallback) { - const parsed = parseInt(String(value), 10); - return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; -} - -let port = parsePort(process.env.PORT || "20128", 20128); -const portIdx = args.indexOf("--port"); -if (portIdx !== -1 && args[portIdx + 1]) { - const cliPort = parsePort(args[portIdx + 1], null); - if (cliPort === null) { - console.error("\x1b[31m✖ Invalid port number\x1b[0m"); - process.exit(1); - } - port = cliPort; -} - -const apiPort = parsePort(process.env.API_PORT || String(port), port); -const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port); -const noOpen = args.includes("--no-open"); - -console.log(` -\x1b[36m ____ _ ____ _ - / __ \\\\ (_) __ \\\\ | | - | | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___ - | | | | '_ \` _ \\\\| '_ \\\\ | _ // _ \\\\| | | | __/ _ \\\\ - | |__| | | | | | | | | | | | \\\\ \\\\ (_) | |_| | || __/ - \\\\____/|_| |_| |_|_| |_|_|_| \\\\_\\\\___/ \\\\__,_|\\\\__\\\\___| -\x1b[0m`); - -const nodeSupport = getNodeRuntimeSupport(); -if (!nodeSupport.nodeCompatible) { - const runtimeWarning = getNodeRuntimeWarning() || "Unsupported Node.js runtime detected."; - console.warn(`\x1b[33m ⚠ Warning: You are running Node.js ${process.versions.node}. - ${runtimeWarning} - - Supported secure runtimes: ${nodeSupport.supportedDisplay} - Recommended: use Node.js ${nodeSupport.recommendedVersion} or newer on the 22.x LTS line. - Workaround: npm rebuild better-sqlite3\x1b[0m -`); -} - -const serverWsJs = join(APP_DIR, "server-ws.mjs"); -const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js"); - -if (!existsSync(serverJs)) { - console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs); - console.error(" The package may not have been built correctly."); - console.error(""); - const nodeExec = process.execPath || ""; - const isMise = nodeExec.includes("mise") || nodeExec.includes(".local/share/mise"); - const isNvm = nodeExec.includes(".nvm") || nodeExec.includes("nvm"); - if (isMise) { - console.error( - " \x1b[33m⚠ mise detected:\x1b[0m If you installed via `npm install -g omniroute`," - ); - console.error(" try: \x1b[36mnpx omniroute@latest\x1b[0m (downloads a fresh copy)"); - console.error(" or: \x1b[36mmise exec -- npx omniroute\x1b[0m"); - } else if (isNvm) { - console.error( - " \x1b[33m⚠ nvm detected:\x1b[0m Try reinstalling after loading the correct Node version:" - ); - console.error(" \x1b[36mnvm use --lts && npm install -g omniroute\x1b[0m"); - } else { - console.error(" Try: \x1b[36mnpm install -g omniroute\x1b[0m (reinstall)"); - console.error(" Or: \x1b[36mnpx omniroute@latest\x1b[0m"); - } - process.exit(1); -} - -const sqliteBinary = join( - APP_DIR, - "node_modules", - "better-sqlite3", - "build", - "Release", - "better_sqlite3.node" -); -if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) { - console.error( - "\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m" +if (process.argv.includes("reset-encrypted-columns")) { + const { runResetEncryptedColumns } = await import( + pathToFileURL(join(ROOT, "bin", "cli", "commands", "reset-encrypted-columns.mjs")).href ); - console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`); - if (platform() === "darwin") { - console.error(" If build tools are missing: xcode-select --install"); - } + const exitCode = await runResetEncryptedColumns(process.argv.slice(2)); + process.exit(exitCode ?? 0); +} + +try { + const { createProgram } = await import( + pathToFileURL(join(ROOT, "bin", "cli", "program.mjs")).href + ); + const program = createProgram(); + await program.parseAsync(process.argv); +} catch (err) { + if (err.exitCode !== undefined) process.exit(err.exitCode); + console.error("\x1b[31m✖", err.message, "\x1b[0m"); process.exit(1); } - -console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`); - -const rawMemory = parseInt(process.env.OMNIROUTE_MEMORY_MB || "512", 10); -const memoryLimit = - Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512; - -const env = { - ...process.env, - OMNIROUTE_PORT: String(port), - PORT: String(dashboardPort), - DASHBOARD_PORT: String(dashboardPort), - API_PORT: String(apiPort), - HOSTNAME: "0.0.0.0", - NODE_ENV: "production", - NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`, -}; - -const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], { - cwd: APP_DIR, - env, - stdio: "pipe", -}); - -let started = false; - -server.stdout.on("data", (data) => { - const text = data.toString(); - process.stdout.write(text); - - if ( - !started && - (text.includes("Ready") || text.includes("started") || text.includes("listening")) - ) { - started = true; - onReady(); - } -}); - -server.stderr.on("data", (data) => { - process.stderr.write(data); -}); - -server.on("error", (err) => { - console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message); - process.exit(1); -}); - -server.on("exit", (code) => { - if (code !== 0 && code !== null) { - console.error(`\x1b[31m✖ Server exited with code ${code}\x1b[0m`); - } - process.exit(code ?? 0); -}); - -function shutdown() { - console.log("\n\x1b[33m⏹ Shutting down OmniRoute...\x1b[0m"); - server.kill("SIGTERM"); - setTimeout(() => { - server.kill("SIGKILL"); - process.exit(0); - }, 5000); -} - -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); - -async function onReady() { - const dashboardUrl = `http://localhost:${dashboardPort}`; - const apiUrl = `http://localhost:${apiPort}`; - - console.log(` - \x1b[32m✔ OmniRoute is running!\x1b[0m - - \x1b[1m Dashboard:\x1b[0m ${dashboardUrl} - \x1b[1m API Base:\x1b[0m ${apiUrl}/v1 - - \x1b[2m Point your CLI tool (Cursor, Cline, Codex) to:\x1b[0m - \x1b[33m ${apiUrl}/v1\x1b[0m - - \x1b[2m Press Ctrl+C to stop\x1b[0m - `); - - if (!noOpen) { - try { - const open = await import("open"); - await open.default(dashboardUrl); - } catch { - // open is optional — if not available, just skip. - } - } -} - -setTimeout(() => { - if (!started) { - started = true; - onReady(); - } -}, 15000); diff --git a/package-lock.json b/package-lock.json index 4c6fbde29d..c8a5b8d212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", "bottleneck": "^2.19.5", + "commander": "^14.0.3", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fuse.js": "^7.3.0", @@ -6327,12 +6328,12 @@ } }, "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=20" } }, "node_modules/concat-map": { @@ -6746,6 +6747,15 @@ "node": ">=12" } }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/d3-dsv/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index 1b19818227..66eee61491 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", "bottleneck": "^2.19.5", + "commander": "^14.0.3", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fuse.js": "^7.3.0", diff --git a/tests/unit/cli-program.test.ts b/tests/unit/cli-program.test.ts new file mode 100644 index 0000000000..4ce9bf5934 --- /dev/null +++ b/tests/unit/cli-program.test.ts @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createProgram } from "../../bin/cli/program.mjs"; + +// ─── program structure ──────────────────────────────────────────────────────── + +test("createProgram returns a Command instance", () => { + const program = createProgram(); + assert.ok(program, "program is defined"); + assert.equal(typeof program.parseAsync, "function", "has parseAsync"); + assert.equal(typeof program.commands, "object", "has commands array"); +}); + +test("program name is 'omniroute'", () => { + const program = createProgram(); + assert.equal(program.name(), "omniroute"); +}); + +test("program description is non-empty", () => { + const program = createProgram(); + const desc = program.description(); + assert.ok(desc && desc.length > 0, `description is non-empty, got: ${desc}`); +}); + +test("program version is non-empty semver", () => { + const program = createProgram(); + const ver = program.version(); + assert.ok(ver && /^\d+\.\d+\.\d+/.test(ver), `version is semver, got: ${ver}`); +}); + +// ─── global options ─────────────────────────────────────────────────────────── + +test("program has --output option with choices", () => { + const program = createProgram(); + const opt = program.options.find((o) => o.long === "--output"); + assert.ok(opt, "--output option exists"); + assert.deepEqual(opt.argChoices, ["table", "json", "jsonl", "csv"]); +}); + +test("program has --quiet / -q option", () => { + const program = createProgram(); + const opt = program.options.find((o) => o.long === "--quiet"); + assert.ok(opt, "--quiet option exists"); + assert.equal(opt.short, "-q"); +}); + +test("program has --timeout option", () => { + const program = createProgram(); + const opt = program.options.find((o) => o.long === "--timeout"); + assert.ok(opt, "--timeout option exists"); +}); + +test("program has --api-key option bound to env", () => { + const program = createProgram(); + const opt = program.options.find((o) => o.long === "--api-key"); + assert.ok(opt, "--api-key option exists"); + assert.equal(opt.envVar, "OMNIROUTE_API_KEY"); +}); + +test("program has --base-url option bound to env", () => { + const program = createProgram(); + const opt = program.options.find((o) => o.long === "--base-url"); + assert.ok(opt, "--base-url option exists"); + assert.equal(opt.envVar, "OMNIROUTE_BASE_URL"); +}); + +// ─── registered commands ────────────────────────────────────────────────────── + +test("program registers 'serve' command", () => { + const program = createProgram(); + const cmd = program.commands.find((c) => c.name() === "serve"); + assert.ok(cmd, "serve command exists"); +}); + +test("serve command is the default command", () => { + const program = createProgram(); + assert.equal( + (program as any)._defaultCommandName, + "serve", + "program._defaultCommandName is 'serve'" + ); +}); + +test("program registers 'doctor' command", () => { + const program = createProgram(); + const cmd = program.commands.find((c) => c.name() === "doctor"); + assert.ok(cmd, "doctor command exists"); +}); + +test("program registers 'setup' command", () => { + const program = createProgram(); + const cmd = program.commands.find((c) => c.name() === "setup"); + assert.ok(cmd, "setup command exists"); +}); + +test("program registers 'providers' command", () => { + const program = createProgram(); + const cmd = program.commands.find((c) => c.name() === "providers"); + assert.ok(cmd, "providers command exists"); +}); + +test("program registers 'config' command", () => { + const program = createProgram(); + const cmd = program.commands.find((c) => c.name() === "config"); + assert.ok(cmd, "config command exists"); +}); + +test("program registers 'status' command", () => { + const program = createProgram(); + const cmd = program.commands.find((c) => c.name() === "status"); + assert.ok(cmd, "status command exists"); +}); + +test("program registers 'logs' command", () => { + const program = createProgram(); + const cmd = program.commands.find((c) => c.name() === "logs"); + assert.ok(cmd, "logs command exists"); +}); + +test("program registers 'update' command", () => { + const program = createProgram(); + const cmd = program.commands.find((c) => c.name() === "update"); + assert.ok(cmd, "update command exists"); +}); + +// ─── exitOverride / --help via Commander ───────────────────────────────────── + +test("--help throws CommanderError with exit code 0", async () => { + const program = createProgram(); + try { + await program.parseAsync(["node", "omniroute", "--help"]); + assert.fail("expected error to be thrown"); + } catch (err: any) { + assert.equal(err.exitCode, 0, `expected exitCode 0, got: ${err.exitCode}`); + assert.equal(err.code, "commander.helpDisplayed"); + } +}); + +test("--version throws CommanderError with exit code 0", async () => { + const program = createProgram(); + try { + await program.parseAsync(["node", "omniroute", "--version"]); + assert.fail("expected error to be thrown"); + } catch (err: any) { + assert.equal(err.exitCode, 0, `expected exitCode 0, got: ${err.exitCode}`); + } +}); + +test("unknown global flag throws CommanderError with exit code 1", async () => { + const program = createProgram(); + try { + await program.parseAsync(["node", "omniroute", "--definitely-not-a-flag"]); + assert.fail("expected error to be thrown"); + } catch (err: any) { + assert.ok(err.exitCode !== undefined, "error has exitCode"); + assert.ok(err.exitCode !== 0, "exit code is non-zero for invalid flag"); + } +});