[cli omniroute] Add modular CLI setup and provider commands (#2046)

Integrated into release/v3.8.0
This commit is contained in:
Wauputra
2026-05-09 03:35:01 +07:00
committed by GitHub
parent 39ae0314b6
commit bc6d0a3641
21 changed files with 2620 additions and 2 deletions

View File

@@ -665,7 +665,7 @@ PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm
**MCP:** `omniroute --mcp` (stdio transport)
**CLI options:** `omniroute --port 3000`, `omniroute --no-open`, `omniroute --help`
**CLI options:** `omniroute setup`, `omniroute doctor`, `omniroute providers available`, `omniroute providers list`, `omniroute --port 3000`, `omniroute --no-open`, `omniroute --help`
**Split-port mode:** `PORT=20128 DASHBOARD_PORT=20129 omniroute`

47
bin/cli/args.mjs Normal file
View File

@@ -0,0 +1,47 @@
export function parseArgs(argv = []) {
const flags = {};
const positionals = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg.startsWith("-") && !arg.startsWith("--") && arg.length > 1) {
for (const key of arg.slice(1)) {
flags[key] = true;
}
continue;
}
if (!arg.startsWith("--")) {
positionals.push(arg);
continue;
}
const eqIndex = arg.indexOf("=");
if (eqIndex !== -1) {
flags[arg.slice(2, eqIndex)] = arg.slice(eqIndex + 1);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith("--")) {
flags[key] = next;
i += 1;
} else {
flags[key] = true;
}
}
return { flags, positionals };
}
export function getStringFlag(flags, name, envName = null) {
const value = flags[name] ?? (envName ? process.env[envName] : undefined);
if (typeof value !== "string") return "";
return value.trim();
}
export function hasFlag(flags, name) {
return flags[name] === true;
}

518
bin/cli/commands/doctor.mjs Normal file
View File

