mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
[cli omniroute] Add modular CLI setup and provider commands (#2046)
Integrated into release/v3.8.0
This commit is contained in:
@@ -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
47
bin/cli/args.mjs
Normal 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
518
bin/cli/commands/doctor.mjs
Normal 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;
|
||||
}
|
||||
428
bin/cli/commands/providers.mjs
Normal file
428
bin/cli/commands/providers.mjs
Normal 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
200
bin/cli/commands/setup.mjs
Normal 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
38
bin/cli/data-dir.mjs
Normal 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
62
bin/cli/encryption.mjs
Normal 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
19
bin/cli/index.mjs
Normal 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
36
bin/cli/io.mjs
Normal 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`);
|
||||
}
|
||||
175
bin/cli/provider-catalog.mjs
Normal file
175
bin/cli/provider-catalog.mjs
Normal 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
276
bin/cli/provider-store.mjs
Normal 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
181
bin/cli/provider-test.mjs
Normal 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 };
|
||||
}
|
||||
}
|
||||
45
bin/cli/settings-store.mjs
Normal file
45
bin/cli/settings-store.mjs
Normal 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
37
bin/cli/sqlite.mjs
Normal 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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
21
tests/unit/cli-args.test.ts
Normal file
21
tests/unit/cli-args.test.ts
Normal 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);
|
||||
});
|
||||
111
tests/unit/cli-doctor-command.test.ts
Normal file
111
tests/unit/cli-doctor-command.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
147
tests/unit/cli-providers-command.test.ts
Normal file
147
tests/unit/cli-providers-command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
176
tests/unit/cli-setup-command.test.ts
Normal file
176
tests/unit/cli-setup-command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user