mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
82
bin/cli/commands/registry.mjs
Normal file
82
bin/cli/commands/registry.mjs
Normal 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));
|
||||
}
|
||||
96
bin/cli/commands/reset-encrypted-columns.mjs
Normal file
96
bin/cli/commands/reset-encrypted-columns.mjs
Normal 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
205
bin/cli/commands/serve.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
33
bin/cli/program.mjs
Normal 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;
|
||||
}
|
||||
@@ -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
18
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
159
tests/unit/cli-program.test.ts
Normal file
159
tests/unit/cli-program.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user