feat(cli): adotar Commander.js como framework CLI (Fase 1.1)

- 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
This commit is contained in:
diegosouzapw
2026-05-14 22:09:53 -03:00
parent 2e494f8f07
commit 31031422d3
10 changed files with 643 additions and 487 deletions

View File

@@ -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));
}

View File

@@ -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;
}
}

205
bin/cli/commands/serve.mjs Normal file
View File

@@ -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 <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
}
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

33
bin/cli/program.mjs Normal file
View File

@@ -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 <format>", 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 <ms>", t("program.timeout")).default("30000"))
.addOption(new Option("--api-key <key>", t("program.api_key")).env("OMNIROUTE_API_KEY"))
.addOption(new Option("--base-url <url>", t("program.base_url")).env("OMNIROUTE_BASE_URL"))
.showHelpAfterError(true)
.exitOverride();
registerCommands(program);
return program;
}

View File

@@ -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 <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 <prov> 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 <key> Get specific env var
omniroute env set <k> <v> 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 <password>
omniroute setup --add-provider --provider openai --api-key <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 <id|name>
omniroute providers test-all
omniroute providers validate
\x1b[1mCLI Tools:\x1b[0m
omniroute config list List CLI tool configuration status
omniroute config get <tool> Show config for a specific tool
omniroute config set <tool> Write config for a tool
omniroute config validate <tool> 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 <name> Add a provider connection
omniroute provider list List configured providers
omniroute provider test <name|id> Test a provider connection
\x1b[1mAfter starting:\x1b[0m
Dashboard: http://localhost:<dashboard-port>
API: http://localhost:<api-port>/v1
\x1b[1mConnect your tools:\x1b[0m
Set your CLI tool (Cursor, Cline, Codex, etc.) to use:
\x1b[33mhttp://localhost:<api-port>/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);

18
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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");
}
});