diff --git a/README.md b/README.md index 43145a1279..d1dca716b4 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/bin/cli/args.mjs b/bin/cli/args.mjs new file mode 100644 index 0000000000..cbd7b652bf --- /dev/null +++ b/bin/cli/args.mjs @@ -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; +} diff --git a/bin/cli/commands/doctor.mjs b/bin/cli/commands/doctor.mjs new file mode 100644 index 0000000000..b88f9faa0e --- /dev/null +++ b/bin/cli/commands/doctor.mjs @@ -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 for server liveness probing (default: 127.0.0.1) + --liveness-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; +} diff --git a/bin/cli/commands/providers.mjs b/bin/cli/commands/providers.mjs new file mode 100644 index 0000000000..8401a34f6d --- /dev/null +++ b/bin/cli/commands/providers.mjs @@ -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 + omniroute providers test-all + omniroute providers validate + +Options: + --json Print machine-readable JSON + --search, --q Filter available providers by id, name, alias, or 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 Filter by id, name, alias, or 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 + omniroute providers test --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 or --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; +} diff --git a/bin/cli/commands/setup.mjs b/bin/cli/commands/setup.mjs new file mode 100644 index 0000000000..be298ed5a5 --- /dev/null +++ b/bin/cli/commands/setup.mjs @@ -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 + omniroute setup --add-provider --provider openai --api-key + omniroute setup --non-interactive + +Options: + --password Set admin password + --add-provider Add an API-key provider connection + --provider Provider id, for example openai or anthropic + --provider-name Display name for the connection + --api-key Provider API key + --default-model Optional default model + --provider-base-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(); + } +} diff --git a/bin/cli/data-dir.mjs b/bin/cli/data-dir.mjs new file mode 100644 index 0000000000..ed9264dd90 --- /dev/null +++ b/bin/cli/data-dir.mjs @@ -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"); +} diff --git a/bin/cli/encryption.mjs b/bin/cli/encryption.mjs new file mode 100644 index 0000000000..3011941232 --- /dev/null +++ b/bin/cli/encryption.mjs @@ -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; +} diff --git a/bin/cli/index.mjs b/bin/cli/index.mjs new file mode 100644 index 0000000000..c9c8683016 --- /dev/null +++ b/bin/cli/index.mjs @@ -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}`); +} diff --git a/bin/cli/io.mjs b/bin/cli/io.mjs new file mode 100644 index 0000000000..ee200b4921 --- /dev/null +++ b/bin/cli/io.mjs @@ -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`); +} diff --git a/bin/cli/provider-catalog.mjs b/bin/cli/provider-catalog.mjs new file mode 100644 index 0000000000..873f933d18 --- /dev/null +++ b/bin/cli/provider-catalog.mjs @@ -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"; +} diff --git a/bin/cli/provider-store.mjs b/bin/cli/provider-store.mjs new file mode 100644 index 0000000000..219861d45e --- /dev/null +++ b/bin/cli/provider-store.mjs @@ -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, + }; +} diff --git a/bin/cli/provider-test.mjs b/bin/cli/provider-test.mjs new file mode 100644 index 0000000000..4ab68bcde0 --- /dev/null +++ b/bin/cli/provider-test.mjs @@ -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 }; + } +} diff --git a/bin/cli/settings-store.mjs b/bin/cli/settings-store.mjs new file mode 100644 index 0000000000..dc780d2046 --- /dev/null +++ b/bin/cli/settings-store.mjs @@ -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; +} diff --git a/bin/cli/sqlite.mjs b/bin/cli/sqlite.mjs new file mode 100644 index 0000000000..cb7eaed351 --- /dev/null +++ b/bin/cli/sqlite.mjs @@ -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 }; +} diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index a646e08961..8202818956 100644 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -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 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 + omniroute setup --add-provider --provider openai --api-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 + omniroute providers test-all + omniroute providers validate + \x1b[1mAfter starting:\x1b[0m Dashboard: http://localhost: API: http://localhost:/v1 diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index 26c332a5ab..b4f20d7213 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -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 +omniroute providers test-all +omniroute providers validate +``` + --- ## CLI Tool Configuration diff --git a/open-sse/utils/kieTask.ts b/open-sse/utils/kieTask.ts index 78f52fd59c..c3b7953d97 100644 --- a/open-sse/utils/kieTask.ts +++ b/open-sse/utils/kieTask.ts @@ -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 { diff --git a/tests/unit/cli-args.test.ts b/tests/unit/cli-args.test.ts new file mode 100644 index 0000000000..234a72b9b5 --- /dev/null +++ b/tests/unit/cli-args.test.ts @@ -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); +}); diff --git a/tests/unit/cli-doctor-command.test.ts b/tests/unit/cli-doctor-command.test.ts new file mode 100644 index 0000000000..c10aa53256 --- /dev/null +++ b/tests/unit/cli-doctor-command.test.ts @@ -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) { + 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"); + }); +}); diff --git a/tests/unit/cli-providers-command.test.ts b/tests/unit/cli-providers-command.test.ts new file mode 100644 index 0000000000..a39607d2c2 --- /dev/null +++ b/tests/unit/cli-providers-command.test.ts @@ -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) { + 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); + }); +}); diff --git a/tests/unit/cli-setup-command.test.ts b/tests/unit/cli-setup-command.test.ts new file mode 100644 index 0000000000..390bafad81 --- /dev/null +++ b/tests/unit/cli-setup-command.test.ts @@ -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) { + 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); + }); +});