mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
229
bin/cli/commands/combo.mjs
Normal file
229
bin/cli/commands/combo.mjs
Normal 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();
|
||||
}
|
||||
}
|
||||
56
bin/cli/commands/dashboard.mjs
Normal file
56
bin/cli/commands/dashboard.mjs
Normal 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
214
bin/cli/commands/keys.mjs
Normal 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
100
bin/cli/commands/models.mjs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
25
bin/cli/commands/restart.mjs
Normal file
25
bin/cli/commands/restart.mjs
Normal 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;
|
||||
}
|
||||
@@ -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
88
bin/cli/commands/stop.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
65
bin/cli/utils/pid.mjs
Normal 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;
|
||||
}
|
||||
135
tests/unit/cli-combo-command.test.ts
Normal file
135
tests/unit/cli-combo-command.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
100
tests/unit/cli-models-command.test.ts
Normal file
100
tests/unit/cli-models-command.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
53
tests/unit/cli-serve-stop-command.test.ts
Normal file
53
tests/unit/cli-serve-stop-command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user