diff --git a/bin/cli/commands/combo.mjs b/bin/cli/commands/combo.mjs new file mode 100644 index 0000000000..07678a1b4d --- /dev/null +++ b/bin/cli/commands/combo.mjs @@ -0,0 +1,229 @@ +import { Option } from "commander"; +import { printHeading } from "../io.mjs"; +import { openOmniRouteDb } from "../sqlite.mjs"; +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +const VALID_STRATEGIES = [ + "priority", + "weighted", + "round-robin", + "p2c", + "random", + "auto", + "lkgp", + "context-optimized", + "context-relay", + "fill-first", + "cost-optimized", + "least-used", + "strict-random", + "reset-aware", +]; + +export function registerCombo(program) { + const combo = program.command("combo").description(t("combo.title")); + + combo + .command("list") + .description("List configured routing combos") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runComboListCommand({ ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + combo + .command("switch ") + .description("Activate a routing combo") + .action(async (name, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runComboSwitchCommand(name, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); + + combo + .command("create ") + .description("Create a new routing combo") + .addOption( + new Option("--strategy ", "Routing strategy") + .choices(VALID_STRATEGIES) + .default("priority") + ) + .action(async (name, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runComboCreateCommand(name, opts.strategy, { + ...opts, + output: globalOpts.output, + }); + if (exitCode !== 0) process.exit(exitCode); + }); + + combo + .command("delete ") + .description("Delete a routing combo") + .option("--yes", "Skip confirmation") + .action(async (name, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runComboDeleteCommand(name, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runComboListCommand(opts = {}) { + const { db } = await openOmniRouteDb(); + try { + // TODO(1.5): replace raw SQL with src/lib/db/combos.ts + const combos = db + .prepare("SELECT id, name, strategy, enabled, target_count FROM combos ORDER BY name") + .all(); + + let activeCombo = null; + try { + const serverUp = await isServerUp(); + if (serverUp) { + const res = await apiFetch("/api/combos/active", { + retry: false, + timeout: 3000, + acceptNotOk: true, + }); + if (res.ok) { + const data = await res.json(); + activeCombo = data.active || data.name || data.combo || null; + } + } + } catch {} + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify({ combos, active: activeCombo }, null, 2)); + return 0; + } + + printHeading(t("combo.title")); + if (combos.length === 0) { + console.log(t("combo.noCombos")); + return 0; + } + + for (const combo of combos) { + const isActive = activeCombo && (combo.name === activeCombo || combo.id === activeCombo); + const icon = isActive ? "\x1b[32m●\x1b[0m" : "\x1b[2m○\x1b[0m"; + const status = combo.enabled ? "\x1b[32menabled\x1b[0m" : "\x1b[31mdisabled\x1b[0m"; + const strategy = (combo.strategy || "priority").padEnd(12); + console.log(` ${icon} ${combo.name.padEnd(25)} [${strategy}] ${status}`); + } + + return 0; + } finally { + db.close(); + } +} + +export async function runComboSwitchCommand(name, opts = {}) { + if (!name) { + console.error("Combo name is required."); + return 1; + } + + const serverUp = await isServerUp(); + if (serverUp) { + try { + const res = await apiFetch("/api/combos/switch", { + method: "POST", + body: { name }, + retry: false, + acceptNotOk: true, + }); + if (res.ok) { + console.log(t("combo.switched", { name })); + return 0; + } + } catch {} + } + + // DB fallback + const { db } = await openOmniRouteDb(); + try { + // TODO(1.5): replace raw SQL with src/lib/db/combos.ts + const combo = db.prepare("SELECT id FROM combos WHERE name = ?").get(name); + if (!combo) { + console.error(`Combo '${name}' not found.`); + return 1; + } + + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'activeCombo', ?)" + ).run(JSON.stringify(name)); + + console.log(t("combo.switched", { name })); + return 0; + } finally { + db.close(); + } +} + +export async function runComboCreateCommand(name, strategy = "priority", opts = {}) { + if (!name) { + console.error("Combo name is required."); + return 1; + } + + if (!VALID_STRATEGIES.includes(strategy)) { + console.error(`Invalid strategy '${strategy}'. Valid: ${VALID_STRATEGIES.join(", ")}`); + return 1; + } + + const { db } = await openOmniRouteDb(); + try { + // TODO(1.5): replace raw SQL with src/lib/db/combos.ts + const existing = db.prepare("SELECT id FROM combos WHERE name = ?").get(name); + if (existing) { + console.error(`Combo '${name}' already exists. Delete it first.`); + return 1; + } + + db.prepare( + "INSERT INTO combos (name, strategy, enabled, target_count) VALUES (?, ?, 1, 0)" + ).run(name, strategy); + + console.log(t("combo.created", { name })); + return 0; + } finally { + db.close(); + } +} + +export async function runComboDeleteCommand(name, opts = {}) { + if (!name) { + console.error("Combo name is required."); + return 1; + } + + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => + rl.question(t("combo.confirmDelete", { name }) + " [y/N] ", resolve) + ); + rl.close(); + if (!/^y(es)?$/i.test(answer)) { + console.log(t("common.cancelled")); + return 0; + } + } + + const { db } = await openOmniRouteDb(); + try { + // TODO(1.5): replace raw SQL with src/lib/db/combos.ts + const result = db.prepare("DELETE FROM combos WHERE name = ?").run(name); + if (result.changes > 0) { + console.log(t("combo.deleted", { name })); + return 0; + } + console.error(`Combo '${name}' not found.`); + return 1; + } finally { + db.close(); + } +} diff --git a/bin/cli/commands/dashboard.mjs b/bin/cli/commands/dashboard.mjs new file mode 100644 index 0000000000..0652d3549e --- /dev/null +++ b/bin/cli/commands/dashboard.mjs @@ -0,0 +1,56 @@ +import { execFile } from "node:child_process"; +import { t } from "../i18n.mjs"; + +export function registerDashboard(program) { + program + .command("dashboard") + .alias("open") + .description(t("dashboard.description")) + .option("--url", t("dashboard.urlOnly")) + .option("--port ", "Port the server is running on", "20128") + .action(async (opts) => { + const exitCode = await runDashboardCommand(opts); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runDashboardCommand(opts = {}) { + const port = opts.port ? parseInt(String(opts.port), 10) : 20128; + const dashboardUrl = `http://localhost:${port}`; + + if (opts.url) { + console.log(dashboardUrl); + return 0; + } + + console.log(t("dashboard.opening", { url: dashboardUrl })); + + try { + const open = await import("open"); + await open.default(dashboardUrl); + } catch { + await openFallback(dashboardUrl); + } + + return 0; +} + +function openFallback(url) { + return new Promise((resolve) => { + const { platform } = process; + let cmd, args; + + if (platform === "darwin") { + cmd = "open"; + args = [url]; + } else if (platform === "win32") { + cmd = "cmd"; + args = ["/c", "start", "", url]; + } else { + cmd = "xdg-open"; + args = [url]; + } + + execFile(cmd, args, { stdio: "ignore" }, () => resolve()); + }); +} diff --git a/bin/cli/commands/keys.mjs b/bin/cli/commands/keys.mjs new file mode 100644 index 0000000000..f47d670d51 --- /dev/null +++ b/bin/cli/commands/keys.mjs @@ -0,0 +1,214 @@ +import { printHeading } from "../io.mjs"; +import { + ensureProviderSchema, + getProviderApiKey, + listProviderConnections, + upsertApiKeyProviderConnection, +} from "../provider-store.mjs"; +import { openOmniRouteDb } from "../sqlite.mjs"; +import { loadAvailableProviders } from "../provider-catalog.mjs"; +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +function getValidProviderIds() { + try { + return new Set(loadAvailableProviders().map((p) => p.id)); + } catch { + return null; + } +} + +function maskKey(raw) { + if (!raw || raw.length <= 8) return "***"; + return raw.slice(0, 6) + "***" + raw.slice(-4); +} + +export function registerKeys(program) { + const keys = program.command("keys").description(t("keys.title")); + + keys + .command("add [apiKey]") + .description("Add or update an API key for a provider") + .option("--stdin", "Read API key from stdin instead of argument") + .action(async (provider, apiKey, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runKeysAddCommand(provider, apiKey, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); + + keys + .command("list") + .description("List all configured API keys") + .option("--json", "Output as JSON") + .action(async (opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runKeysListCommand({ ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); + + keys + .command("remove ") + .description("Remove an API key for a provider") + .option("--yes", "Skip confirmation prompt") + .action(async (provider, opts, cmd) => { + const globalOpts = cmd.parent.optsWithGlobals(); + const exitCode = await runKeysRemoveCommand(provider, { ...opts, ...globalOpts }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runKeysAddCommand(provider, apiKey, opts = {}) { + if (!provider) { + console.error("Provider is required. Usage: omniroute keys add [apiKey]"); + return 1; + } + + let key = apiKey; + if (opts.stdin) { + key = await readStdin(); + if (!key) { + console.error("No API key provided via stdin."); + return 1; + } + } + + if (!key) { + console.error("API key is required. Usage: omniroute keys add "); + return 1; + } + + const providerLower = provider.toLowerCase(); + const validIds = getValidProviderIds(); + if (validIds && !validIds.has(providerLower)) { + console.error(`Unknown provider: ${providerLower}`); + return 1; + } + + // Server-first + const serverUp = await isServerUp(); + if (serverUp) { + try { + const res = await apiFetch("/api/v1/providers/keys", { + method: "POST", + body: { provider: providerLower, apiKey: key }, + retry: false, + }); + if (res.ok) { + console.log(t("keys.added", { provider: providerLower })); + return 0; + } + } catch {} + } + + // DB fallback + const { db } = await openOmniRouteDb(); + try { + const existing = listProviderConnections(db).find( + (c) => c.provider === providerLower && c.authType === "apikey" + ); + upsertApiKeyProviderConnection(db, { + provider: providerLower, + name: existing?.name || providerLower, + apiKey: key, + }); + console.log(t("keys.added", { provider: providerLower })); + return 0; + } finally { + db.close(); + } +} + +export async function runKeysListCommand(opts = {}) { + const { db } = await openOmniRouteDb(); + try { + ensureProviderSchema(db); + const connections = listProviderConnections(db).filter( + (c) => c.authType === "apikey" && c.apiKey + ); + + if (opts.json) { + const rows = connections.map((c) => ({ + id: c.id, + provider: c.provider, + name: c.name, + isActive: c.isActive, + maskedKey: maskKey(c.apiKey), + })); + console.log(JSON.stringify({ keys: rows }, null, 2)); + return 0; + } + + printHeading(t("keys.title")); + if (connections.length === 0) { + console.log(t("keys.noKeys")); + return 0; + } + + for (const c of connections) { + let raw = ""; + try { + raw = getProviderApiKey(c); + } catch { + raw = c.apiKey || ""; + } + const masked = maskKey(raw); + const status = c.isActive ? "\x1b[32m● enabled\x1b[0m" : "\x1b[33m○ disabled\x1b[0m"; + console.log(` ${c.provider.padEnd(20)} ${masked.padEnd(22)} ${status}`); + } + + console.log(`\n${t("keys.listed", { count: connections.length })}`); + return 0; + } finally { + db.close(); + } +} + +export async function runKeysRemoveCommand(provider, opts = {}) { + if (!provider) { + console.error("Provider is required. Usage: omniroute keys remove "); + return 1; + } + + const providerLower = provider.toLowerCase(); + + if (!opts.yes) { + const readline = await import("node:readline"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => + rl.question(t("keys.confirmRemove", { id: providerLower }) + " [y/N] ", resolve) + ); + rl.close(); + if (!/^y(es)?$/i.test(answer)) { + console.log(t("common.cancelled")); + return 0; + } + } + + const { db } = await openOmniRouteDb(); + try { + ensureProviderSchema(db); + const result = db + .prepare( + "DELETE FROM provider_connections WHERE provider = ? AND (auth_type = 'apikey' OR (api_key IS NOT NULL AND api_key != ''))" + ) + .run(providerLower); + + if (result.changes > 0) { + console.log(t("keys.removed")); + return 0; + } + console.log(t("keys.noKeys")); + return 0; + } finally { + db.close(); + } +} + +async function readStdin() { + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => (data += chunk)); + process.stdin.on("end", () => resolve(data.trim())); + }); +} diff --git a/bin/cli/commands/models.mjs b/bin/cli/commands/models.mjs new file mode 100644 index 0000000000..65aa4d0a2f --- /dev/null +++ b/bin/cli/commands/models.mjs @@ -0,0 +1,100 @@ +import { apiFetch, isServerUp } from "../api.mjs"; +import { t } from "../i18n.mjs"; + +export function registerModels(program) { + program + .command("models [provider]") + .description(t("models.description")) + .option("--search ", t("models.search")) + .option("--json", "Output as JSON") + .action(async (provider, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const exitCode = await runModelsCommand(provider, { ...opts, output: globalOpts.output }); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runModelsCommand(provider, opts = {}) { + const serverUp = await isServerUp(); + if (!serverUp) { + console.error(t("models.noServer")); + return 1; + } + + let models = []; + + try { + const res = await apiFetch("/api/models", { retry: false, timeout: 5000, acceptNotOk: true }); + if (res.ok) { + const data = await res.json(); + models = Array.isArray(data) ? data : data.models || []; + } + } catch {} + + if (models.length === 0) { + try { + const res = await apiFetch("/api/v1/models", { + retry: false, + timeout: 5000, + acceptNotOk: true, + }); + if (res.ok) { + const data = await res.json(); + models = Array.isArray(data) ? data : data.data || []; + } + } catch {} + } + + if (provider) { + const filter = provider.toLowerCase(); + models = models.filter( + (m) => + (m.provider && m.provider.toLowerCase().includes(filter)) || + (m.id && m.id.toLowerCase().startsWith(filter)) || + (m.name && m.name.toLowerCase().includes(filter)) + ); + } + + if (opts.search) { + const search = opts.search.toLowerCase(); + models = models.filter( + (m) => + (m.id && m.id.toLowerCase().includes(search)) || + (m.name && m.name.toLowerCase().includes(search)) || + (m.provider && m.provider.toLowerCase().includes(search)) || + (m.description && m.description.toLowerCase().includes(search)) + ); + } + + if (opts.json || opts.output === "json") { + console.log(JSON.stringify(models, null, 2)); + return 0; + } + + if (models.length === 0) { + console.log(t("models.noModels")); + return 0; + } + + console.log(); + console.log("\x1b[36m" + " Model".padEnd(45) + "Provider".padEnd(20) + "Context\x1b[0m"); + console.log( + "\x1b[2m " + "─".repeat(44) + " " + "─".repeat(19) + " " + "─".repeat(10) + "\x1b[0m" + ); + + const displayModels = models.slice(0, 50); + for (const model of displayModels) { + const name = (model.id || model.name || "unknown").slice(0, 43); + const prov = (model.provider || "unknown").slice(0, 18); + const context = model.context_length || model.max_tokens || model.contextWindow || "-"; + console.log(` ${name.padEnd(45)}${prov.padEnd(20)}${String(context).padEnd(10)}`); + } + + console.log(); + if (models.length > 50) { + console.log(`\x1b[2m ... and ${models.length - 50} more. Use --json for full list.\x1b[0m`); + } + console.log(` \x1b[32mTotal: ${models.length} models\x1b[0m`); + + return 0; +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 0c463e268d..8794f1fdae 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -1,20 +1,32 @@ import { registerServe } from "./serve.mjs"; +import { registerStop } from "./stop.mjs"; +import { registerRestart } from "./restart.mjs"; +import { registerDashboard } from "./dashboard.mjs"; import { registerDoctor } from "./doctor.mjs"; import { registerSetup } from "./setup.mjs"; import { registerProviders } from "./providers.mjs"; import { registerProvider } from "./provider-cmd.mjs"; import { registerConfig } from "./config.mjs"; +import { registerKeys } from "./keys.mjs"; +import { registerModels } from "./models.mjs"; +import { registerCombo } from "./combo.mjs"; import { registerStatus } from "./status.mjs"; import { registerLogs } from "./logs.mjs"; import { registerUpdate } from "./update.mjs"; export function registerCommands(program) { registerServe(program); + registerStop(program); + registerRestart(program); + registerDashboard(program); registerDoctor(program); registerSetup(program); registerProviders(program); registerProvider(program); registerConfig(program); + registerKeys(program); + registerModels(program); + registerCombo(program); registerStatus(program); registerLogs(program); registerUpdate(program); diff --git a/bin/cli/commands/restart.mjs b/bin/cli/commands/restart.mjs new file mode 100644 index 0000000000..96a182c845 --- /dev/null +++ b/bin/cli/commands/restart.mjs @@ -0,0 +1,25 @@ +import { t } from "../i18n.mjs"; +import { runStopCommand } from "./stop.mjs"; +import { sleep } from "../utils/pid.mjs"; + +export function registerRestart(program) { + program + .command("restart") + .description(t("restart.description")) + .option("--port ", t("serve.port"), "20128") + .action(async (opts) => { + const exitCode = await runRestartCommand(opts); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runRestartCommand(opts = {}) { + console.log(t("restart.restarting")); + + await runStopCommand(opts); + await sleep(1000); + + const { runServe } = await import("./serve.mjs"); + await runServe(opts); + return 0; +} diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 192e742686..3a12d257d5 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -4,6 +4,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { platform } from "node:os"; import { t } from "../i18n.mjs"; +import { writePidFile, cleanupPidFile } from "../utils/pid.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, "..", "..", ".."); @@ -26,7 +27,7 @@ export function registerServe(program) { }); } -async function runServe(opts = {}) { +export async function runServe(opts = {}) { const { isNativeBinaryCompatible } = await import("../../../scripts/build/native-binary-compat.mjs"); const { getNodeRuntimeSupport, getNodeRuntimeWarning } = @@ -122,12 +123,25 @@ async function runServe(opts = {}) { NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`, }; + const isDaemon = opts.daemon === true; + const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], { cwd: APP_DIR, env, - stdio: "pipe", + stdio: isDaemon ? "ignore" : "pipe", + detached: isDaemon, }); + writePidFile(server.pid); + + if (isDaemon) { + server.unref(); + console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`); + console.log(` \x1b[1mDashboard:\x1b[0m http://localhost:${dashboardPort}`); + console.log(` \x1b[1mAPI Base:\x1b[0m http://localhost:${apiPort}/v1`); + return; + } + let started = false; server.stdout.on("data", (data) => { @@ -160,6 +174,7 @@ async function runServe(opts = {}) { function shutdown() { console.log("\n\x1b[33m⏹ Shutting down OmniRoute...\x1b[0m"); + cleanupPidFile(); server.kill("SIGTERM"); setTimeout(() => { server.kill("SIGKILL"); diff --git a/bin/cli/commands/stop.mjs b/bin/cli/commands/stop.mjs new file mode 100644 index 0000000000..09df2ed78d --- /dev/null +++ b/bin/cli/commands/stop.mjs @@ -0,0 +1,88 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { readPidFile, isPidRunning, cleanupPidFile, sleep } from "../utils/pid.mjs"; +import { t } from "../i18n.mjs"; + +const execFileAsync = promisify(execFile); + +export function registerStop(program) { + program + .command("stop") + .description(t("stop.description")) + .action(async (opts) => { + const exitCode = await runStopCommand(opts); + if (exitCode !== 0) process.exit(exitCode); + }); +} + +export async function runStopCommand(opts = {}) { + const pid = readPidFile(); + + if (pid && isPidRunning(pid)) { + console.log(t("stop.stopping", { pid })); + try { + process.kill(pid, "SIGTERM"); + + let waited = 0; + while (waited < 5000 && isPidRunning(pid)) { + await sleep(100); + waited += 100; + } + + if (isPidRunning(pid)) { + process.kill(pid, "SIGKILL"); + await sleep(500); + } + + cleanupPidFile(); + console.log(t("stop.stopped")); + return 0; + } catch (err) { + console.error( + t("common.error", { message: err instanceof Error ? err.message : String(err) }) + ); + return 1; + } + } + + const port = opts.port ? parseInt(String(opts.port), 10) : 20128; + if (pid === null) { + console.log(t("stop.portFallback")); + await killByPort(port); + cleanupPidFile(); + console.log(t("stop.stopped")); + return 0; + } + + console.log(t("stop.notRunning")); + return 0; +} + +async function killByPort(port) { + if (process.platform === "win32") return; + try { + const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`]); + const pids = stdout + .trim() + .split("\n") + .map((p) => parseInt(p, 10)) + .filter((p) => Number.isFinite(p) && p > 0); + + for (const p of pids) { + try { + process.kill(p, "SIGTERM"); + } catch {} + } + + if (pids.length > 0) { + await sleep(1000); + for (const p of pids) { + try { + if (isPidRunning(p)) process.kill(p, "SIGKILL"); + } catch {} + } + } + } catch { + // lsof not available or no process on port + } +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index d9693d2221..fc01366116 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -107,6 +107,28 @@ "stopped": "Tunnel stopped.", "confirmStop": "Stop tunnel {id}?" }, + "stop": { + "description": "Stop the OmniRoute server", + "stopping": "Stopping server (PID {pid})...", + "stopped": "Server stopped.", + "notRunning": "No server is running.", + "portFallback": "No PID file found, attempting port-based stop..." + }, + "restart": { + "description": "Restart the OmniRoute server", + "restarting": "Restarting OmniRoute server..." + }, + "dashboard": { + "description": "Open the OmniRoute dashboard in a browser", + "opening": "Opening dashboard at {url}", + "urlOnly": "Print dashboard URL without opening browser" + }, + "models": { + "description": "List available models (requires server)", + "search": "Filter models by id, name, provider, or description", + "noServer": "Server not running. Start with: omniroute serve", + "noModels": "No models found." + }, "program": { "description": "OmniRoute — Smart AI Router with Auto Fallback", "version": "Print version and exit", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index cca3c122b6..2ed620308a 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -107,6 +107,28 @@ "stopped": "Túnel parado.", "confirmStop": "Parar túnel {id}?" }, + "stop": { + "description": "Parar o servidor OmniRoute", + "stopping": "Parando servidor (PID {pid})...", + "stopped": "Servidor parado.", + "notRunning": "Nenhum servidor está em execução.", + "portFallback": "Arquivo PID não encontrado, tentando parar pela porta..." + }, + "restart": { + "description": "Reiniciar o servidor OmniRoute", + "restarting": "Reiniciando servidor OmniRoute..." + }, + "dashboard": { + "description": "Abrir o painel OmniRoute no navegador", + "opening": "Abrindo painel em {url}", + "urlOnly": "Exibir URL do painel sem abrir o navegador" + }, + "models": { + "description": "Listar modelos disponíveis (requer servidor)", + "search": "Filtrar modelos por id, nome, provedor ou descrição", + "noServer": "Servidor não está em execução. Inicie com: omniroute serve", + "noModels": "Nenhum modelo encontrado." + }, "program": { "description": "OmniRoute — Roteador de IA com Fallback Automático", "version": "Exibir versão e sair", diff --git a/bin/cli/utils/pid.mjs b/bin/cli/utils/pid.mjs new file mode 100644 index 0000000000..ee3056f40a --- /dev/null +++ b/bin/cli/utils/pid.mjs @@ -0,0 +1,65 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { resolveDataDir } from "../data-dir.mjs"; + +export function getPidFilePath() { + return join(resolveDataDir(), "server.pid"); +} + +export function writePidFile(pid) { + try { + const pidPath = getPidFilePath(); + const dir = dirname(pidPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(pidPath, String(pid), "utf8"); + return true; + } catch { + return false; + } +} + +export function readPidFile() { + try { + const pidPath = getPidFilePath(); + if (!existsSync(pidPath)) return null; + const content = readFileSync(pidPath, "utf8").trim(); + return content ? parseInt(content, 10) : null; + } catch { + return null; + } +} + +export function isPidRunning(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function cleanupPidFile() { + try { + const pidPath = getPidFilePath(); + if (existsSync(pidPath)) unlinkSync(pidPath); + } catch {} +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForServer(port, timeout = 15000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const res = await fetch(`http://localhost:${port}/api/health`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return true; + } catch {} + await sleep(500); + } + return false; +} diff --git a/tests/unit/cli-combo-command.test.ts b/tests/unit/cli-combo-command.test.ts new file mode 100644 index 0000000000..4b177db2a6 --- /dev/null +++ b/tests/unit/cli-combo-command.test.ts @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import Database from "better-sqlite3"; + +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_FETCH = globalThis.fetch; + +interface ComboRow { + id: number; + name: string; + strategy: string; + enabled: number; +} + +function createTempDataDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-combo-")); +} + +function initComboTable(dbPath: string) { + const db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.prepare( + "CREATE TABLE IF NOT EXISTS key_value (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (namespace, key))" + ).run(); + db.prepare( + "CREATE TABLE IF NOT EXISTS combos (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, strategy TEXT, enabled INTEGER DEFAULT 1, target_count INTEGER DEFAULT 0)" + ).run(); + db.close(); +} + +async function withComboEnv(fn: (dataDir: string, dbPath: string) => Promise) { + const dataDir = createTempDataDir(); + const dbPath = path.join(dataDir, "storage.sqlite"); + process.env.DATA_DIR = dataDir; + globalThis.fetch = (async () => { + throw new Error("server offline"); + }) as typeof fetch; + + initComboTable(dbPath); + + const originalLog = console.log; + console.log = () => {}; + + try { + await fn(dataDir, dbPath); + } finally { + console.log = originalLog; + globalThis.fetch = ORIGINAL_FETCH; + fs.rmSync(dataDir, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +} + +test("combo create inserts a new combo row", async () => { + await withComboEnv(async (_dataDir, dbPath) => { + const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs"); + + const result = await runComboCreateCommand("my-combo", "priority", {}); + assert.equal(result, 0); + + const db = new Database(dbPath); + const row = db + .prepare("SELECT name, strategy, enabled FROM combos WHERE name = ?") + .get("my-combo") as ComboRow | undefined; + db.close(); + + assert.ok(row); + assert.equal(row.name, "my-combo"); + assert.equal(row.strategy, "priority"); + assert.equal(row.enabled, 1); + }); +}); + +test("combo create fails if combo already exists", async () => { + await withComboEnv(async () => { + const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs"); + + await runComboCreateCommand("dup-combo", "auto", {}); + const originalError = console.error; + console.error = () => {}; + const result = await runComboCreateCommand("dup-combo", "auto", {}); + console.error = originalError; + + assert.equal(result, 1); + }); +}); + +test("combo delete removes the row", async () => { + await withComboEnv(async (_dataDir, dbPath) => { + const { runComboCreateCommand, runComboDeleteCommand } = + await import("../../bin/cli/commands/combo.mjs"); + + await runComboCreateCommand("to-delete", "weighted", {}); + const result = await runComboDeleteCommand("to-delete", { yes: true }); + assert.equal(result, 0); + + const db = new Database(dbPath); + const row = db.prepare("SELECT id FROM combos WHERE name = ?").get("to-delete"); + db.close(); + assert.equal(row, undefined); + }); +}); + +test("combo list returns 0 with empty combos table", async () => { + await withComboEnv(async () => { + const { runComboListCommand } = await import("../../bin/cli/commands/combo.mjs"); + const result = await runComboListCommand({}); + assert.equal(result, 0); + }); +}); + +test("combo switch updates key_value settings when server is offline", async () => { + await withComboEnv(async (_dataDir, dbPath) => { + const { runComboCreateCommand, runComboSwitchCommand } = + await import("../../bin/cli/commands/combo.mjs"); + + await runComboCreateCommand("my-switch", "round-robin", {}); + const result = await runComboSwitchCommand("my-switch", {}); + assert.equal(result, 0); + + const db = new Database(dbPath); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'activeCombo'") + .get() as { value: string } | undefined; + db.close(); + + assert.ok(row); + assert.equal(JSON.parse(row.value), "my-switch"); + }); +}); diff --git a/tests/unit/cli-keys-command.test.ts b/tests/unit/cli-keys-command.test.ts index c1b08bc284..6c524b0805 100644 --- a/tests/unit/cli-keys-command.test.ts +++ b/tests/unit/cli-keys-command.test.ts @@ -55,33 +55,71 @@ async function withCliKeysEnv(fn: (dataDir: string, dbPath: string) => Promise { +test("keys add writes provider_connection row to DB when server is offline", async () => { await withCliKeysEnv(async (_dataDir, dbPath) => { - const { runSubcommand } = await import("../../bin/cli-commands.mjs"); + const { runKeysAddCommand } = await import("../../bin/cli/commands/keys.mjs"); - await runSubcommand("keys", ["add", "openai", "sk-test-cli-key"]); + const result = await runKeysAddCommand("openai", "sk-test-cli-key", {}); + assert.equal(result, 0); - let db = new Database(dbPath); - let row = db + const db = new Database(dbPath); + const row = db .prepare( "SELECT provider, auth_type, name, api_key, is_active, created_at, updated_at FROM provider_connections WHERE provider = ?" ) .get("openai") as ProviderConnectionRow | undefined; db.close(); - assert.ok(row); + assert.ok(row, "row should exist"); assert.equal(row.provider, "openai"); assert.equal(row.auth_type, "apikey"); assert.equal(row.name, "openai"); - assert.equal(row.api_key, "sk-test-cli-key"); assert.equal(row.is_active, 1); assert.ok(row.created_at); assert.ok(row.updated_at); + }); +}); - await runSubcommand("keys", ["list"]); - await runSubcommand("keys", ["remove", "openai"]); +test("keys list returns 0 and shows no keys on empty DB", async () => { + await withCliKeysEnv(async () => { + const { runKeysListCommand } = await import("../../bin/cli/commands/keys.mjs"); + const result = await runKeysListCommand({}); + assert.equal(result, 0); + }); +}); - db = new Database(dbPath); +test("keys list --json returns structured output", async () => { + await withCliKeysEnv(async () => { + const { runKeysAddCommand, runKeysListCommand } = + await import("../../bin/cli/commands/keys.mjs"); + + await runKeysAddCommand("openai", "sk-list-json-test", {}); + + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runKeysListCommand({ json: true }); + console.log = originalLog; + + assert.equal(result, 0); + const parsed = JSON.parse(lines.join("\n")); + assert.ok(Array.isArray(parsed.keys)); + assert.equal(parsed.keys.length, 1); + assert.equal(parsed.keys[0].provider, "openai"); + }); +}); + +test("keys remove deletes the provider_connection row", async () => { + await withCliKeysEnv(async (_dataDir, dbPath) => { + const { runKeysAddCommand, runKeysRemoveCommand } = + await import("../../bin/cli/commands/keys.mjs"); + + await runKeysAddCommand("openai", "sk-remove-test", {}); + + const result = await runKeysRemoveCommand("openai", { yes: true }); + assert.equal(result, 0); + + const db = new Database(dbPath); const countRow = db .prepare("SELECT COUNT(*) AS count FROM provider_connections WHERE provider = ?") .get("openai") as CountRow; @@ -90,3 +128,16 @@ test("legacy keys command writes and removes provider_connections with the real assert.equal(countRow.count, 0); }); }); + +test("keys add fails gracefully with missing API key argument", async () => { + await withCliKeysEnv(async () => { + const { runKeysAddCommand } = await import("../../bin/cli/commands/keys.mjs"); + + const originalError = console.error; + console.error = () => {}; + const result = await runKeysAddCommand("openai", undefined as unknown as string, {}); + console.error = originalError; + + assert.equal(result, 1); + }); +}); diff --git a/tests/unit/cli-models-command.test.ts b/tests/unit/cli-models-command.test.ts new file mode 100644 index 0000000000..757ea5fffc --- /dev/null +++ b/tests/unit/cli-models-command.test.ts @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const ORIGINAL_FETCH = globalThis.fetch; + +function makeResponse(data: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "content-type": "application/json" }), + json: async () => data, + text: async () => JSON.stringify(data), + } as unknown as Response; +} + +async function withModelsFetch(mockFetch: typeof fetch, fn: () => Promise) { + globalThis.fetch = mockFetch; + try { + await fn(); + } finally { + globalThis.fetch = ORIGINAL_FETCH; + } +} + +test("models returns 1 when server is offline", async () => { + await withModelsFetch( + (async () => { + throw new Error("connection refused"); + }) as typeof fetch, + async () => { + const { runModelsCommand } = await import("../../bin/cli/commands/models.mjs"); + const originalError = console.error; + console.error = () => {}; + const result = await runModelsCommand(undefined, {}); + console.error = originalError; + assert.equal(result, 1); + } + ); +}); + +test("models --json returns 0 and prints JSON when server responds", async () => { + const mockModels = [ + { id: "gpt-4o", provider: "openai", name: "GPT-4o" }, + { id: "claude-3-5-sonnet", provider: "anthropic", name: "Claude 3.5 Sonnet" }, + ]; + + const mockFetch = (async (url: string) => { + if (String(url).includes("/api/health")) { + return makeResponse({ status: "ok" }); + } + if (String(url).includes("/api/models")) { + return makeResponse(mockModels); + } + throw new Error("unexpected URL: " + url); + }) as typeof fetch; + + await withModelsFetch(mockFetch, async () => { + const { runModelsCommand } = await import("../../bin/cli/commands/models.mjs"); + + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runModelsCommand(undefined, { json: true }); + console.log = originalLog; + + assert.equal(result, 0); + const parsed = JSON.parse(lines.join("\n")); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed.length, 2); + }); +}); + +test("models filters by provider argument", async () => { + const mockModels = [ + { id: "gpt-4o", provider: "openai" }, + { id: "claude-3-5-sonnet", provider: "anthropic" }, + ]; + + const mockFetch = (async (url: string) => { + if (String(url).includes("/api/health")) { + return makeResponse({ status: "ok" }); + } + return makeResponse(mockModels); + }) as typeof fetch; + + await withModelsFetch(mockFetch, async () => { + const { runModelsCommand } = await import("../../bin/cli/commands/models.mjs"); + + const lines: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => lines.push(msg); + const result = await runModelsCommand("openai", { json: true }); + console.log = originalLog; + + assert.equal(result, 0); + const parsed = JSON.parse(lines.join("\n")); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].provider, "openai"); + }); +}); diff --git a/tests/unit/cli-serve-stop-command.test.ts b/tests/unit/cli-serve-stop-command.test.ts new file mode 100644 index 0000000000..efde50e186 --- /dev/null +++ b/tests/unit/cli-serve-stop-command.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_FETCH = globalThis.fetch; + +function createTempDataDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-stop-")); +} + +async function withEnv(fn: (dataDir: string) => Promise) { + const dataDir = createTempDataDir(); + process.env.DATA_DIR = dataDir; + globalThis.fetch = (async () => { + throw new Error("server offline"); + }) as typeof fetch; + + const originalLog = console.log; + console.log = () => {}; + + try { + await fn(dataDir); + } finally { + console.log = originalLog; + globalThis.fetch = ORIGINAL_FETCH; + fs.rmSync(dataDir, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +} + +test("stop returns 0 when no server is running (no PID file)", async () => { + await withEnv(async () => { + const { runStopCommand } = await import("../../bin/cli/commands/stop.mjs"); + const result = await runStopCommand({}); + assert.equal(result, 0); + }); +}); + +test("stop returns 0 when PID file exists but process is gone", async (t) => { + await withEnv(async (dataDir) => { + const pidPath = path.join(dataDir, "server.pid"); + fs.writeFileSync(pidPath, "999999999", "utf8"); + + const { runStopCommand } = await import("../../bin/cli/commands/stop.mjs"); + const result = await runStopCommand({}); + assert.equal(result, 0); + }); +});