@@ -0,0 +1,518 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { createDecipheriv, scryptSync } from "node:crypto";
import { pathToFileURL } from "node:url";
import { parseArgs, getStringFlag, hasFlag } from "../args.mjs";
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
import { printHeading } from "../io.mjs";
const STATIC_SALT = "omniroute-field-encryption-v1";
const KEY_LENGTH = 32;
const CHECK_TIMEOUT_MS = 2000;
function ok(name, message, details = {}) {
return { name, status: "ok", message, details };
}
function warn(name, message, details = {}) {
return { name, status: "warn", message, details };
}
function fail(name, message, details = {}) {
return { name, status: "fail", message, details };
}
function parsePort(value, fallback) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
function parseConfiguredPort(value) {
if (value === undefined || value === null || value === "") return { valid: true, port: null };
const parsed = Number.parseInt(String(value), 10);
return {
valid: Number.isFinite(parsed) && parsed > 0 && parsed <= 65535,
port: parsed,
};
}
function formatBytes(bytes) {
const gb = bytes / 1024 / 1024 / 1024;
return `${gb.toFixed(1)} GB`;
}
function findEnvFileCandidates(dataDir) {
const candidates = [];
if (process.env.DATA_DIR) candidates.push(path.join(process.env.DATA_DIR, ".env"));
candidates.push(path.join(dataDir, ".env"));
candidates.push(path.join(process.cwd(), ".env"));
return [...new Set(candidates)];
}
function checkConfig(dataDir) {
const envCandidates = findEnvFileCandidates(dataDir);
const envFile = envCandidates.find((candidate) => fs.existsSync(candidate));
const portChecks = [
["PORT", process.env.PORT],
["API_PORT", process.env.API_PORT],
["DASHBOARD_PORT", process.env.DASHBOARD_PORT],
].map(([name, value]) => ({ name, value, ...parseConfiguredPort(value) }));
const invalidPorts = portChecks.filter((item) => !item.valid);
if (invalidPorts.length > 0) {
return fail(
"Config",
`Invalid port setting: ${invalidPorts.map((item) => item.name).join(", ")}`,
{ envFile: envFile || null, invalidPorts }
);
}
if (!envFile) {
return warn("Config", ".env file not found; using defaults and process environment", {
checked: envCandidates,
});
}
return ok("Config", `.env found at ${envFile}`, { envFile });
}
async function loadBetterSqlite() {
try {
return (await import("better-sqlite3")).default;
} catch (error) {
return { error };
}
}
function resolveMigrationsDir(rootDir) {
const configured = process.env.OMNIROUTE_MIGRATIONS_DIR;
const candidates = [
configured,
path.join(rootDir, "src", "lib", "db", "migrations"),
path.join(rootDir, "app", "src", "lib", "db", "migrations"),
path.join(process.cwd(), "src", "lib", "db", "migrations"),
].filter(Boolean);
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
}
function readMigrationFiles(migrationsDir) {
if (!migrationsDir) return [];
return fs
.readdirSync(migrationsDir)
.filter((file) => /^\d+_.+\.sql$/.test(file))
.sort()
.map((file) => {
const [, version, name] = file.match(/^(\d+)_(.+)\.sql$/) || [];
return { version, name, file };
});
}
async function checkDatabase(dbPath, rootDir) {
if (!fs.existsSync(dbPath)) {
return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath });
}
const Database = await loadBetterSqlite();
if (Database.error) {
return fail("Database", "better-sqlite3 could not be loaded", {
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
});
}
let db;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
const quickCheck = db.prepare("PRAGMA quick_check").get();
const quickCheckValue = Object.values(quickCheck || {})[0];
if (quickCheckValue !== "ok") {
return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath });
}
const migrationsDir = resolveMigrationsDir(rootDir);
const migrationFiles = readMigrationFiles(migrationsDir);
if (migrationFiles.length === 0) {
return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" });
}
const table = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("_omniroute_migrations");
if (!table) {
return warn("Database", "SQLite is readable, but migration table is missing", { dbPath });
}
const appliedRows = db
.prepare("SELECT version FROM _omniroute_migrations")
.all()
.map((row) => row.version);
const applied = new Set(appliedRows);
const pending = migrationFiles.filter((migration) => !applied.has(migration.version));
if (pending.length > 0) {
return warn("Database", `${pending.length} migration(s) appear pending`, {
dbPath,
pending: pending.map((migration) => migration.file),
});
}
return ok("Database", "SQLite quick_check passed and migrations look current", { dbPath });
} catch (error) {
return fail("Database", "SQLite database could not be read", {
dbPath,
error: error instanceof Error ? error.message : String(error),
});
} finally {
if (db) db.close();
}
}
function deriveStorageKey() {
const secret = process.env.STORAGE_ENCRYPTION_KEY;
if (!secret) return null;
return scryptSync(secret, STATIC_SALT, KEY_LENGTH);
}
function decryptCredentialSample(value, key) {
const prefix = "enc:v1:";
const body = value.slice(prefix.length);
const [ivHex, encryptedHex, authTagHex] = body.split(":");
if (!ivHex || !encryptedHex || !authTagHex) throw new Error("Malformed encrypted value");
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
async function checkStorageEncryption(dbPath) {
const secret = process.env.STORAGE_ENCRYPTION_KEY;
if (secret !== undefined && String(secret).trim() === "") {
return fail("Storage/encryption", "STORAGE_ENCRYPTION_KEY is set but empty");
}
if (!fs.existsSync(dbPath)) {
return secret
? ok("Storage/encryption", "Encryption key is configured; database not initialized yet")
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
}
const Database = await loadBetterSqlite();
if (Database.error) {
return fail("Storage/encryption", "Could not inspect encrypted credentials", {
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
});
}
let db;
try {
db = new Database(dbPath, { readonly: true, fileMustExist: true });
const hasProviderTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("provider_connections");
if (!hasProviderTable) {
return secret
? ok("Storage/encryption", "Encryption key is configured; provider table not initialized")
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
}
const rows = db
.prepare(
`SELECT api_key, access_token, refresh_token, id_token
FROM provider_connections
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 20`
)
.all();
const encryptedValues = rows.flatMap((row) =>
["api_key", "access_token", "refresh_token", "id_token"]
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
.map((key) => row[key])
);
if (encryptedValues.length === 0) {
return secret
? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found")
: warn(
"Storage/encryption",
"No STORAGE_ENCRYPTION_KEY configured; credentials are plaintext"
);
}
if (!secret) {
return fail(
"Storage/encryption",
"Encrypted credentials exist but STORAGE_ENCRYPTION_KEY is missing",
{ encryptedSamples: encryptedValues.length }
);
}
const key = deriveStorageKey();
for (const value of encryptedValues) {
decryptCredentialSample(value, key);
}
return ok("Storage/encryption", "Encrypted credential samples decrypt successfully", {
encryptedSamples: encryptedValues.length,
});
} catch (error) {
return fail("Storage/encryption", "Encrypted credential check failed", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
if (db) db.close();
}
}
function checkPort(port, label) {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", (error) => {
if (error.code === "EADDRINUSE") {
resolve(warn("Port availability", `${label} port ${port} is already in use`, { port }));
} else {
resolve(
warn("Port availability", `${label} port ${port} could not be checked`, {
port,
error: error.message,
})
);
}
});
server.once("listening", () => {
server.close(() => {
resolve(ok("Port availability", `${label} port ${port} is available`, { port }));
});
});
server.listen(port, "127.0.0.1");
});
}
async function checkPorts() {
const port = parsePort(process.env.PORT || "20128", 20128);
const apiPort = parsePort(process.env.API_PORT || String(port), port);
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
const checks = await Promise.all([
checkPort(dashboardPort, "Dashboard"),
apiPort === dashboardPort ? Promise.resolve(null) : checkPort(apiPort, "API"),
]);
const results = checks.filter(Boolean);
const failResult = results.find((result) => result.status === "fail");
if (failResult) return failResult;
const warnResults = results.filter((result) => result.status === "warn");
if (warnResults.length > 0) {
return warn("Port availability", warnResults.map((result) => result.message).join("; "), {
ports: { apiPort, dashboardPort },
});
}
return ok("Port availability", "Configured port(s) are available", {
ports: { apiPort, dashboardPort },
});
}
async function checkNodeRuntime(rootDir) {
const { getNodeRuntimeSupport } = await import(
pathToFileURL(path.join(rootDir, "bin", "nodeRuntimeSupport.mjs")).href
);
const support = getNodeRuntimeSupport();
if (!support.nodeCompatible) {
return fail("Node runtime", `${support.nodeVersion} is outside supported policy`, support);
}
return ok("Node runtime", `${support.nodeVersion} is supported`, support);
}
async function checkNativeBinary(rootDir) {
const candidates = [
path.join(
rootDir,
"app",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
),
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
];
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
if (!binaryPath) {
return warn("Native binary", "better-sqlite3 native binary was not found", { candidates });
}
const { isNativeBinaryCompatible } = await import(
pathToFileURL(path.join(rootDir, "scripts", "native-binary-compat.mjs")).href
);
const compatible = isNativeBinaryCompatible(binaryPath);
if (!compatible) {
return fail("Native binary", "better-sqlite3 native binary is incompatible", { binaryPath });
}
return ok("Native binary", "better-sqlite3 native binary is compatible", { binaryPath });
}
function checkMemory() {
const configured = process.env.OMNIROUTE_MEMORY_MB || "512";
const memoryMb = Number.parseInt(configured, 10);
if (!Number.isFinite(memoryMb) || memoryMb < 64 || memoryMb > 16384) {
return fail("Memory", `Invalid OMNIROUTE_MEMORY_MB: ${configured}`, { configured });
}
const total = os.totalmem();
const free = os.freemem();
const requestedBytes = memoryMb * 1024 * 1024;
if (requestedBytes > total) {
return warn(
"Memory",
`Requested memory ${memoryMb} MB exceeds total RAM ${formatBytes(total)}`,
{
memoryMb,
totalBytes: total,
freeBytes: free,
}
);
}
return ok("Memory", `${memoryMb} MB limit configured; ${formatBytes(free)} free`, {
memoryMb,
totalBytes: total,
freeBytes: free,
});
}
async function fetchWithTimeout(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
function formatHostForUrl(host) {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function resolveLivenessUrl(options = {}) {
const explicitUrl = options.livenessUrl || process.env.OMNIROUTE_DOCTOR_LIVENESS_URL;
if (explicitUrl) return explicitUrl;
const port = parsePort(process.env.PORT || "20128", 20128);
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
const host = String(options.livenessHost || process.env.OMNIROUTE_DOCTOR_HOST || "127.0.0.1")
.trim()
.replace(/^https?:\/\//, "")
.replace(/\/.*$/, "");
return `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/api/health/degradation`;
}
async function checkServerLiveness(options = {}) {
const url = resolveLivenessUrl(options);
try {
const response = await fetchWithTimeout(url);
if (!response.ok) {
return warn("Server liveness", `Server responded with HTTP ${response.status}`, { url });
}
return ok("Server liveness", "Server health endpoint is reachable", { url });
} catch {
return warn("Server liveness", "Server health endpoint is not reachable", { url });
}
}
export async function collectDoctorChecks(context = {}, options = {}) {
const rootDir =
context.rootDir ||
path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "..");
const dataDir = resolveDataDir();
const dbPath = resolveStoragePath(dataDir);
const checks = [];
checks.push(checkConfig(dataDir));
checks.push(await checkDatabase(dbPath, rootDir));
checks.push(await checkStorageEncryption(dbPath));
checks.push(await checkPorts());
checks.push(await checkNodeRuntime(rootDir));
checks.push(await checkNativeBinary(rootDir));
checks.push(checkMemory());
if (!options.skipLiveness) {
checks.push(await checkServerLiveness(options));
}
return {
dataDir,
dbPath,
checks,
summary: {
ok: checks.filter((check) => check.status === "ok").length,
warn: checks.filter((check) => check.status === "warn").length,
fail: checks.filter((check) => check.status === "fail").length,
},
};
}
function printDoctorHelp() {
console.log(`
Usage:
omniroute doctor
omniroute doctor --json
omniroute doctor --no-liveness
omniroute doctor --host 0.0.0.0
Options:
--json Print machine-readable JSON
--no-liveness Skip HTTP health endpoint probing
--host <host> Host for server liveness probing (default: 127.0.0.1)
--liveness-url <url> Full health endpoint URL override
Checks:
config, database, storage/encryption, ports, Node runtime, native binary, memory, server liveness
`);
}
function printCheck(check) {
const label = check.status.toUpperCase().padEnd(4);
const color =
check.status === "ok" ? "\x1b[32m" : check.status === "warn" ? "\x1b[33m" : "\x1b[31m";
console.log(`${color}${label}\x1b[0m ${check.name}: ${check.message}`);
}
export async function runDoctorCommand(argv, context = {}) {
const { flags } = parseArgs(argv);
if (hasFlag(flags, "help") || hasFlag(flags, "h")) {
printDoctorHelp();
return 0;
}
const result = await collectDoctorChecks(context, {
skipLiveness: hasFlag(flags, "no-liveness"),
livenessHost: getStringFlag(flags, "host"),
livenessUrl: getStringFlag(flags, "liveness-url"),
});
if (hasFlag(flags, "json")) {
console.log(JSON.stringify(result, null, 2));
} else {
printHeading("OmniRoute Doctor");
console.log(`Data dir: ${result.dataDir}`);
console.log(`Database: ${result.dbPath}\n`);
for (const check of result.checks) {
printCheck(check);
}
console.log(
`\nSummary: ${result.summary.ok} ok, ${result.summary.warn} warning(s), ${result.summary.fail} failure(s)`
);
}
return result.summary.fail > 0 ? 1 : 0;
}

View File

@@ -0,0 +1,428 @@
import { parseArgs, getStringFlag, hasFlag } from "../args.mjs";
import { printHeading } from "../io.mjs";
import { getAvailableProviderCategories, loadAvailableProviders } from "../provider-catalog.mjs";
import { testProviderApiKey } from "../provider-test.mjs";
import {
findProviderConnection,
getProviderApiKey,
listProviderConnections,
updateProviderTestResult,
} from "../provider-store.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
function publicConnection(connection) {
return {
id: connection.id,
provider: connection.provider,
name: connection.name,
authType: connection.authType,
isActive: connection.isActive,
testStatus: connection.testStatus,
lastTested: connection.lastTested,
lastError: connection.lastError,
defaultModel: connection.defaultModel,
};
}
function printProvidersHelp() {
console.log(`
Usage:
omniroute providers available
omniroute providers available --search openai
omniroute providers available --category api-key
omniroute providers list
omniroute providers test <id|name>
omniroute providers test-all
omniroute providers validate
Options:
--json Print machine-readable JSON
--search, --q <text> Filter available providers by id, name, alias, or category
--category <category> Filter available providers by category
Notes:
"available" shows the OmniRoute provider catalog.
"list" shows provider connections already configured in local SQLite.
Provider commands read local SQLite directly and do not require the server to be running.
API-key provider tests update test_status, last_tested, and error fields in SQLite.
`);
}
function printAvailableHelp() {
console.log(`
Usage:
omniroute providers available
omniroute providers available --search openai
omniroute providers available --category api-key
omniroute providers available --json
Options:
--json Print machine-readable JSON
--search, --q <text> Filter by id, name, alias, or category
--category <category> Filter by category, for example api-key, oauth, free
Notes:
Shows the OmniRoute provider catalog, not locally configured provider connections.
`);
}
function printListHelp() {
console.log(`
Usage:
omniroute providers list
omniroute providers list --json
Options:
--json Print machine-readable JSON
Notes:
Lists provider connections already configured in local SQLite.
`);
}
function printTestHelp() {
console.log(`
Usage:
omniroute providers test <id|name>
omniroute providers test <id|name> --json
Options:
--json Print machine-readable JSON
Notes:
Tests one configured provider connection and updates test status in local SQLite.
`);
}
function printTestAllHelp() {
console.log(`
Usage:
omniroute providers test-all
omniroute providers test-all --json
Options:
--json Print machine-readable JSON
Notes:
Tests every active configured provider connection and updates test status in local SQLite.
`);
}
function printValidateHelp() {
console.log(`
Usage:
omniroute providers validate
omniroute providers validate --json
Options:
--json Print machine-readable JSON
Notes:
Validates local provider configuration without calling upstream providers.
`);
}
function printProvidersSubcommandHelp(subcommand) {
if (subcommand === "available") printAvailableHelp();
else if (subcommand === "list") printListHelp();
else if (subcommand === "test") printTestHelp();
else if (subcommand === "test-all") printTestAllHelp();
else if (subcommand === "validate") printValidateHelp();
else printProvidersHelp();
}
function statusColor(status) {
if (status === "active" || status === "success") return "\x1b[32m";
if (status === "error" || status === "expired" || status === "unavailable") return "\x1b[31m";
return "\x1b[33m";
}
function printProviderTable(connections) {
if (connections.length === 0) {
console.log("No providers configured.");
return;
}
for (const connection of connections) {
const shortId = connection.id.slice(0, 8);
const status = connection.testStatus || "unknown";
const color = statusColor(status);
console.log(
`${shortId.padEnd(10)} ${connection.provider.padEnd(14)} ${String(connection.name).padEnd(
24
)} ${color}${status}\x1b[0m`
);
}
}
function normalizeCategoryFilter(category) {
const normalized = String(category || "")
.trim()
.toLowerCase()
.replaceAll("_", "-");
if (normalized === "apikey") return "api-key";
return normalized;
}
function availableProviderNotes(provider) {
const notes = [];
if (provider.alias) notes.push(`alias:${provider.alias}`);
if (provider.hasFree) notes.push("free");
if (provider.passthroughModels) notes.push("passthrough");
if (provider.deprecated) notes.push("deprecated");
return notes.join(", ");
}
function publicAvailableProvider(provider) {
return {
id: provider.id,
name: provider.name,
category: provider.category,
alias: provider.alias,
website: provider.website,
deprecated: provider.deprecated,
hasFree: provider.hasFree,
passthroughModels: provider.passthroughModels,
};
}
function filterAvailableProviders(providers, flags) {
const search = String(getStringFlag(flags, "search") || getStringFlag(flags, "q") || "")
.trim()
.toLowerCase();
const category = normalizeCategoryFilter(getStringFlag(flags, "category"));
return providers.filter((provider) => {
if (category && provider.category !== category) return false;
if (!search) return true;
return [provider.id, provider.name, provider.category, provider.alias]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(search));
});
}
function printAvailableProviderTable(providers, categories) {
if (providers.length === 0) {
console.log("No available providers matched the filters.");
return;
}
console.log(`${providers.length} providers available.`);
console.log(`Categories: ${categories.join(", ")}`);
console.log("Use --search <text> or --category <category> to filter.\n");
console.log(`${"ID".padEnd(24)} ${"Category".padEnd(14)} ${"Name".padEnd(28)} Notes`);
for (const provider of providers) {
console.log(
`${provider.id.padEnd(24)} ${provider.category.padEnd(14)} ${String(provider.name).padEnd(
28
)} ${availableProviderNotes(provider)}`
);
}
}
function buildTestInput(connection, apiKey) {
return {
provider: connection.provider,
apiKey,
defaultModel: connection.defaultModel,
baseUrl: connection.providerSpecificData?.baseUrl || null,
};
}
async function runProviderTest(db, connection) {
try {
const apiKey = getProviderApiKey(connection);
const result = await testProviderApiKey(buildTestInput(connection, apiKey));
updateProviderTestResult(db, connection.id, result);
return {
connection: publicConnection(connection),
...result,
};
} catch (error) {
const result = {
valid: false,
error: error instanceof Error ? error.message : String(error),
statusCode: null,
};
updateProviderTestResult(db, connection.id, result);
return {
connection: publicConnection(connection),
...result,
};
}
}
function validateConnection(connection) {
const issues = [];
const warnings = [];
if (!connection.id) issues.push("Missing id");
if (!connection.provider) issues.push("Missing provider");
if (!connection.authType) warnings.push("Missing auth type");
if (connection.authType === "apikey") {
try {
getProviderApiKey(connection);
} catch (error) {
issues.push(error instanceof Error ? error.message : String(error));
}
} else if (!connection.accessToken && !connection.refreshToken) {
warnings.push("OAuth connection has no access or refresh token visible locally");
}
if (connection.providerSpecificData === null && connection.providerSpecificData !== undefined) {
warnings.push("provider_specific_data is absent or not an object");
}
return {
connection: publicConnection(connection),
valid: issues.length === 0,
issues,
warnings,
};
}
async function availableCommand(flags) {
const allProviders = loadAvailableProviders();
const providers = filterAvailableProviders(allProviders, flags).map(publicAvailableProvider);
const categories = getAvailableProviderCategories(allProviders);
if (hasFlag(flags, "json")) {
console.log(JSON.stringify({ count: providers.length, categories, providers }, null, 2));
} else {
printHeading("OmniRoute Available Providers");
printAvailableProviderTable(providers, categories);
}
return 0;
}
async function listCommand(flags) {
const { db } = await openOmniRouteDb();
try {
const connections = listProviderConnections(db).map(publicConnection);
if (hasFlag(flags, "json")) {
console.log(JSON.stringify({ providers: connections }, null, 2));
} else {
printHeading("OmniRoute Providers");
printProviderTable(connections);
}
return 0;
} finally {
db.close();
}
}
async function testCommand(flags, selector) {
if (!selector) {
console.error("Provider id or name is required.");
return 1;
}
const { db } = await openOmniRouteDb();
try {
const connection = findProviderConnection(db, selector);
if (!connection) {
console.error(`Provider connection not found: ${selector}`);
return 1;
}
const result = await runProviderTest(db, connection);
if (hasFlag(flags, "json")) {
console.log(JSON.stringify(result, null, 2));
} else if (result.valid) {
console.log(`\x1b[32mOK\x1b[0m ${connection.name}: provider test passed`);
} else {
console.log(`\x1b[31mFAIL\x1b[0m ${connection.name}: ${result.error}`);
}
return result.valid ? 0 : 1;
} finally {
db.close();
}
}
async function testAllCommand(flags) {
const { db } = await openOmniRouteDb();
try {
const connections = listProviderConnections(db);
const results = [];
for (const connection of connections) {
if (!connection.isActive) {
results.push({
connection: publicConnection(connection),
valid: false,
skipped: true,
error: "Connection is inactive",
});
continue;
}
results.push(await runProviderTest(db, connection));
}
if (hasFlag(flags, "json")) {
console.log(JSON.stringify({ results }, null, 2));
} else {
printHeading("OmniRoute Provider Tests");
for (const result of results) {
const label = result.valid
? "\x1b[32mOK\x1b[0m"
: result.skipped
? "\x1b[33mSKIP\x1b[0m"
: "\x1b[31mFAIL\x1b[0m";
console.log(
`${label} ${result.connection.name}: ${result.valid ? "provider test passed" : result.error}`
);
}
}
return results.some((result) => !result.valid && !result.skipped) ? 1 : 0;
} finally {
db.close();
}
}
async function validateCommand(flags) {
const { db } = await openOmniRouteDb();
try {
const results = listProviderConnections(db).map(validateConnection);
if (hasFlag(flags, "json")) {
console.log(JSON.stringify({ results }, null, 2));
} else {
printHeading("OmniRoute Provider Validation");
if (results.length === 0) {
console.log("No providers configured.");
}
for (const result of results) {
const label = result.valid ? "\x1b[32mOK\x1b[0m" : "\x1b[31mFAIL\x1b[0m";
const messages = [...result.issues, ...result.warnings].join("; ");
console.log(`${label} ${result.connection.name}${messages ? `: ${messages}` : ""}`);
}
}
return results.some((result) => !result.valid) ? 1 : 0;
} finally {
db.close();
}
}
export async function runProvidersCommand(argv) {
const { flags, positionals } = parseArgs(argv);
const requestedSubcommand = positionals[0];
const subcommand = requestedSubcommand || "list";
if (hasFlag(flags, "help") || hasFlag(flags, "h")) {
printProvidersSubcommandHelp(requestedSubcommand);
return 0;
}
if (subcommand === "available") return availableCommand(flags);
if (subcommand === "list") return listCommand(flags);
if (subcommand === "test") return testCommand(flags, positionals[1]);
if (subcommand === "test-all") return testAllCommand(flags);
if (subcommand === "validate") return validateCommand(flags);
console.error(`Unknown providers subcommand: ${subcommand}`);
printProvidersHelp();
return 1;
}

200
bin/cli/commands/setup.mjs Normal file
View File

@@ -0,0 +1,200 @@
import { parseArgs, getStringFlag, hasFlag } from "../args.mjs";
import { createPrompt, printHeading, printInfo, printSuccess } from "../io.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { getSettings, hashManagementPassword, updateSettings } from "../settings-store.mjs";
import { testProviderApiKey } from "../provider-test.mjs";
import { updateProviderTestResult, upsertApiKeyProviderConnection } from "../provider-store.mjs";
import {
formatProviderChoices,
getProviderDisplayName,
resolveProviderChoice,
} from "../provider-catalog.mjs";
function wantsProviderSetup(flags) {
return (
hasFlag(flags, "add-provider") ||
Boolean(getStringFlag(flags, "provider", "OMNIROUTE_PROVIDER")) ||
Boolean(getStringFlag(flags, "api-key", "OMNIROUTE_API_KEY"))
);
}
async function resolvePassword(flags, prompt, nonInteractive) {
const flagPassword = getStringFlag(flags, "password", "OMNIROUTE_SETUP_PASSWORD");
if (flagPassword) return flagPassword;
if (nonInteractive) return "";
const answer = await prompt.ask("Set an admin password now? [y/N]", "N");
if (!/^y(es)?$/i.test(answer)) return "";
const password = await prompt.ask("Admin password");
const confirm = await prompt.ask("Confirm password");
if (password !== confirm) {
throw new Error("Passwords do not match.");
}
return password;
}
async function setupPassword(db, flags, prompt, nonInteractive) {
const password = await resolvePassword(flags, prompt, nonInteractive);
if (!password) {
const settings = getSettings(db);
if (!settings.password) {
updateSettings(db, { requireLogin: false });
}
if (!nonInteractive) {
printInfo("Password setup skipped. Dashboard login remains disabled.");
}
return false;
}
if (password.length < 8) {
throw new Error("Password must be at least 8 characters.");
}
const hashedPassword = await hashManagementPassword(password);
updateSettings(db, {
password: hashedPassword,
requireLogin: true,
});
printSuccess("Admin password configured");
return true;
}
async function resolveProviderInput(flags, prompt, nonInteractive) {
let provider = getStringFlag(flags, "provider", "OMNIROUTE_PROVIDER");
let apiKey = getStringFlag(flags, "api-key", "OMNIROUTE_API_KEY");
let name = getStringFlag(flags, "provider-name", "OMNIROUTE_PROVIDER_NAME");
const defaultModel = getStringFlag(flags, "default-model", "OMNIROUTE_DEFAULT_MODEL");
const baseUrl = getStringFlag(flags, "provider-base-url", "OMNIROUTE_PROVIDER_BASE_URL");
if (!provider && !nonInteractive) {
console.log("Choose a provider:");
console.log(formatProviderChoices());
provider = resolveProviderChoice(await prompt.ask("Provider", "1"));
}
provider = provider || "openai";
if (!apiKey && !nonInteractive) {
apiKey = await prompt.ask(`${getProviderDisplayName(provider)} API key`);
}
if (!apiKey) {
throw new Error("Provider API key is required. Pass --api-key or OMNIROUTE_API_KEY.");
}
if (!name) {
name = getProviderDisplayName(provider);
}
return {
provider,
apiKey,
name,
defaultModel: defaultModel || null,
providerSpecificData: baseUrl ? { baseUrl } : null,
};
}
async function setupProvider(db, flags, prompt, nonInteractive) {
if (!wantsProviderSetup(flags) && nonInteractive) return null;
if (!wantsProviderSetup(flags)) {
const answer = await prompt.ask("Add your first provider now? [Y/n]", "Y");
if (/^n(o)?$/i.test(answer)) return null;
}
const input = await resolveProviderInput(flags, prompt, nonInteractive);
const connection = upsertApiKeyProviderConnection(db, input);
printSuccess(`Provider configured: ${connection.name}`);
if (hasFlag(flags, "test-provider")) {
printInfo(`Testing provider connection: ${connection.provider}`);
const result = await testProviderApiKey({
provider: input.provider,
apiKey: input.apiKey,
defaultModel: input.defaultModel,
baseUrl: input.providerSpecificData?.baseUrl || null,
});
updateProviderTestResult(db, connection.id, result);
if (result.valid) {
printSuccess("Provider test passed");
} else {
printInfo(`Provider test failed: ${result.error || "unknown error"}`);
}
}
return connection;
}
function printSetupHelp() {
console.log(`
Usage:
omniroute setup
omniroute setup --password <password>
omniroute setup --add-provider --provider openai --api-key <key>
omniroute setup --non-interactive
Options:
--password <value> Set admin password
--add-provider Add an API-key provider connection
--provider <id> Provider id, for example openai or anthropic
--provider-name <name> Display name for the connection
--api-key <value> Provider API key
--default-model <model> Optional default model
--provider-base-url <url> Optional OpenAI-compatible base URL override
--test-provider Test the provider after saving it
--non-interactive Read all inputs from flags/env and do not prompt
Environment:
OMNIROUTE_SETUP_PASSWORD
OMNIROUTE_PROVIDER
OMNIROUTE_PROVIDER_NAME
OMNIROUTE_PROVIDER_BASE_URL
OMNIROUTE_API_KEY
OMNIROUTE_DEFAULT_MODEL
DATA_DIR
`);
}
export async function runSetupCommand(argv) {
const { flags } = parseArgs(argv);
if (hasFlag(flags, "help") || hasFlag(flags, "h")) {
printSetupHelp();
return 0;
}
const nonInteractive = hasFlag(flags, "non-interactive");
const prompt = createPrompt();
try {
printHeading("OmniRoute Setup");
const { db, dbPath } = await openOmniRouteDb();
printInfo(`Database: ${dbPath}`);
const before = getSettings(db);
const passwordChanged = await setupPassword(db, flags, prompt, nonInteractive);
const providerConnection = await setupProvider(db, flags, prompt, nonInteractive);
updateSettings(db, { setupComplete: true });
const after = getSettings(db);
db.close();
console.log("");
printSuccess("Setup complete");
printInfo(
`Login: ${after.requireLogin === true ? "enabled" : "disabled"}${
passwordChanged ? " (password updated)" : ""
}`
);
if (providerConnection) {
printInfo(`Provider: ${providerConnection.provider} (${providerConnection.name})`);
} else if (!before.setupComplete) {
printInfo("Provider: skipped");
}
return 0;
} finally {
prompt.close();
}
}

38
bin/cli/data-dir.mjs Normal file
View File

@@ -0,0 +1,38 @@
import os from "node:os";
import path from "node:path";
const APP_NAME = "omniroute";
function normalizeConfiguredPath(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? path.resolve(trimmed) : null;
}
function safeHomeDir() {
try {
return os.homedir();
} catch {
return process.env.HOME || process.env.USERPROFILE || os.tmpdir();
}
}
export function resolveDataDir() {
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
if (configured) return configured;
const homeDir = safeHomeDir();
if (process.platform === "win32") {
const appData = process.env.APPDATA || path.join(homeDir, "AppData", "Roaming");
return path.join(appData, APP_NAME);
}
const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME);
if (xdgConfigHome) return path.join(xdgConfigHome, APP_NAME);
return path.join(homeDir, `.${APP_NAME}`);
}
export function resolveStoragePath(dataDir = resolveDataDir()) {
return path.join(dataDir, "storage.sqlite");
}

