From 8fc97a7f91e4a44152f5686fd24fd1c289dfdf6e Mon Sep 17 00:00:00 2001 From: oyi77 Date: Thu, 2 Apr 2026 13:27:51 +0700 Subject: [PATCH] feat(cliproxyapi): add DB schema, upstream proxy config & settings UI Part 1 of CLIProxyAPI integration (#902). - DB migration for version_manager + upstream_proxy_config tables - DB modules: versionManager.ts (tool lifecycle CRUD), upstreamProxy.ts (proxy config CRUD + SSRF guard) - localDb.ts re-exports for new modules - Provider registration: UPSTREAM_PROXY_PROVIDERS in providers.ts - Zod validation schemas for version-manager API routes - Settings UI: CliproxyapiSettingsTab (Advanced tab) - 47 unit tests for DB modules --- .../components/CliproxyapiSettingsTab.tsx | 193 +++++++++ .../(dashboard)/dashboard/settings/page.tsx | 8 +- .../016_version_manager_upstream_proxy.sql | 56 +++ src/lib/db/upstreamProxy.ts | 230 +++++++++++ src/lib/db/versionManager.ts | 300 ++++++++++++++ src/lib/localDb.ts | 22 ++ src/shared/constants/providers.ts | 25 +- src/shared/validation/schemas.ts | 8 + tests/unit/db-upstreamProxy.test.mjs | 340 ++++++++++++++++ tests/unit/db-versionManager.test.mjs | 368 ++++++++++++++++++ 10 files changed, 1548 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx create mode 100644 src/lib/db/migrations/016_version_manager_upstream_proxy.sql create mode 100644 src/lib/db/upstreamProxy.ts create mode 100644 src/lib/db/versionManager.ts create mode 100644 tests/unit/db-upstreamProxy.test.mjs create mode 100644 tests/unit/db-versionManager.test.mjs diff --git a/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx new file mode 100644 index 0000000000..12094623ed --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Input, Toggle } from "@/shared/components"; + +export default function CliproxyapiSettingsTab() { + const [settings, setSettings] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: string; text: string } | null>(null); + const [toolState, setToolState] = useState(null); + + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((data) => { + setSettings(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + + fetch("/api/version-manager/status") + .then((r) => r.json()) + .then((data) => { + const entry = Array.isArray(data) ? data.find((t: any) => t.tool === "cliproxyapi") : null; + setToolState(entry); + }) + .catch(() => {}); + }, []); + + const updateSetting = async (key: string, value: any) => { + setSaving(true); + setMessage(null); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [key]: value }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, [key]: value })); + setMessage({ type: "success", text: "Setting saved" }); + } + } catch { + setMessage({ type: "error", text: "Failed to save setting" }); + } finally { + setSaving(false); + } + }; + + const cpaEnabled = settings.cliproxyapi_fallback_enabled === true; + const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317"; + const cpaCodes = settings.cliproxyapi_fallback_codes || "502,401,403,429,503"; + + const statusColor = + toolState?.status === "running" + ? "text-green-600 dark:text-green-400" + : toolState?.status === "error" + ? "text-red-600 dark:text-red-400" + : "text-text-muted"; + + const statusIcon = + toolState?.status === "running" + ? "check_circle" + : toolState?.status === "error" + ? "error" + : "help"; + + return ( +
+ {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + + +
+
+ swap_horiz +
+
+

CLIProxyAPI Fallback

+

+ When enabled, failed requests are retried through CLIProxyAPI (localhost:8317) +

+
+
+ +
+
+ + updateSetting("cliproxyapi_fallback_enabled", checked)} + /> +
+ + {cpaEnabled && ( + <> +
+ + updateSetting("cliproxyapi_url", e.target.value)} + placeholder="http://127.0.0.1:8317" + className="w-full" + /> +
+ +
+ + updateSetting("cliproxyapi_fallback_codes", e.target.value)} + placeholder="502,401,403,429,503" + className="w-full" + /> +
+ + )} +
+
+ + +

CLIProxyAPI Status

