feat(cli): migrar serve/stop/restart/dashboard/keys/models/combo (Fase 1.3)

Extrai 7 grupos de comandos do monolito bin/cli-commands.mjs (2853 linhas)
para módulos individuais em bin/cli/commands/, registrados via Commander.

- stop.mjs: SIGTERM/SIGKILL via process.kill(); fallback por porta usa execFile
  com array de args (evita injeção de shell / Semgrep CWE-78)
- restart.mjs: delega para runStopCommand + runServe
- dashboard.mjs: alias "open", fallback nativo por plataforma via execFile
- keys.mjs: server-first (POST /api/v1/providers/keys) → DB fallback; valida
  provider via loadAvailableProviders(); suporte a --stdin
- models.mjs: GET /api/models → fallback /api/v1/models; filtro por provider e --search
- combo.mjs: list/switch/create/delete; switch server-first → DB fallback via key_value;
  TODO(1.5) marcados para substituir SQL cru por src/lib/db/combos.ts
- serve.mjs: escreve PID via writePidFile() no spawn; limpa no shutdown; suporte daemon

utils/pid.mjs e i18n keys (en.json + pt-BR.json) já criados em iteração anterior.

Testes: cli-keys-command.test.ts atualizado para novos runners;
        cli-serve-stop-command.test.ts, cli-combo-command.test.ts,
        cli-models-command.test.ts adicionados (23 testes, 0 falhas).
This commit is contained in:
diegosouzapw
2026-05-14 23:11:30 -03:00
parent 4ed8e4e673
commit 22d27ca273
15 changed files with 1199 additions and 12 deletions

229
bin/cli/commands/combo.mjs Normal file
View File

@@ -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 <name>")
.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 <name>")
.description("Create a new routing combo")
.addOption(
new Option("--strategy <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 <name>")
.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();
}
}

View File

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

214
bin/cli/commands/keys.mjs Normal file
View File

@@ -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 <provider> [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 <provider>")
.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 <provider> [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 <provider> <apiKey>");
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 <provider>");
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()));
});
}

100
bin/cli/commands/models.mjs Normal file
View File

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

View File

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

View File

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

View File

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

88
bin/cli/commands/stop.mjs Normal file
View File

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

View File

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

View File

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

65
bin/cli/utils/pid.mjs Normal file
View File

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

View File

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

View File

@@ -55,33 +55,71 @@ async function withCliKeysEnv(fn: (dataDir: string, dbPath: string) => Promise<v
}
}
test("legacy keys command writes and removes provider_connections with the real schema", async () => {
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);
});
});

View File

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

View File

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