62
bin/cli/encryption.mjs Normal file
View File

@@ -0,0 +1,62 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
const PREFIX = "enc:v1:";
// Keep this salt in sync with the app-side field encryption format so credentials written by
// CLI setup remain decryptable by the dashboard/server and vice versa.
const STATIC_SALT = "omniroute-field-encryption-v1";
let cachedKey = null;
function getEncryptionKey() {
if (cachedKey !== null) return cachedKey;
const secret = process.env.STORAGE_ENCRYPTION_KEY;
if (!secret || typeof secret !== "string" || secret.trim().length === 0) {
return null;
}
cachedKey = scryptSync(secret, STATIC_SALT, KEY_LENGTH);
return cachedKey;
}
export function encryptCredential(value) {
if (!value || typeof value !== "string" || value.startsWith(PREFIX)) return value || null;
const key = getEncryptionKey();
if (!key) return value;
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`;
}
export function decryptCredential(value) {
if (!value || typeof value !== "string") return value || null;
if (!value.startsWith(PREFIX)) return value;
const key = getEncryptionKey();
if (!key) {
throw new Error("STORAGE_ENCRYPTION_KEY is required to decrypt this provider credential.");
}
const body = value.slice(PREFIX.length);
const parts = body.split(":");
if (parts.length !== 3) {
throw new Error("Malformed encrypted provider credential.");
}
const [ivHex, encryptedHex, authTagHex] = parts;
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"));
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}

19
bin/cli/index.mjs Normal file
View File

@@ -0,0 +1,19 @@
import { runDoctorCommand } from "./commands/doctor.mjs";
import { runProvidersCommand } from "./commands/providers.mjs";
import { runSetupCommand } from "./commands/setup.mjs";
export async function runCliCommand(command, argv, context = {}) {
if (command === "doctor") {
return runDoctorCommand(argv, context);
}
if (command === "providers") {
return runProvidersCommand(argv, context);
}
if (command === "setup") {
return runSetupCommand(argv, context);
}
throw new Error(`Unknown CLI command: ${command}`);
}

36
bin/cli/io.mjs Normal file
View File

@@ -0,0 +1,36 @@
import readline from "node:readline";
export function createPrompt() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function ask(question, defaultValue = "") {
const suffix = defaultValue ? ` (${defaultValue})` : "";
return new Promise((resolve) => {
rl.question(`${question}${suffix}: `, (answer) => {
const trimmed = answer.trim();
resolve(trimmed || defaultValue);
});
});
}
function close() {
rl.close();
}
return { ask, close };
}
export function printHeading(title) {
console.log(`\n\x1b[1m\x1b[36m${title}\x1b[0m\n`);
}
export function printSuccess(message) {
console.log(`\x1b[32m✔ ${message}\x1b[0m`);
}
export function printInfo(message) {
console.log(`\x1b[2m${message}\x1b[0m`);
}

View File

@@ -0,0 +1,175 @@
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const CLI_DIR = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ROOT_DIR = join(CLI_DIR, "..", "..");
const require = createRequire(import.meta.url);
export const COMMON_PROVIDERS = [
{ id: "openai", name: "OpenAI" },
{ id: "anthropic", name: "Anthropic" },
{ id: "google", name: "Google AI" },
{ id: "openrouter", name: "OpenRouter" },
{ id: "groq", name: "Groq" },
{ id: "mistral", name: "Mistral" },
];
function normalizeCatalogCategory(exportName) {
const raw = exportName
.replace(/_PROVIDERS$/, "")
.toLowerCase()
.replaceAll("_", "-");
if (raw === "apikey") return "api-key";
return raw;
}
function loadTypeScript() {
try {
return require("typescript");
} catch {
return null;
}
}
function getPropertyName(ts, name) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
}
function getObjectProperty(ts, objectLiteral, propertyName) {
return objectLiteral.properties.find(
(property) =>
ts.isPropertyAssignment(property) && getPropertyName(ts, property.name) === propertyName
);
}
function getStringProperty(ts, objectLiteral, propertyName) {
const property = getObjectProperty(ts, objectLiteral, propertyName);
const initializer = property?.initializer;
if (!initializer) return null;
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
return initializer.text;
}
return null;
}
function getBooleanProperty(ts, objectLiteral, propertyName) {
const property = getObjectProperty(ts, objectLiteral, propertyName);
const initializer = property?.initializer;
return initializer?.kind === ts.SyntaxKind.TrueKeyword;
}
function extractProviderBlocks(source, filePath) {
const ts = loadTypeScript();
if (!ts) return [];
const providers = [];
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
sourceFile.forEachChild((node) => {
if (!ts.isVariableStatement(node)) return;
for (const declaration of node.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name)) continue;
const exportName = declaration.name.text;
if (!exportName.endsWith("_PROVIDERS")) continue;
if (!declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) {
continue;
}
const category = normalizeCatalogCategory(exportName);
for (const property of declaration.initializer.properties) {
if (!ts.isPropertyAssignment(property)) continue;
if (!ts.isObjectLiteralExpression(property.initializer)) continue;
const key = getPropertyName(ts, property.name);
if (!key) continue;
const id = getStringProperty(ts, property.initializer, "id") || key;
const name = getStringProperty(ts, property.initializer, "name") || id;
providers.push({
id,
name,
category,
alias: getStringProperty(ts, property.initializer, "alias"),
website: getStringProperty(ts, property.initializer, "website"),
deprecated: getBooleanProperty(ts, property.initializer, "deprecated"),
hasFree: getBooleanProperty(ts, property.initializer, "hasFree"),
passthroughModels: getBooleanProperty(ts, property.initializer, "passthroughModels"),
});
}
}
});
return providers;
}
function fallbackAvailableProviders() {
return COMMON_PROVIDERS.map((provider) => ({
...provider,
category: "api-key",
alias: null,
website: null,
deprecated: false,
hasFree: false,
passthroughModels: false,
}));
}
function resolveProviderCatalogPath(rootDir, options = {}) {
const configuredPath = options.catalogPath || process.env.OMNIROUTE_PROVIDER_CATALOG_PATH;
if (configuredPath) {
return isAbsolute(configuredPath) ? configuredPath : resolve(rootDir, configuredPath);
}
return join(rootDir, "src", "shared", "constants", "providers.ts");
}
export function loadAvailableProviders(options = {}) {
const rootDir = typeof options === "string" ? options : options.rootDir || DEFAULT_ROOT_DIR;
const providersPath = resolveProviderCatalogPath(rootDir, options);
if (!existsSync(providersPath)) {
return fallbackAvailableProviders();
}
try {
const source = readFileSync(providersPath, "utf-8");
const providers = extractProviderBlocks(source, providersPath);
if (providers.length === 0) return fallbackAvailableProviders();
const seen = new Set();
return providers.filter((provider) => {
if (seen.has(provider.id)) return false;
seen.add(provider.id);
return true;
});
} catch {
return fallbackAvailableProviders();
}
}
export function getAvailableProviderCategories(providers = loadAvailableProviders()) {
return [...new Set(providers.map((provider) => provider.category))].sort();
}
export function getProviderDisplayName(providerId) {
return COMMON_PROVIDERS.find((provider) => provider.id === providerId)?.name || providerId;
}
export function formatProviderChoices() {
return COMMON_PROVIDERS.map((provider, index) => `${index + 1}. ${provider.name}`).join("\n");
}
export function resolveProviderChoice(value) {
const trimmed = String(value || "").trim();
const numeric = Number.parseInt(trimmed, 10);
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= COMMON_PROVIDERS.length) {
return COMMON_PROVIDERS[numeric - 1].id;
}
return trimmed || "openai";
}

276
bin/cli/provider-store.mjs Normal file
View File

@@ -0,0 +1,276 @@
import { randomUUID } from "node:crypto";
import { decryptCredential, encryptCredential } from "./encryption.mjs";
const REQUIRED_PROVIDER_COLUMNS = [
["auth_type", "TEXT"],
["name", "TEXT"],
["email", "TEXT"],
["priority", "INTEGER DEFAULT 0"],
["is_active", "INTEGER DEFAULT 1"],
["access_token", "TEXT"],
["refresh_token", "TEXT"],
["expires_at", "TEXT"],
["token_expires_at", "TEXT"],
["scope", "TEXT"],
["project_id", "TEXT"],
["test_status", "TEXT"],
["error_code", "TEXT"],
["last_error", "TEXT"],
["last_error_at", "TEXT"],
["last_error_type", "TEXT"],
["last_error_source", "TEXT"],
["backoff_level", "INTEGER DEFAULT 0"],
["rate_limited_until", "TEXT"],
["health_check_interval", "INTEGER"],
["last_health_check_at", "TEXT"],
["last_tested", "TEXT"],
["api_key", "TEXT"],
["id_token", "TEXT"],
["provider_specific_data", "TEXT"],
["expires_in", "INTEGER"],
["display_name", "TEXT"],
["global_priority", "INTEGER"],
["default_model", "TEXT"],
["token_type", "TEXT"],
["consecutive_use_count", "INTEGER DEFAULT 0"],
["rate_limit_protection", "INTEGER DEFAULT 0"],
["last_used_at", "TEXT"],
['"group"', "TEXT"],
["max_concurrent", "INTEGER"],
["created_at", "TEXT"],
["updated_at", "TEXT"],
];
function ensureProviderColumns(db) {
const existingColumns = new Set(
db
.prepare("PRAGMA table_info(provider_connections)")
.all()
.map((column) => column.name)
);
const missingColumns = REQUIRED_PROVIDER_COLUMNS.filter(([name]) => {
const normalizedName = name.replaceAll('"', "");
return !existingColumns.has(normalizedName);
});
if (missingColumns.length === 0) return;
db.transaction(() => {
for (const [name, type] of missingColumns) {
db.prepare(`ALTER TABLE provider_connections ADD COLUMN ${name} ${type}`).run();
}
})();
}
export function ensureProviderSchema(db) {
db.prepare(
`CREATE TABLE IF NOT EXISTS provider_connections (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
auth_type TEXT,
name TEXT,
email TEXT,
priority INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
access_token TEXT,
refresh_token TEXT,
expires_at TEXT,
token_expires_at TEXT,
scope TEXT,
project_id TEXT,
test_status TEXT,
error_code TEXT,
last_error TEXT,
last_error_at TEXT,
last_error_type TEXT,
last_error_source TEXT,
backoff_level INTEGER DEFAULT 0,
rate_limited_until TEXT,
health_check_interval INTEGER,
last_health_check_at TEXT,
last_tested TEXT,
api_key TEXT,
id_token TEXT,
provider_specific_data TEXT,
expires_in INTEGER,
display_name TEXT,
global_priority INTEGER,
default_model TEXT,
token_type TEXT,
consecutive_use_count INTEGER DEFAULT 0,
rate_limit_protection INTEGER DEFAULT 0,
last_used_at TEXT,
"group" TEXT,
max_concurrent INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`
).run();
ensureProviderColumns(db);
db.prepare("CREATE INDEX IF NOT EXISTS idx_pc_provider ON provider_connections(provider)").run();
db.prepare("CREATE INDEX IF NOT EXISTS idx_pc_active ON provider_connections(is_active)").run();
db.prepare(
"CREATE INDEX IF NOT EXISTS idx_pc_priority ON provider_connections(provider, priority)"
).run();
}
function nextPriority(db, provider) {
const row = db
.prepare("SELECT MAX(priority) as max_priority FROM provider_connections WHERE provider = ?")
.get(provider);
return Number(row?.max_priority || 0) + 1;
}
function parseJsonObject(value) {
if (!value || typeof value !== "string") return undefined;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function rowToConnection(row) {
return {
id: row.id,
provider: row.provider,
authType: row.auth_type || "oauth",
name: row.name || row.display_name || row.email || row.provider,
email: row.email || null,
priority: row.priority || 0,
isActive: row.is_active !== 0,
apiKey: row.api_key || null,
accessToken: row.access_token || null,
refreshToken: row.refresh_token || null,
idToken: row.id_token || null,
providerSpecificData: parseJsonObject(row.provider_specific_data),
testStatus: row.test_status || "unknown",
defaultModel: row.default_model || null,
lastTested: row.last_tested || null,
lastError: row.last_error || null,
updatedAt: row.updated_at || null,
createdAt: row.created_at || null,
};
}
export function listProviderConnections(db) {
ensureProviderSchema(db);
return db
.prepare(
`SELECT * FROM provider_connections
ORDER BY provider ASC, priority ASC, updated_at DESC`
)
.all()
.map(rowToConnection);
}
export function findProviderConnection(db, selector) {
const normalized = String(selector || "")
.trim()
.toLowerCase();
if (!normalized) return null;
const connections = listProviderConnections(db);
return (
connections.find((connection) => connection.id.toLowerCase() === normalized) ||
connections.find((connection) => connection.id.toLowerCase().startsWith(normalized)) ||
connections.find((connection) => String(connection.name || "").toLowerCase() === normalized) ||
connections.find((connection) => connection.provider.toLowerCase() === normalized) ||
null
);
}
export function getProviderApiKey(connection) {
if (connection.authType !== "apikey") {
throw new Error(`Connection ${connection.name} is not an API-key provider.`);
}
if (!connection.apiKey) {
throw new Error(`Connection ${connection.name} has no API key configured.`);
}
return decryptCredential(connection.apiKey);
}
export function upsertApiKeyProviderConnection(db, input) {
ensureProviderSchema(db);
const provider = input.provider;
const name = input.name || provider;
const now = new Date().toISOString();
const existing = db
.prepare(
"SELECT id, priority FROM provider_connections WHERE provider = ? AND auth_type = 'apikey' AND name = ?"
)
.get(provider, name);
const connection = {
id: existing?.id || randomUUID(),
provider,
authType: "apikey",
name,
priority: existing?.priority || nextPriority(db, provider),
isActive: 1,
apiKey: encryptCredential(input.apiKey),
testStatus: input.testStatus || "unknown",
defaultModel: input.defaultModel || null,
providerSpecificData: input.providerSpecificData
? JSON.stringify(input.providerSpecificData)
: null,
createdAt: input.createdAt || now,
updatedAt: now,
};
db.prepare(
`INSERT INTO provider_connections (
id, provider, auth_type, name, priority, is_active, api_key, provider_specific_data,
test_status, default_model, created_at, updated_at
) VALUES (
@id, @provider, @authType, @name, @priority, @isActive, @apiKey, @providerSpecificData,
@testStatus, @defaultModel, @createdAt, @updatedAt
)
ON CONFLICT(id) DO UPDATE SET
api_key = excluded.api_key,
provider_specific_data = excluded.provider_specific_data,
test_status = excluded.test_status,
default_model = excluded.default_model,
is_active = excluded.is_active,
updated_at = excluded.updated_at`
).run(connection);
return connection;
}
export function updateProviderTestResult(db, connectionId, result) {
ensureProviderSchema(db);
const now = new Date().toISOString();
const valid = result?.valid === true;
db.prepare(
`UPDATE provider_connections SET
test_status = @testStatus,
last_error = @lastError,
last_error_at = @lastErrorAt,
last_error_type = @lastErrorType,
last_error_source = @lastErrorSource,
error_code = @errorCode,
last_tested = @lastTested,
updated_at = @updatedAt
WHERE id = @id`
).run({
id: connectionId,
testStatus: valid ? "active" : "error",
lastError: valid ? null : result?.error || "Provider test failed",
lastErrorAt: valid ? null : now,
lastErrorType: valid ? null : "connection_test_failed",
lastErrorSource: valid ? null : "upstream",
errorCode: valid ? null : result?.statusCode || null,
lastTested: now,
updatedAt: now,
});
return {
...result,
testedAt: now,
};
}

181
bin/cli/provider-test.mjs Normal file
View File

@@ -0,0 +1,181 @@
const DEFAULT_TIMEOUT_MS = 15000;
const PROVIDER_TEST_CONFIGS = {
openai: {
format: "openai",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini",
},
openrouter: {
format: "openai",
baseUrl: "https://openrouter.ai/api/v1",
model: "openai/gpt-4o-mini",
},
groq: {
format: "openai",
baseUrl: "https://api.groq.com/openai/v1",
model: "llama-3.1-8b-instant",
},
mistral: {
format: "openai",
baseUrl: "https://api.mistral.ai/v1",
model: "mistral-small-latest",
},
anthropic: {
format: "anthropic",
baseUrl: "https://api.anthropic.com/v1",
model: "claude-3-5-haiku-latest",
},
google: {
format: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
model: "gemini-1.5-flash",
},
};
function joinUrl(baseUrl, suffix) {
return `${baseUrl.replace(/\/+$/, "")}/${suffix.replace(/^\/+/, "")}`;
}
function providerEnvName(provider, suffix) {
const normalizedProvider = String(provider || "")
.toUpperCase()
.replace(/[^A-Z0-9]/g, "_");
return `OMNIROUTE_PROVIDER_TEST_${normalizedProvider}_${suffix}`;
}
function resolveTestModel(input, config) {
const providerOverride = process.env[providerEnvName(input.provider, "MODEL")];
return (
input.defaultModel ||
providerOverride ||
process.env.OMNIROUTE_PROVIDER_TEST_MODEL ||
config.model
);
}
function resolveProviderConfig(input) {
const config = PROVIDER_TEST_CONFIGS[input.provider];
if (!config) return null;
return {
...config,
baseUrl: input.baseUrl || config.baseUrl,
model: resolveTestModel(input, config),
};
}
async function fetchWithTimeout(url, init = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
function classifyResponse(response) {
if (response.ok) return { valid: true, error: null, statusCode: response.status };
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key", statusCode: response.status };
}
if (response.status >= 500) {
return {
valid: false,
error: `Provider unavailable (${response.status})`,
statusCode: response.status,
};
}
return { valid: true, error: null, statusCode: response.status };
}
async function testOpenAILikeProvider(input, config) {
const headers = {
Authorization: `Bearer ${input.apiKey}`,
"Content-Type": "application/json",
};
const modelsRes = await fetchWithTimeout(joinUrl(config.baseUrl, "/models"), {
method: "GET",
headers,
});
if (modelsRes.ok || modelsRes.status === 401 || modelsRes.status === 403) {
return classifyResponse(modelsRes);
}
const chatRes = await fetchWithTimeout(joinUrl(config.baseUrl, "/chat/completions"), {
method: "POST",
headers,
body: JSON.stringify({
model: config.model,
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
return classifyResponse(chatRes);
}
async function testAnthropicProvider(input, config) {
const response = await fetchWithTimeout(joinUrl(config.baseUrl, "/messages"), {
method: "POST",
headers: {
"x-api-key": input.apiKey,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
return classifyResponse(response);
}
async function testGoogleProvider(input, config) {
const url = new URL(joinUrl(config.baseUrl, "/models"));
url.searchParams.set("key", input.apiKey);
const response = await fetchWithTimeout(url.toString(), {
method: "GET",
});
return classifyResponse(response);
}
export async function testProviderApiKey(input) {
if (!input.apiKey) {
return { valid: false, error: "Missing API key", statusCode: null };
}
const config = resolveProviderConfig(input);
if (!config) {
return { valid: false, error: "Provider test not supported", unsupported: true };
}
try {
if (config.format === "openai") {
return await testOpenAILikeProvider(input, config);
}
if (config.format === "anthropic") {
return await testAnthropicProvider(input, config);
}
if (config.format === "google") {
return await testGoogleProvider(input, config);
}
return { valid: false, error: "Provider test not supported", unsupported: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { valid: false, error: message || "Provider test failed", statusCode: null };
}
}

View File

@@ -0,0 +1,45 @@
import bcrypt from "bcryptjs";
const MANAGEMENT_PASSWORD_SALT_ROUNDS = 12;
export async function hashManagementPassword(password) {
return bcrypt.hash(password, MANAGEMENT_PASSWORD_SALT_ROUNDS);
}
export function ensureSettingsSchema(db) {
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();
}
export function updateSettings(db, updates) {
ensureSettingsSchema(db);
const insert = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
);
const tx = db.transaction(() => {
for (const [key, value] of Object.entries(updates)) {
insert.run(key, JSON.stringify(value));
}
});
tx();
}
export function getSettings(db) {
ensureSettingsSchema(db);
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all();
const settings = {};
for (const row of rows) {
try {
settings[row.key] = JSON.parse(row.value);
} catch {
settings[row.key] = row.value;
}
}
return settings;
}

37
bin/cli/sqlite.mjs Normal file
View File

@@ -0,0 +1,37 @@
import fs from "node:fs";
import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
import { ensureProviderSchema } from "./provider-store.mjs";
import { ensureSettingsSchema } from "./settings-store.mjs";
export async function openOmniRouteDb() {
const dataDir = resolveDataDir();
const dbPath = resolveStoragePath(dataDir);
fs.mkdirSync(dataDir, { recursive: true });
let Database;
try {
Database = (await import("better-sqlite3")).default;
} catch {
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
}
let db;
try {
db = new Database(dbPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
throw new Error(
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
);
}
throw error;
}
db.pragma("journal_mode = WAL");
ensureSettingsSchema(db);
ensureProviderSchema(db);
return { db, dataDir, dbPath };
}

View File

@@ -8,6 +8,10 @@
* omniroute --port 3000 Start on custom port
* omniroute --no-open Start without opening browser
* omniroute --mcp Start MCP server (stdio transport for IDEs)
* omniroute setup Interactive guided setup
* omniroute doctor Run local health checks
* omniroute providers available List supported providers
* omniroute providers list List configured providers
* omniroute reset-encrypted-columns Reset broken encrypted credentials
* omniroute --help Show help
* omniroute --version Show version
@@ -74,6 +78,19 @@ function loadEnvFile() {
loadEnvFile();
const args = process.argv.slice(2);
const command = args[0];
const CLI_COMMANDS = new Set(["doctor", "providers", "setup"]);
if (CLI_COMMANDS.has(command)) {
try {
const { runCliCommand } = await import(pathToFileURL(join(ROOT, "bin", "cli", "index.mjs")).href);
const exitCode = await runCliCommand(command, args.slice(1), { rootDir: ROOT });
process.exit(exitCode ?? 0);
} catch (err) {
console.error("\x1b[31m✖ CLI command failed:\x1b[0m", err.message || err);
process.exit(1);
}
}
if (args.includes("--help") || args.includes("-h")) {
console.log(`
@@ -81,6 +98,10 @@ if (args.includes("--help") || args.includes("-h")) {
\x1b[1mUsage:\x1b[0m
omniroute Start the server
omniroute setup Interactive guided setup
omniroute doctor Run local health checks
omniroute providers available List supported providers
omniroute providers list List configured providers
omniroute --port <port> Use custom API port (default: 20128)
omniroute --no-open Don't open browser automatically
omniroute --mcp Start MCP server (stdio transport for IDEs)
@@ -99,6 +120,25 @@ if (args.includes("--help") || args.includes("-h")) {
Loads .env from: ~/.omniroute/.env or ./.env
Memory limit: OMNIROUTE_MEMORY_MB (default: 512)
\x1b[1mSetup:\x1b[0m
omniroute setup --password <password>
omniroute setup --add-provider --provider openai --api-key <key>
omniroute setup --non-interactive
\x1b[1mDoctor:\x1b[0m
omniroute doctor
omniroute doctor --json
omniroute doctor --no-liveness
\x1b[1mProviders:\x1b[0m
omniroute providers available
omniroute providers available --search openai
omniroute providers available --category api-key
omniroute providers list
omniroute providers test <id|name>
omniroute providers test-all
omniroute providers validate
\x1b[1mAfter starting:\x1b[0m
Dashboard: http://localhost:<dashboard-port>
API: http://localhost:<api-port>/v1

View File

@@ -61,11 +61,42 @@ See the [Docker Guide](DOCKER_GUIDE.md) for complete Docker setup including Comp
| Command | Description |
| ----------------------- | ----------------------------------------------------------- |
| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) |
| `omniroute setup` | Guided CLI onboarding for password and first provider |
| `omniroute doctor` | Run local health checks without starting the server |
| `omniroute providers` | Discover, list, validate, and test providers from CLI |
| `omniroute --port 3000` | Set canonical/API port to 3000 |
| `omniroute --mcp` | Start MCP server (stdio transport) |
| `omniroute --no-open` | Don't auto-open browser |
| `omniroute --help` | Show help |
Headless setup can be scripted with flags or environment variables:
```bash
omniroute setup --non-interactive --password "$OMNIROUTE_PASSWORD"
omniroute setup --non-interactive --add-provider --provider openai --api-key "$OPENAI_API_KEY"
omniroute setup --non-interactive --add-provider --provider openai --api-key "$OPENAI_API_KEY" --test-provider
```
Run local diagnostics without opening the dashboard:
```bash
omniroute doctor
omniroute doctor --json
omniroute doctor --no-liveness
```
Manage providers from SSH or scripts without opening the dashboard:
```bash
omniroute providers available
omniroute providers available --search openai
omniroute providers available --category api-key
omniroute providers list
omniroute providers test <id-or-name>
omniroute providers test-all
omniroute providers validate
```
---
## CLI Tool Configuration

View File

@@ -8,15 +8,45 @@ export type KieCallbackBody = {
callbackUrl?: unknown;
};
const FALLBACK_KIE_CALLBACK_URL = "https://omniroute.local/api/kie/callback";
export function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function callbackUrlFromBaseUrl(baseUrl: string | undefined): string | null {
if (!baseUrl || baseUrl.trim().length === 0) return null;
try {
const url = new URL(baseUrl);
url.pathname = "/api/kie/callback";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return null;
}
}
function getConfiguredKieCallbackUrl(): string {
const explicit =
process.env.KIE_CALLBACK_URL?.trim() || process.env.OMNIROUTE_KIE_CALLBACK_URL?.trim();
if (explicit) return explicit;
return (
callbackUrlFromBaseUrl(process.env.OMNIROUTE_PUBLIC_URL) ||
callbackUrlFromBaseUrl(process.env.NEXT_PUBLIC_APP_URL) ||
callbackUrlFromBaseUrl(process.env.APP_URL) ||
callbackUrlFromBaseUrl(process.env.PUBLIC_URL) ||
FALLBACK_KIE_CALLBACK_URL
);
}
export function getKieCallbackUrl(body: KieCallbackBody = {}): string {
const callbackUrl = body.callBackUrl ?? body.callback_url ?? body.callbackUrl;
return typeof callbackUrl === "string" && callbackUrl.trim().length > 0
? callbackUrl
: "https://omniroute.local/api/kie/callback";
: getConfiguredKieCallbackUrl();
}
export function parseKieResultJson(recordData: unknown): JsonObject {

View File

@@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
test("CLI parser treats short flags as flags", async () => {
const { parseArgs, hasFlag } = await import("../../bin/cli/args.mjs");
const { flags, positionals } = parseArgs(["doctor", "-h"]);
assert.deepEqual(positionals, ["doctor"]);
assert.equal(hasFlag(flags, "h"), true);
});
test("CLI parser supports bundled short flags", async () => {
const { parseArgs, hasFlag } = await import("../../bin/cli/args.mjs");
const { flags, positionals } = parseArgs(["providers", "-hv"]);
assert.deepEqual(positionals, ["providers"]);
assert.equal(hasFlag(flags, "h"), true);
assert.equal(hasFlag(flags, "v"), true);
});

View File

@@ -0,0 +1,111 @@
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 ROOT_DIR = path.resolve(".");
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_PORT = process.env.PORT;
const ORIGINAL_API_PORT = process.env.API_PORT;
const ORIGINAL_DASHBOARD_PORT = process.env.DASHBOARD_PORT;
const ORIGINAL_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY;
interface DoctorCheck {
name: string;
status: string;
}
interface DoctorResult {
checks: DoctorCheck[];
}
function createTempDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-doctor-"));
}
async function withDoctorEnv(fn: (dataDir: string) => Promise<void>) {
const dataDir = createTempDataDir();
process.env.DATA_DIR = dataDir;
delete process.env.PORT;
delete process.env.API_PORT;
delete process.env.DASHBOARD_PORT;
delete process.env.STORAGE_ENCRYPTION_KEY;
try {
await fn(dataDir);
} finally {
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;
if (ORIGINAL_PORT === undefined) delete process.env.PORT;
else process.env.PORT = ORIGINAL_PORT;
if (ORIGINAL_API_PORT === undefined) delete process.env.API_PORT;
else process.env.API_PORT = ORIGINAL_API_PORT;
if (ORIGINAL_DASHBOARD_PORT === undefined) delete process.env.DASHBOARD_PORT;
else process.env.DASHBOARD_PORT = ORIGINAL_DASHBOARD_PORT;
if (ORIGINAL_STORAGE_ENCRYPTION_KEY === undefined) delete process.env.STORAGE_ENCRYPTION_KEY;
else process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_ENCRYPTION_KEY;
}
}
function getCheck(result: DoctorResult, name: string) {
return result.checks.find((check) => check.name === name);
}
test("doctor reports warnings but no failures when database is not initialized", async () => {
await withDoctorEnv(async () => {
const { collectDoctorChecks } = await import("../../bin/cli/commands/doctor.mjs");
const result = await collectDoctorChecks({ rootDir: ROOT_DIR }, { skipLiveness: true });
assert.equal(result.summary.fail, 0);
assert.equal(getCheck(result, "Database")?.status, "warn");
});
});
test("doctor fails invalid configured ports", async () => {
await withDoctorEnv(async () => {
process.env.PORT = "99999";
const { collectDoctorChecks } = await import("../../bin/cli/commands/doctor.mjs");
const result = await collectDoctorChecks({ rootDir: ROOT_DIR }, { skipLiveness: true });
assert.equal(getCheck(result, "Config")?.status, "fail");
assert.ok(result.summary.fail >= 1);
});
});
test("doctor fails when encrypted credentials exist without storage key", async () => {
await withDoctorEnv(async (dataDir) => {
const dbPath = path.join(dataDir, "storage.sqlite");
const db = new Database(dbPath);
db.prepare(
`CREATE TABLE provider_connections (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
api_key TEXT,
access_token TEXT,
refresh_token TEXT,
id_token TEXT
)`
).run();
db.prepare("INSERT INTO provider_connections (id, provider, api_key) VALUES (?, ?, ?)").run(
"conn-1",
"openai",
"enc:v1:00112233445566778899aabbccddeeff:00:00112233445566778899aabbccddeeff"
);
db.close();
const { collectDoctorChecks } = await import("../../bin/cli/commands/doctor.mjs");
const result = await collectDoctorChecks({ rootDir: ROOT_DIR }, { skipLiveness: true });
assert.equal(getCheck(result, "Storage/encryption")?.status, "fail");
});
});

View File

@@ -0,0 +1,147 @@
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_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY;
const ORIGINAL_FETCH = globalThis.fetch;
function createTempDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-providers-"));
}
async function withProvidersEnv(fn: (dataDir: string) => Promise<void>) {
const dataDir = createTempDataDir();
process.env.DATA_DIR = dataDir;
delete process.env.STORAGE_ENCRYPTION_KEY;
globalThis.fetch = ORIGINAL_FETCH;
try {
await fn(dataDir);
} finally {
fs.rmSync(dataDir, { recursive: true, force: true });
globalThis.fetch = ORIGINAL_FETCH;
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
if (ORIGINAL_STORAGE_ENCRYPTION_KEY === undefined) delete process.env.STORAGE_ENCRYPTION_KEY;
else process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_ENCRYPTION_KEY;
}
}
async function createProvider(dataDir: string) {
const { ensureProviderSchema, upsertApiKeyProviderConnection } =
await import("../../bin/cli/provider-store.mjs");
const db = new Database(path.join(dataDir, "storage.sqlite"));
ensureProviderSchema(db);
const connection = upsertApiKeyProviderConnection(db, {
provider: "openai",
name: "OpenAI CLI",
apiKey: "sk-test",
});
db.close();
return connection;
}
test("providers list succeeds with configured providers", async () => {
await withProvidersEnv(async (dataDir) => {
await createProvider(dataDir);
const { runProvidersCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersCommand(["list", "--json"]);
assert.equal(exitCode, 0);
});
});
test("providers available lists supported provider catalog", async () => {
await withProvidersEnv(async () => {
const { runProvidersCommand } = await import("../../bin/cli/commands/providers.mjs");
const logs: string[] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
const exitCode = await runProvidersCommand(["available", "--json", "--search", "openai"]);
assert.equal(exitCode, 0);
} finally {
console.log = originalLog;
}
const result = JSON.parse(logs.join("\n")) as {
providers: Array<{ id: string; category: string }>;
};
assert.ok(result.providers.some((provider) => provider.id === "openai"));
assert.ok(result.providers.some((provider) => provider.category === "api-key"));
});
});
test("providers test updates provider status from upstream result", async () => {
await withProvidersEnv(async (dataDir) => {
await createProvider(dataDir);
globalThis.fetch = (async () =>
new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { runProvidersCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersCommand(["test", "OpenAI CLI"]);
assert.equal(exitCode, 0);
const db = new Database(path.join(dataDir, "storage.sqlite"));
const row = db.prepare("SELECT test_status, last_error FROM provider_connections").get() as {
test_status: string;
last_error: string | null;
};
db.close();
assert.equal(row.test_status, "active");
assert.equal(row.last_error, null);
});
});
test("providers validate fails encrypted API keys without storage key", async () => {
await withProvidersEnv(async (dataDir) => {
const db = new Database(path.join(dataDir, "storage.sqlite"));
db.prepare(
`CREATE TABLE provider_connections (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
auth_type TEXT,
name TEXT,
api_key TEXT,
is_active INTEGER,
priority INTEGER,
created_at TEXT,
updated_at TEXT
)`
).run();
db.prepare(
`INSERT INTO provider_connections
(id, provider, auth_type, name, api_key, is_active, priority, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1, 1, ?, ?)`
).run(
"conn-1",
"openai",
"apikey",
"Encrypted OpenAI",
"enc:v1:00112233445566778899aabbccddeeff:00:00112233445566778899aabbccddeeff",
new Date().toISOString(),
new Date().toISOString()
);
db.close();
const { runProvidersCommand } = await import("../../bin/cli/commands/providers.mjs");
const exitCode = await runProvidersCommand(["validate", "--json"]);
assert.equal(exitCode, 1);
});
});

View File

@@ -0,0 +1,176 @@
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 bcrypt from "bcryptjs";
import Database from "better-sqlite3";
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_STORAGE_ENCRYPTION_KEY = process.env.STORAGE_ENCRYPTION_KEY;
const ORIGINAL_FETCH = globalThis.fetch;
function createTempDataDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-setup-"));
}
async function withTempEnv(fn: (dataDir: string) => Promise<void>) {
const dataDir = createTempDataDir();
process.env.DATA_DIR = dataDir;
delete process.env.STORAGE_ENCRYPTION_KEY;
try {
await fn(dataDir);
} finally {
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;
}
if (ORIGINAL_STORAGE_ENCRYPTION_KEY === undefined) {
delete process.env.STORAGE_ENCRYPTION_KEY;
} else {
process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_ENCRYPTION_KEY;
}
globalThis.fetch = ORIGINAL_FETCH;
}
}
test("setup command writes password, setup state, and provider in non-interactive mode", async () => {
await withTempEnv(async (dataDir) => {
const { runSetupCommand } = await import("../../bin/cli/commands/setup.mjs");
const exitCode = await runSetupCommand([
"--non-interactive",
"--password",
"super-secret",
"--add-provider",
"--provider",
"openai",
"--provider-name",
"OpenAI CLI",
"--api-key",
"sk-test",
"--default-model",
"gpt-4o-mini",
]);
assert.equal(exitCode, 0);
const db = new Database(path.join(dataDir, "storage.sqlite"));
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'")
.all() as Array<{ key: string; value: string }>;
const settings = Object.fromEntries(rows.map((row) => [row.key, JSON.parse(row.value)]));
assert.equal(settings.setupComplete, true);
assert.equal(settings.requireLogin, true);
assert.equal(await bcrypt.compare("super-secret", settings.password as string), true);
const provider = db.prepare("SELECT * FROM provider_connections").get() as {
provider: string;
auth_type: string;
name: string;
api_key: string;
default_model: string;
is_active: number;
};
db.close();
assert.equal(provider.provider, "openai");
assert.equal(provider.auth_type, "apikey");
assert.equal(provider.name, "OpenAI CLI");
assert.equal(provider.api_key, "sk-test");
assert.equal(provider.default_model, "gpt-4o-mini");
assert.equal(provider.is_active, 1);
});
});
test("setup command can mark onboarding complete without provider in non-interactive mode", async () => {
await withTempEnv(async (dataDir) => {
const { runSetupCommand } = await import("../../bin/cli/commands/setup.mjs");
const exitCode = await runSetupCommand(["--non-interactive", "--password", "super-secret"]);
assert.equal(exitCode, 0);
const db = new Database(path.join(dataDir, "storage.sqlite"));
const setupComplete = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'setupComplete'")
.get() as { value: string };
const providerCount = db
.prepare("SELECT COUNT(*) as count FROM provider_connections")
.get() as { count: number };
db.close();
assert.equal(JSON.parse(setupComplete.value), true);
assert.equal(providerCount.count, 0);
});
});
test("setup command disables login when no password is configured", async () => {
await withTempEnv(async (dataDir) => {
const { runSetupCommand } = await import("../../bin/cli/commands/setup.mjs");
const exitCode = await runSetupCommand([
"--non-interactive",
"--add-provider",
"--provider",
"openai",
"--api-key",
"sk-test",
]);
assert.equal(exitCode, 0);
const db = new Database(path.join(dataDir, "storage.sqlite"));
const requireLogin = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'requireLogin'")
.get() as { value: string };
db.close();
assert.equal(JSON.parse(requireLogin.value), false);
});
});
test("setup command can test provider and persist active status", async () => {
await withTempEnv(async (dataDir) => {
const calls: string[] = [];
globalThis.fetch = (async (url: URL | RequestInfo) => {
calls.push(String(url));
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { runSetupCommand } = await import("../../bin/cli/commands/setup.mjs");
const exitCode = await runSetupCommand([
"--non-interactive",
"--add-provider",
"--provider",
"openai",
"--api-key",
"sk-test",
"--test-provider",
]);
assert.equal(exitCode, 0);
assert.equal(calls.length, 1);
assert.equal(calls[0], "https://api.openai.com/v1/models");
const db = new Database(path.join(dataDir, "storage.sqlite"));
const provider = db.prepare("SELECT * FROM provider_connections").get() as {
test_status: string;
last_tested: string;
last_error: string | null;
};
db.close();
assert.equal(provider.test_status, "active");
assert.ok(provider.last_tested);
assert.equal(provider.last_error, null);
});
});