+ {loading ? ( +
+ + progress_activity + + Loading... +
+ ) : toolState ? ( +
+
+

Status

+
+ + {statusIcon} + +

+ {toolState.status?.replace("_", " ") || "Unknown"} +

+
+
+
+

Version

+

+ {toolState.installedVersion ? `v${toolState.installedVersion}` : "Not installed"} +

+
+
+

Health

+

+ {toolState.healthStatus === "healthy" + ? "Healthy" + : toolState.healthStatus === "unhealthy" + ? "Unhealthy" + : "Unknown"} +

+
+
+

Port

+

{toolState.port || 8317}

+
+
+ ) : ( +

CLIProxyAPI not detected

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 640fcea3ea..a9a25e4aca 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -21,6 +21,7 @@ import CacheStatsCard from "./components/CacheStatsCard"; import CacheSettingsTab from "./components/CacheSettingsTab"; import MemorySkillsTab from "./components/MemorySkillsTab"; import ResilienceTab from "./components/ResilienceTab"; +import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab"; const tabs = [ { id: "general", labelKey: "general", icon: "settings" }, @@ -113,7 +114,12 @@ export default function SettingsPage() { {activeTab === "resilience" && } - {activeTab === "advanced" && } + {activeTab === "advanced" && ( +
+ + +
+ )} {/* App Info */} diff --git a/src/lib/db/migrations/016_version_manager_upstream_proxy.sql b/src/lib/db/migrations/016_version_manager_upstream_proxy.sql new file mode 100644 index 0000000000..337e7d9d7a --- /dev/null +++ b/src/lib/db/migrations/016_version_manager_upstream_proxy.sql @@ -0,0 +1,56 @@ +-- Migration 016: Version Manager & Upstream Proxy tables +-- +-- Adds two tables for CLIProxyAPI integration: +-- version_manager — binary lifecycle management for CLI tools (CLIProxyAPI, etc.) +-- upstream_proxy_config — per-provider routing mode (native vs CLIProxyAPI vs fallback) + +-- -------------------------------------------------------------------------- +-- Table: version_manager +-- Tracks installed versions, process state, and update settings for +-- externally managed CLI tools (initially CLIProxyAPI). +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS version_manager ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool TEXT NOT NULL UNIQUE, -- 'cliproxyapi' | 'omniroute' + current_version TEXT, -- latest known release version + installed_version TEXT, -- currently installed version + pinned_version TEXT, -- user-pinned version (null = auto) + binary_path TEXT, -- absolute path to binary + status TEXT NOT NULL DEFAULT 'not_installed', -- not_installed | installed | running | stopped | error + pid INTEGER, -- process ID when running + port INTEGER DEFAULT 8317, -- managed tool's port + api_key TEXT, -- API key for CLIProxyAPI (from its config) + management_key TEXT, -- CLIProxyAPI management secret key + auto_update INTEGER NOT NULL DEFAULT 1, -- 1 = auto-update enabled + auto_start INTEGER NOT NULL DEFAULT 0, -- 1 = start with OmniRoute + last_health_check TEXT, -- ISO timestamp + last_update_check TEXT, -- ISO timestamp + health_status TEXT DEFAULT 'unknown', -- unknown | healthy | unhealthy | timeout + config_overrides TEXT, -- JSON blob for CLIProxyAPI config overrides + error_message TEXT, -- last error message + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- -------------------------------------------------------------------------- +-- Table: upstream_proxy_config +-- Per-provider routing configuration for CLIProxyAPI passthrough mode. +-- Determines whether each provider uses OmniRoute's native executor, +-- delegates to CLIProxyAPI, or uses fallback (native first, then CLIProxyAPI). +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS upstream_proxy_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL UNIQUE, -- e.g., 'antigravity', 'claude', 'codex' + mode TEXT NOT NULL DEFAULT 'native', -- 'native' | 'cliproxyapi' | 'fallback' + cliproxyapi_model_mapping TEXT, -- JSON: { "ag/gemini-3-pro": "gemini-3-pro-high", ... } + native_priority INTEGER NOT NULL DEFAULT 1, -- order in fallback chain (1 = first) + cliproxyapi_priority INTEGER NOT NULL DEFAULT 2, -- order in fallback chain + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_upc_provider ON upstream_proxy_config(provider_id); +CREATE INDEX IF NOT EXISTS idx_upc_mode ON upstream_proxy_config(mode); +CREATE INDEX IF NOT EXISTS idx_vm_tool ON version_manager(tool); +CREATE INDEX IF NOT EXISTS idx_vm_status ON version_manager(status); diff --git a/src/lib/db/upstreamProxy.ts b/src/lib/db/upstreamProxy.ts new file mode 100644 index 0000000000..fdad87e73c --- /dev/null +++ b/src/lib/db/upstreamProxy.ts @@ -0,0 +1,230 @@ +/** Upstream proxy config persistence for upstream_proxy_config table. */ +import { getDbInstance } from "./core"; + +interface UpstreamProxyConfig { + id: number; + providerId: string; + mode: string; + cliproxyapiModelMapping: Record | null; + nativePriority: number; + cliproxyapiPriority: number; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +interface UpstreamProxyRow { + id: unknown; + provider_id: unknown; + mode: unknown; + cliproxyapi_model_mapping: unknown; + native_priority: unknown; + cliproxyapi_priority: unknown; + enabled: unknown; + created_at: unknown; + updated_at: unknown; +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +const BLOCKED_HOSTNAMES = ["metadata.google.internal", "169.254.169.254", "metadata.aws.internal"]; + +function isPrivateHost(hostname: string): boolean { + if (BLOCKED_HOSTNAMES.includes(hostname)) return true; + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false; + if ( + /^10\./.test(hostname) || + /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || + /^192\.168\./.test(hostname) + ) + return true; + if ( + /^0\./.test(hostname) || + /^127\./.test(hostname) || + /^224\./.test(hostname) || + /^169\.254\./.test(hostname) + ) + return true; + return false; +} + +export function validateProxyUrl( + url: string +): { valid: true; url: string } | { valid: false; error: string } { + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) { + return { + valid: false, + error: `Unsupported protocol "${parsed.protocol}" — use http or https`, + }; + } + if (isPrivateHost(parsed.hostname)) { + return { + valid: false, + error: `Proxy URL cannot point to private/internal address "${parsed.hostname}"`, + }; + } + return { valid: true, url }; + } catch { + return { valid: false, error: `Invalid URL: "${url}"` }; + } +} + +function rowToConfig(record: Record): UpstreamProxyConfig { + return { + id: record.id as number, + providerId: record.provider_id as string, + mode: record.mode as string, + cliproxyapiModelMapping: + record.cliproxyapi_model_mapping && typeof record.cliproxyapi_model_mapping === "string" + ? JSON.parse(record.cliproxyapi_model_mapping) + : null, + nativePriority: record.native_priority as number, + cliproxyapiPriority: record.cliproxyapi_priority as number, + enabled: record.enabled === 1 || record.enabled === true, + createdAt: record.created_at as string, + updatedAt: record.updated_at as string, + }; +} + +export async function getUpstreamProxyConfigs() { + const db = getDbInstance(); + const rows = db + .prepare("SELECT * FROM upstream_proxy_config ORDER BY provider_id") + .all() as UpstreamProxyRow[]; + return rows.map((row) => rowToConfig(toRecord(row))); +} + +export async function getUpstreamProxyConfig(providerId: string) { + const db = getDbInstance(); + const row = db + .prepare("SELECT * FROM upstream_proxy_config WHERE provider_id = ?") + .get(providerId) as UpstreamProxyRow | undefined; + if (!row) return null; + return rowToConfig(toRecord(row)); +} + +export async function upsertUpstreamProxyConfig(data: { + providerId: string; + mode?: string; + cliproxyapiModelMapping?: Record | null; + nativePriority?: number; + cliproxyapiPriority?: number; + enabled?: boolean; +}) { + const db = getDbInstance(); + const mode = data.mode ?? "native"; + const cliproxyapiModelMapping = + data.cliproxyapiModelMapping !== undefined + ? JSON.stringify(data.cliproxyapiModelMapping) + : null; + const nativePriority = data.nativePriority ?? 1; + const cliproxyapiPriority = data.cliproxyapiPriority ?? 2; + const enabled = data.enabled !== false ? 1 : 0; + + db.prepare( + `INSERT INTO upstream_proxy_config + (provider_id, mode, cliproxyapi_model_mapping, native_priority, cliproxyapi_priority, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(provider_id) DO UPDATE SET + mode = excluded.mode, + cliproxyapi_model_mapping = excluded.cliproxyapi_model_mapping, + native_priority = excluded.native_priority, + cliproxyapi_priority = excluded.cliproxyapi_priority, + enabled = excluded.enabled, + updated_at = datetime('now')` + ).run( + data.providerId, + mode, + cliproxyapiModelMapping, + nativePriority, + cliproxyapiPriority, + enabled + ); + + return getUpstreamProxyConfig(data.providerId); +} + +export async function updateUpstreamProxyConfig( + providerId: string, + updates: Record +) { + const db = getDbInstance(); + const current = await getUpstreamProxyConfig(providerId); + if (!current) { + throw new Error(`Provider ${providerId} not found`); + } + + const sets: string[] = ["updated_at = datetime('now')"]; + const params: unknown[] = []; + + if (updates.mode !== undefined) { + sets.push("mode = ?"); + params.push(updates.mode); + } + if (updates.cliproxyapiModelMapping !== undefined) { + sets.push("cliproxyapi_model_mapping = ?"); + params.push( + updates.cliproxyapiModelMapping === null + ? null + : JSON.stringify(updates.cliproxyapiModelMapping) + ); + } + if (updates.nativePriority !== undefined) { + sets.push("native_priority = ?"); + params.push(updates.nativePriority); + } + if (updates.cliproxyapiPriority !== undefined) { + sets.push("cliproxyapi_priority = ?"); + params.push(updates.cliproxyapiPriority); + } + if (updates.enabled !== undefined) { + sets.push("enabled = ?"); + params.push(updates.enabled === true ? 1 : 0); + } + + params.push(providerId); + db.prepare(`UPDATE upstream_proxy_config SET ${sets.join(", ")} WHERE provider_id = ?`).run( + ...params + ); + + return getUpstreamProxyConfig(providerId); +} + +export async function deleteUpstreamProxyConfig(providerId: string) { + const db = getDbInstance(); + const result = db + .prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?") + .run(providerId); + return result.changes > 0; +} + +export async function getProvidersByMode(mode: string) { + const db = getDbInstance(); + const rows = db + .prepare( + "SELECT * FROM upstream_proxy_config WHERE mode = ? AND enabled = 1 ORDER BY provider_id" + ) + .all(mode) as UpstreamProxyRow[]; + return rows.map((row) => rowToConfig(toRecord(row))); +} + +export async function getFallbackChainForProvider(providerId: string) { + const config = await getUpstreamProxyConfig(providerId); + if (!config) return []; + + const chain: { executor: "native" | "cliproxyapi"; priority: number }[] = []; + + if (config.enabled) { + chain.push({ executor: "native", priority: config.nativePriority }); + if (config.mode === "cliproxyapi" || config.mode === "fallback") { + chain.push({ executor: "cliproxyapi", priority: config.cliproxyapiPriority }); + } + } + + chain.sort((a, b) => a.priority - b.priority); + return chain; +} diff --git a/src/lib/db/versionManager.ts b/src/lib/db/versionManager.ts new file mode 100644 index 0000000000..d72339b80e --- /dev/null +++ b/src/lib/db/versionManager.ts @@ -0,0 +1,300 @@ +/** Version manager tool state persistence. */ + +import { getDbInstance } from "./core"; + +interface VersionManagerRow { + id?: unknown; + tool?: unknown; + current_version?: unknown; + installed_version?: unknown; + pinned_version?: unknown; + binary_path?: unknown; + status?: unknown; + pid?: unknown; + port?: unknown; + api_key?: unknown; + management_key?: unknown; + auto_update?: unknown; + auto_start?: unknown; + last_health_check?: unknown; + last_update_check?: unknown; + health_status?: unknown; + config_overrides?: unknown; + error_message?: unknown; + created_at?: unknown; + updated_at?: unknown; +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +function parseConfigOverrides(value: unknown): Record | null { + if (!value || typeof value !== "string" || value.trim() === "") return null; + try { + const parsed = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? parsed : null; + } catch { + return null; + } +} + +function stringifyConfigOverrides(value: Record | null): string | null { + if (value === null) return null; + try { + return JSON.stringify(value); + } catch { + return null; + } +} + +interface VersionManagerTool { + id: number; + tool: string; + currentVersion: string | null; + installedVersion: string | null; + pinnedVersion: string | null; + binaryPath: string | null; + status: string; + pid: number | null; + port: number; + apiKey: string | null; + managementKey: string | null; + autoUpdate: boolean; + autoStart: boolean; + lastHealthCheck: string | null; + lastUpdateCheck: string | null; + healthStatus: string; + configOverrides: Record | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +} + +function rowToVersionManager(row: VersionManagerRow): VersionManagerTool { + const record = toRecord(row); + return { + id: typeof record.id === "number" ? record.id : 0, + tool: typeof record.tool === "string" ? record.tool : "", + currentVersion: + record.current_version === null + ? null + : typeof record.current_version === "string" + ? record.current_version + : null, + installedVersion: + record.installed_version === null + ? null + : typeof record.installed_version === "string" + ? record.installed_version + : null, + pinnedVersion: + record.pinned_version === null + ? null + : typeof record.pinned_version === "string" + ? record.pinned_version + : null, + binaryPath: + record.binary_path === null + ? null + : typeof record.binary_path === "string" + ? record.binary_path + : null, + status: typeof record.status === "string" ? record.status : "not_installed", + pid: record.pid === null ? null : typeof record.pid === "number" ? record.pid : null, + port: typeof record.port === "number" ? record.port : 8317, + apiKey: + record.api_key === null ? null : typeof record.api_key === "string" ? record.api_key : null, + managementKey: + record.management_key === null + ? null + : typeof record.management_key === "string" + ? record.management_key + : null, + autoUpdate: + record.auto_update === 1 || record.auto_update === true || record.auto_update === "1", + autoStart: record.auto_start === 1 || record.auto_start === true || record.auto_start === "1", + lastHealthCheck: + record.last_health_check === null + ? null + : typeof record.last_health_check === "string" + ? record.last_health_check + : null, + lastUpdateCheck: + record.last_update_check === null + ? null + : typeof record.last_update_check === "string" + ? record.last_update_check + : null, + healthStatus: typeof record.health_status === "string" ? record.health_status : "unknown", + configOverrides: parseConfigOverrides(record.config_overrides), + errorMessage: + record.error_message === null + ? null + : typeof record.error_message === "string" + ? record.error_message + : null, + createdAt: typeof record.created_at === "string" ? record.created_at : "", + updatedAt: typeof record.updated_at === "string" ? record.updated_at : "", + }; +} + +export async function getVersionManagerStatus(): Promise { + const db = getDbInstance(); + const rows = db.prepare("SELECT * FROM version_manager").all() as VersionManagerRow[]; + return rows.map(rowToVersionManager); +} + +export async function getVersionManagerTool(tool: string): Promise { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM version_manager WHERE tool = ?").get(tool) as + | VersionManagerRow + | undefined; + if (!row) return null; + return rowToVersionManager(row); +} + +export async function upsertVersionManagerTool(data: { + tool: string; + currentVersion?: string | null; + installedVersion?: string | null; + pinnedVersion?: string | null; + binaryPath?: string | null; + status?: string; + pid?: number | null; + port?: number; + apiKey?: string | null; + managementKey?: string | null; + autoUpdate?: boolean; + autoStart?: boolean; + healthStatus?: string; + configOverrides?: Record | null; + errorMessage?: string | null; +}): Promise { + const db = getDbInstance(); + db.prepare( + ` + INSERT INTO version_manager ( + tool, current_version, installed_version, pinned_version, binary_path, + status, pid, port, api_key, management_key, auto_update, auto_start, + health_status, config_overrides, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(tool) DO UPDATE SET + current_version = excluded.current_version, + installed_version = excluded.installed_version, + pinned_version = excluded.pinned_version, + binary_path = excluded.binary_path, + status = excluded.status, + pid = excluded.pid, + port = excluded.port, + api_key = excluded.api_key, + management_key = excluded.management_key, + auto_update = excluded.auto_update, + auto_start = excluded.auto_start, + health_status = excluded.health_status, + config_overrides = excluded.config_overrides, + error_message = excluded.error_message, + updated_at = datetime('now') + ` + ).run( + data.tool, + data.currentVersion ?? null, + data.installedVersion ?? null, + data.pinnedVersion ?? null, + data.binaryPath ?? null, + data.status ?? "not_installed", + data.pid ?? null, + data.port ?? 8317, + data.apiKey ?? null, + data.managementKey ?? null, + data.autoUpdate !== undefined ? (data.autoUpdate ? 1 : 0) : 1, + data.autoStart !== undefined ? (data.autoStart ? 1 : 0) : 0, + data.healthStatus ?? "unknown", + stringifyConfigOverrides(data.configOverrides ?? null), + data.errorMessage ?? null + ); + const result = await getVersionManagerTool(data.tool); + if (!result) throw new Error("Failed to retrieve inserted version manager tool"); + return result; +} + +export async function updateVersionManagerTool( + tool: string, + updates: Record +): Promise { + const db = getDbInstance(); + const existing = await getVersionManagerTool(tool); + if (!existing) return null; + + const sets: string[] = ["updated_at = datetime('now')"]; + const params: Record = { tool }; + + for (const [key, value] of Object.entries(updates)) { + const dbKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + + if (key === "configOverrides") { + sets.push("config_overrides = @configOverrides"); + params.configOverrides = stringifyConfigOverrides(value as Record | null); + } else if (key === "autoUpdate" || key === "autoStart") { + sets.push(`${dbKey} = @${key}`); + params[key] = value === true ? 1 : 0; + } else if (value === null) { + sets.push(`${dbKey} = null`); + } else { + sets.push(`${dbKey} = @${key}`); + params[key] = value; + } + } + + db.prepare(`UPDATE version_manager SET ${sets.join(", ")} WHERE tool = @tool`).run(params); + return getVersionManagerTool(tool); +} + +export async function deleteVersionManagerTool(tool: string): Promise { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM version_manager WHERE tool = ?").run(tool); + return result.changes > 0; +} + +export async function updateToolHealth(tool: string, healthStatus: string): Promise { + const db = getDbInstance(); + const result = db + .prepare( + "UPDATE version_manager SET health_status = ?, last_health_check = datetime('now') WHERE tool = ?" + ) + .run(healthStatus, tool); + return result.changes > 0; +} + +export async function updateToolVersion( + tool: string, + field: "current_version" | "installed_version", + version: string +): Promise { + const db = getDbInstance(); + const result = db + .prepare(`UPDATE version_manager SET ${field} = ?, updated_at = datetime('now') WHERE tool = ?`) + .run(version, tool); + return result.changes > 0; +} + +export async function setToolStatus( + tool: string, + status: string, + pid?: number, + errorMessage?: string +): Promise { + const db = getDbInstance(); + const result = db + .prepare( + pid !== undefined + ? "UPDATE version_manager SET status = ?, pid = ?, error_message = ?, updated_at = datetime('now') WHERE tool = ?" + : "UPDATE version_manager SET status = ?, error_message = ?, updated_at = datetime('now') WHERE tool = ?" + ) + .run( + ...(pid !== undefined + ? [status, pid, errorMessage ?? null, tool] + : [status, errorMessage ?? null, tool]) + ); + return result.changes > 0; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 0294b0620c..31301a67e7 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -210,6 +210,28 @@ export { export type { QuotaSnapshotRow, ProviderUtilizationPoint } from "@/shared/types/utilization"; +export { + getVersionManagerStatus, + getVersionManagerTool, + upsertVersionManagerTool, + updateVersionManagerTool, + deleteVersionManagerTool, + updateToolHealth, + updateToolVersion, + setToolStatus, +} from "./db/versionManager"; + +export { + getUpstreamProxyConfigs, + getUpstreamProxyConfig, + upsertUpstreamProxyConfig, + updateUpstreamProxyConfig, + deleteUpstreamProxyConfig, + getProvidersByMode, + getFallbackChainForProvider, + validateProxyUrl, +} from "./db/upstreamProxy"; + export { getProviderLimitsCache, getAllProviderLimitsCache, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 934e675a3a..3f20e5ca52 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -611,12 +611,35 @@ export function isAnthropicCompatibleProvider(providerId) { return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); } +export const UPSTREAM_PROXY_PROVIDERS = { + cliproxyapi: { + id: "cliproxyapi", + alias: "cpa", + name: "CLIProxyAPI", + icon: "proxy", + color: "#6366F1", + textIcon: "CPA", + website: "https://github.com/router-for-me/CLIProxyAPI", + defaultPort: 8317, + healthEndpoint: "/v1/models", + managementPrefix: "/v0/management", + configDir: "~/.cli-proxy-api", + binaryName: "cli-proxy-api", + githubRepo: "router-for-me/CLIProxyAPI", + }, +}; + export function isClaudeCodeCompatibleProvider(providerId) { return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); } // All providers (combined) -export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; +export const AI_PROVIDERS = { + ...FREE_PROVIDERS, + ...OAUTH_PROVIDERS, + ...APIKEY_PROVIDERS, + ...UPSTREAM_PROXY_PROVIDERS, +}; // Auth methods export const AUTH_METHODS = { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index b49901a076..330f7c165c 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1320,3 +1320,11 @@ export const updateAutoDisableAccountsSchema = z threshold: z.number().int().min(1).max(10).optional(), }) .strict(); + +export const versionManagerToolSchema = z.object({ + tool: z.string().trim().min(1), +}); + +export const versionManagerInstallSchema = versionManagerToolSchema.extend({ + version: z.string().trim().optional(), +}); diff --git a/tests/unit/db-upstreamProxy.test.mjs b/tests/unit/db-upstreamProxy.test.mjs new file mode 100644 index 0000000000..44ce32ff68 --- /dev/null +++ b/tests/unit/db-upstreamProxy.test.mjs @@ -0,0 +1,340 @@ +import { describe, it, beforeEach, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import Database from "better-sqlite3"; + +const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-dbupc-test-")); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS upstream_proxy_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL UNIQUE, + mode TEXT NOT NULL DEFAULT 'native', + cliproxyapi_model_mapping TEXT, + native_priority INTEGER NOT NULL DEFAULT 1, + cliproxyapi_priority INTEGER NOT NULL DEFAULT 2, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`; + +let testDb; +let testDbPath; + +beforeEach(() => { + testDbPath = path.join(fileTmpDir, `test-${Date.now()}.db`); + testDb = new Database(testDbPath); + testDb.exec(SCHEMA); +}); + +afterEach(() => { + testDb.close(); + try { + fs.unlinkSync(testDbPath); + } catch {} +}); + +after(() => { + if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true }); +}); + +function upsert(db, data) { + db.prepare( + ` + INSERT OR REPLACE INTO upstream_proxy_config + (provider_id, mode, cliproxyapi_model_mapping, native_priority, cliproxyapi_priority, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ` + ).run( + data.providerId, + data.mode ?? "native", + data.cliproxyapiModelMapping !== undefined + ? JSON.stringify(data.cliproxyapiModelMapping) + : null, + data.nativePriority ?? 1, + data.cliproxyapiPriority ?? 2, + data.enabled !== false ? 1 : 0 + ); + return getConfig(db, data.providerId); +} + +function getConfig(db, providerId) { + const row = db + .prepare("SELECT * FROM upstream_proxy_config WHERE provider_id = ?") + .get(providerId); + if (!row) return null; + return { + id: row.id, + providerId: row.provider_id, + mode: row.mode, + cliproxyapiModelMapping: + row.cliproxyapi_model_mapping && typeof row.cliproxyapi_model_mapping === "string" + ? JSON.parse(row.cliproxyapi_model_mapping) + : null, + nativePriority: row.native_priority, + cliproxyapiPriority: row.cliproxyapi_priority, + enabled: row.enabled === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function getAllConfigs(db) { + return db + .prepare("SELECT * FROM upstream_proxy_config ORDER BY provider_id") + .all() + .map((r) => ({ + id: r.id, + providerId: r.provider_id, + mode: r.mode, + cliproxyapiModelMapping: + r.cliproxyapi_model_mapping && typeof r.cliproxyapi_model_mapping === "string" + ? JSON.parse(r.cliproxyapi_model_mapping) + : null, + nativePriority: r.native_priority, + cliproxyapiPriority: r.cliproxyapi_priority, + enabled: r.enabled === 1, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); +} + +function getProvidersByMode(db, mode) { + return db + .prepare( + "SELECT * FROM upstream_proxy_config WHERE mode = ? AND enabled = 1 ORDER BY provider_id" + ) + .all(mode) + .map((r) => ({ + id: r.id, + providerId: r.provider_id, + mode: r.mode, + cliproxyapiModelMapping: r.cliproxyapi_model_mapping + ? JSON.parse(r.cliproxyapi_model_mapping) + : null, + nativePriority: r.native_priority, + cliproxyapiPriority: r.cliproxyapi_priority, + enabled: r.enabled === 1, + })); +} + +function getFallbackChain(db, providerId) { + const config = getConfig(db, providerId); + if (!config) return []; + const chain = []; + if (config.enabled) { + chain.push({ executor: "native", priority: config.nativePriority }); + if (config.mode === "cliproxyapi" || config.mode === "fallback") { + chain.push({ executor: "cliproxyapi", priority: config.cliproxyapiPriority }); + } + } + chain.sort((a, b) => a.priority - b.priority); + return chain; +} + +describe("db/upstreamProxy (logic)", () => { + describe("upsertUpstreamProxyConfig", () => { + it("should insert and read back", () => { + const config = upsert(testDb, { providerId: "claude", mode: "native" }); + assert.equal(config.providerId, "claude"); + assert.equal(config.mode, "native"); + assert.equal(config.enabled, true); + assert.equal(config.nativePriority, 1); + assert.equal(config.cliproxyapiPriority, 2); + assert.equal(config.cliproxyapiModelMapping, null); + assert.ok(config.id > 0); + }); + + it("should replace on conflict", () => { + upsert(testDb, { providerId: "t", mode: "native" }); + const replaced = upsert(testDb, { providerId: "t", mode: "fallback" }); + assert.equal(replaced.mode, "fallback"); + }); + + it("should store model mapping as JSON", () => { + const mapping = { "ag/gemini-3-pro": "gemini-3-pro-high" }; + const config = upsert(testDb, { providerId: "mapped", cliproxyapiModelMapping: mapping }); + assert.deepEqual(config.cliproxyapiModelMapping, mapping); + }); + + it("should store null model mapping", () => { + const config = upsert(testDb, { providerId: "nomap", cliproxyapiModelMapping: null }); + assert.equal(config.cliproxyapiModelMapping, null); + }); + + it("should handle enabled=false", () => { + assert.equal(upsert(testDb, { providerId: "dis", enabled: false }).enabled, false); + }); + + it("should set custom priorities", () => { + const config = upsert(testDb, { + providerId: "pri", + nativePriority: 5, + cliproxyapiPriority: 10, + }); + assert.equal(config.nativePriority, 5); + assert.equal(config.cliproxyapiPriority, 10); + }); + }); + + describe("getUpstreamProxyConfig", () => { + it("should return null for non-existent provider", () => { + assert.equal(getConfig(testDb, "ghost"), null); + }); + + it("should return config for provider", () => { + upsert(testDb, { providerId: "claude", mode: "fallback" }); + assert.equal(getConfig(testDb, "claude").mode, "fallback"); + }); + }); + + describe("getUpstreamProxyConfigs", () => { + it("should return all ordered by provider_id", () => { + upsert(testDb, { providerId: "zebra" }); + upsert(testDb, { providerId: "alpha" }); + const configs = getAllConfigs(testDb); + assert.equal(configs.length, 2); + assert.equal(configs[0].providerId, "alpha"); + assert.equal(configs[1].providerId, "zebra"); + }); + + it("should return empty array", () => { + assert.equal(getAllConfigs(testDb).length, 0); + }); + }); + + describe("updateUpstreamProxyConfig", () => { + it("should update individual fields", () => { + upsert(testDb, { providerId: "u", mode: "native" }); + testDb + .prepare( + "UPDATE upstream_proxy_config SET mode = ?, updated_at = datetime('now') WHERE provider_id = ?" + ) + .run("cliproxyapi", "u"); + assert.equal(getConfig(testDb, "u").mode, "cliproxyapi"); + }); + + it("should update model mapping", () => { + upsert(testDb, { providerId: "u2" }); + testDb + .prepare( + "UPDATE upstream_proxy_config SET cliproxyapi_model_mapping = ?, updated_at = datetime('now') WHERE provider_id = ?" + ) + .run(JSON.stringify({ k: "v" }), "u2"); + assert.deepEqual(getConfig(testDb, "u2").cliproxyapiModelMapping, { k: "v" }); + }); + + it("should update multiple fields", () => { + upsert(testDb, { providerId: "m" }); + testDb + .prepare( + "UPDATE upstream_proxy_config SET mode = ?, native_priority = ?, enabled = ?, updated_at = datetime('now') WHERE provider_id = ?" + ) + .run("fallback", 3, 0, "m"); + const config = getConfig(testDb, "m"); + assert.equal(config.mode, "fallback"); + assert.equal(config.nativePriority, 3); + assert.equal(config.enabled, false); + }); + + it("should set model mapping to null", () => { + upsert(testDb, { providerId: "n", cliproxyapiModelMapping: { a: 1 } }); + testDb + .prepare( + "UPDATE upstream_proxy_config SET cliproxyapi_model_mapping = NULL WHERE provider_id = ?" + ) + .run("n"); + assert.equal(getConfig(testDb, "n").cliproxyapiModelMapping, null); + }); + }); + + describe("deleteUpstreamProxyConfig", () => { + it("should delete existing config", () => { + upsert(testDb, { providerId: "del" }); + assert.equal( + testDb.prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?").run("del") + .changes, + 1 + ); + assert.equal(getConfig(testDb, "del"), null); + }); + + it("should return 0 for non-existent", () => { + assert.equal( + testDb.prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?").run("ghost") + .changes, + 0 + ); + }); + }); + + describe("getProvidersByMode", () => { + it("should filter by mode and enabled", () => { + upsert(testDb, { providerId: "p1", mode: "fallback", enabled: true }); + upsert(testDb, { providerId: "p2", mode: "fallback", enabled: true }); + upsert(testDb, { providerId: "p3", mode: "native", enabled: true }); + upsert(testDb, { providerId: "p4", mode: "fallback", enabled: false }); + const results = getProvidersByMode(testDb, "fallback"); + assert.equal(results.length, 2); + assert.ok(results.every((r) => r.enabled)); + }); + + it("should return empty for no matches", () => { + assert.equal(getProvidersByMode(testDb, "cliproxyapi").length, 0); + }); + }); + + describe("getFallbackChainForProvider", () => { + it("should return empty for non-existent provider", () => { + assert.deepEqual(getFallbackChain(testDb, "ghost"), []); + }); + + it("should return native-only for native mode", () => { + upsert(testDb, { providerId: "native-only", mode: "native" }); + const chain = getFallbackChain(testDb, "native-only"); + assert.equal(chain.length, 1); + assert.equal(chain[0].executor, "native"); + }); + + it("should return native+cliproxyapi for fallback mode", () => { + upsert(testDb, { + providerId: "fb", + mode: "fallback", + nativePriority: 1, + cliproxyapiPriority: 2, + }); + const chain = getFallbackChain(testDb, "fb"); + assert.equal(chain.length, 2); + assert.equal(chain[0].executor, "native"); + assert.equal(chain[1].executor, "cliproxyapi"); + }); + + it("should return both for cliproxyapi mode", () => { + upsert(testDb, { providerId: "cpa", mode: "cliproxyapi" }); + const chain = getFallbackChain(testDb, "cpa"); + assert.equal(chain.length, 2); + }); + + it("should sort by priority", () => { + upsert(testDb, { + providerId: "rev", + mode: "fallback", + nativePriority: 10, + cliproxyapiPriority: 1, + }); + const chain = getFallbackChain(testDb, "rev"); + assert.equal(chain[0].executor, "cliproxyapi"); + assert.equal(chain[0].priority, 1); + assert.equal(chain[1].executor, "native"); + assert.equal(chain[1].priority, 10); + }); + + it("should return empty chain when disabled", () => { + upsert(testDb, { providerId: "dis-fb", mode: "fallback", enabled: false }); + assert.deepEqual(getFallbackChain(testDb, "dis-fb"), []); + }); + }); +}); diff --git a/tests/unit/db-versionManager.test.mjs b/tests/unit/db-versionManager.test.mjs new file mode 100644 index 0000000000..aaefb08699 --- /dev/null +++ b/tests/unit/db-versionManager.test.mjs @@ -0,0 +1,368 @@ +import { describe, it, beforeEach, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import Database from "better-sqlite3"; + +const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-dbvm-test-")); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS version_manager ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool TEXT NOT NULL UNIQUE, + current_version TEXT, + installed_version TEXT, + pinned_version TEXT, + binary_path TEXT, + status TEXT NOT NULL DEFAULT 'not_installed', + pid INTEGER, + port INTEGER DEFAULT 8317, + api_key TEXT, + management_key TEXT, + auto_update INTEGER NOT NULL DEFAULT 1, + auto_start INTEGER NOT NULL DEFAULT 0, + last_health_check TEXT, + last_update_check TEXT, + health_status TEXT DEFAULT 'unknown', + config_overrides TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`; + +let testDb; +let testDbPath; + +beforeEach(() => { + testDbPath = path.join(fileTmpDir, `test-${Date.now()}.db`); + testDb = new Database(testDbPath); + testDb.exec(SCHEMA); +}); + +afterEach(() => { + testDb.close(); + try { + fs.unlinkSync(testDbPath); + } catch {} +}); + +after(() => { + if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true }); +}); + +function upsertTool(db, data) { + db.prepare( + ` + INSERT INTO version_manager ( + tool, current_version, installed_version, pinned_version, binary_path, + status, pid, port, api_key, management_key, auto_update, auto_start, + health_status, config_overrides, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(tool) DO UPDATE SET + current_version = excluded.current_version, + installed_version = excluded.installed_version, + pinned_version = excluded.pinned_version, + binary_path = excluded.binary_path, + status = excluded.status, + pid = excluded.pid, + port = excluded.port, + api_key = excluded.api_key, + management_key = excluded.management_key, + auto_update = excluded.auto_update, + auto_start = excluded.auto_start, + health_status = excluded.health_status, + config_overrides = excluded.config_overrides, + error_message = excluded.error_message, + updated_at = datetime('now') + ` + ).run( + data.tool, + data.currentVersion ?? null, + data.installedVersion ?? null, + data.pinnedVersion ?? null, + data.binaryPath ?? null, + data.status ?? "not_installed", + data.pid ?? null, + data.port ?? 8317, + data.apiKey ?? null, + data.managementKey ?? null, + data.autoUpdate !== undefined ? (data.autoUpdate ? 1 : 0) : 1, + data.autoStart !== undefined ? (data.autoStart ? 1 : 0) : 0, + data.healthStatus ?? "unknown", + data.configOverrides !== undefined + ? data.configOverrides + ? JSON.stringify(data.configOverrides) + : null + : null, + data.errorMessage ?? null + ); + return db.prepare("SELECT * FROM version_manager WHERE tool = ?").get(data.tool); +} + +function parseConfigOverrides(value) { + if (!value || typeof value !== "string" || value.trim() === "") return null; + try { + const parsed = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? parsed : null; + } catch { + return null; + } +} + +function toTool(row) { + if (!row) return null; + return { + id: row.id, + tool: row.tool, + currentVersion: row.current_version, + installedVersion: row.installed_version, + pinnedVersion: row.pinned_version, + binaryPath: row.binary_path, + status: row.status, + pid: row.pid, + port: row.port, + apiKey: row.api_key, + managementKey: row.management_key, + autoUpdate: row.auto_update === 1, + autoStart: row.auto_start === 1, + lastHealthCheck: row.last_health_check, + lastUpdateCheck: row.last_update_check, + healthStatus: row.health_status, + configOverrides: parseConfigOverrides(row.config_overrides), + errorMessage: row.error_message, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +describe("db/versionManager (logic)", () => { + describe("upsertVersionManagerTool", () => { + it("should insert a new tool and read it back", () => { + const tool = toTool( + upsertTool(testDb, { + tool: "cliproxyapi", + installedVersion: "6.9.7", + binaryPath: "/tmp/bin/cliproxyapi", + status: "installed", + }) + ); + assert.equal(tool.tool, "cliproxyapi"); + assert.equal(tool.installedVersion, "6.9.7"); + assert.equal(tool.binaryPath, "/tmp/bin/cliproxyapi"); + assert.equal(tool.status, "installed"); + assert.equal(tool.port, 8317); + assert.equal(tool.autoUpdate, true); + assert.equal(tool.autoStart, false); + assert.equal(tool.healthStatus, "unknown"); + assert.ok(tool.id > 0); + }); + + it("should update on conflict (upsert)", () => { + upsertTool(testDb, { tool: "test-tool", status: "installed" }); + const tool = toTool( + upsertTool(testDb, { tool: "test-tool", installedVersion: "7.0.0", status: "running" }) + ); + assert.equal(tool.installedVersion, "7.0.0"); + assert.equal(tool.status, "running"); + }); + + it("should store boolean fields as 0/1", () => { + const tool = toTool( + upsertTool(testDb, { tool: "bool-test", autoUpdate: false, autoStart: true }) + ); + assert.equal(tool.autoUpdate, false); + assert.equal(tool.autoStart, true); + }); + + it("should store config overrides as JSON", () => { + const tool = toTool(upsertTool(testDb, { tool: "cfg", configOverrides: { port: 9999 } })); + assert.deepEqual(tool.configOverrides, { port: 9999 }); + }); + + it("should handle null config overrides", () => { + const tool = toTool(upsertTool(testDb, { tool: "null-cfg" })); + assert.equal(tool.configOverrides, null); + }); + + it("should handle pid and error message", () => { + const tool = toTool( + upsertTool(testDb, { tool: "pid-test", pid: 12345, errorMessage: "err" }) + ); + assert.equal(tool.pid, 12345); + assert.equal(tool.errorMessage, "err"); + }); + }); + + describe("getVersionManagerTool", () => { + it("should return null for non-existent tool", () => { + assert.equal( + toTool(testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("ghost")), + null + ); + }); + + it("should return tool by name", () => { + upsertTool(testDb, { tool: "findme", installedVersion: "1.0.0" }); + const tool = toTool( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("findme") + ); + assert.equal(tool.installedVersion, "1.0.0"); + }); + }); + + describe("getVersionManagerStatus", () => { + it("should return all tools", () => { + upsertTool(testDb, { tool: "a" }); + upsertTool(testDb, { tool: "b" }); + assert.equal(testDb.prepare("SELECT * FROM version_manager").all().length, 2); + }); + + it("should return empty when no tools", () => { + assert.equal(testDb.prepare("SELECT * FROM version_manager").all().length, 0); + }); + }); + + describe("updateVersionManagerTool (partial)", () => { + it("should update pinnedVersion and autoUpdate", () => { + upsertTool(testDb, { tool: "upd", status: "installed" }); + testDb + .prepare( + "UPDATE version_manager SET pinned_version = ?, auto_update = ?, updated_at = datetime('now') WHERE tool = ?" + ) + .run("6.8.0", 0, "upd"); + const tool = toTool( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("upd") + ); + assert.equal(tool.pinnedVersion, "6.8.0"); + assert.equal(tool.autoUpdate, false); + assert.equal(tool.status, "installed"); + }); + + it("should set fields to null", () => { + upsertTool(testDb, { tool: "nulls", installedVersion: "1.0.0", pid: 42 }); + testDb + .prepare("UPDATE version_manager SET installed_version = NULL, pid = NULL WHERE tool = ?") + .run("nulls"); + const tool = toTool( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("nulls") + ); + assert.equal(tool.installedVersion, null); + assert.equal(tool.pid, null); + }); + }); + + describe("deleteVersionManagerTool", () => { + it("should delete existing tool", () => { + upsertTool(testDb, { tool: "del" }); + assert.equal( + testDb.prepare("DELETE FROM version_manager WHERE tool = ?").run("del").changes, + 1 + ); + assert.equal( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("del"), + undefined + ); + }); + + it("should return 0 changes for non-existent", () => { + assert.equal( + testDb.prepare("DELETE FROM version_manager WHERE tool = ?").run("ghost").changes, + 0 + ); + }); + }); + + describe("updateToolHealth", () => { + it("should update health_status and last_health_check", () => { + upsertTool(testDb, { tool: "h" }); + const r = testDb + .prepare( + "UPDATE version_manager SET health_status = ?, last_health_check = datetime('now') WHERE tool = ?" + ) + .run("healthy", "h"); + assert.equal(r.changes, 1); + const tool = toTool(testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("h")); + assert.equal(tool.healthStatus, "healthy"); + assert.ok(tool.lastHealthCheck); + }); + + it("should return 0 changes for non-existent", () => { + assert.equal( + testDb + .prepare("UPDATE version_manager SET health_status = ? WHERE tool = ?") + .run("healthy", "ghost").changes, + 0 + ); + }); + }); + + describe("updateToolVersion", () => { + it("should update current_version", () => { + upsertTool(testDb, { tool: "v1" }); + testDb + .prepare("UPDATE version_manager SET current_version = ? WHERE tool = ?") + .run("7.0.0", "v1"); + assert.equal( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("v1").current_version, + "7.0.0" + ); + }); + + it("should update installed_version", () => { + upsertTool(testDb, { tool: "v2" }); + testDb + .prepare("UPDATE version_manager SET installed_version = ? WHERE tool = ?") + .run("7.0.0", "v2"); + assert.equal( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("v2").installed_version, + "7.0.0" + ); + }); + }); + + describe("setToolStatus", () => { + it("should update status with pid", () => { + upsertTool(testDb, { tool: "s1" }); + testDb + .prepare("UPDATE version_manager SET status = ?, pid = ?, error_message = ? WHERE tool = ?") + .run("running", 9999, "ok", "s1"); + const row = testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("s1"); + assert.equal(row.status, "running"); + assert.equal(row.pid, 9999); + assert.equal(row.error_message, "ok"); + }); + + it("should update status without pid", () => { + upsertTool(testDb, { tool: "s2" }); + testDb + .prepare("UPDATE version_manager SET status = ?, error_message = ? WHERE tool = ?") + .run("error", "crashed", "s2"); + const row = testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("s2"); + assert.equal(row.status, "error"); + }); + + it("should return 0 for non-existent", () => { + assert.equal( + testDb + .prepare("UPDATE version_manager SET status = ? WHERE tool = ?") + .run("running", "ghost").changes, + 0 + ); + }); + }); + + describe("parseConfigOverrides", () => { + it("should parse valid JSON", () => { + assert.deepEqual(parseConfigOverrides('{"key":"val"}'), { key: "val" }); + }); + + it("should return null for invalid JSON", () => { + assert.equal(parseConfigOverrides("not-json"), null); + assert.equal(parseConfigOverrides(""), null); + assert.equal(parseConfigOverrides(null), null); + assert.equal(parseConfigOverrides("123"), null); + }); + }); +});