"use server"; import { NextResponse } from "next/server"; import fs from "fs/promises"; import pino from "pino"; import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { getCliRuntimeStatus, getCliPrimaryConfigPath } from "@/shared/services/cliRuntime"; import { getAllCliToolLastConfigured } from "@/lib/db/cliToolState"; import { checkToolConfigStatus } from "@/lib/cliTools/checkToolConfigStatus"; import { getCached, setCached } from "@/lib/cliTools/batchStatusCache"; import type { ToolBatchStatus, ToolBatchStatusMap } from "@/shared/types/cliBatchStatus"; const logger = pino({ name: "cli-tools-all-statuses-api" }); const TOOL_CHECK_TIMEOUT_MS = 5000; // 5s per tool max /** * Attempt to extract the endpoint from a config file for a given toolId. * Returns null if extraction is not possible or the file is not parseable. */ async function extractEndpointFromConfig( toolId: string, configPath: string ): Promise { try { const content = await fs.readFile(configPath, "utf-8"); // TOML-based tools (codex) — do a best-effort text search if (toolId === "codex") { const match = content.match(/base_url\s*=\s*["']([^"'\n]+)["']/i); return match ? match[1] : null; } const config = JSON.parse(content) as Record; switch (toolId) { case "claude": { const env = config.env as Record | undefined; return (env?.ANTHROPIC_BASE_URL as string | undefined) ?? null; } case "qwen": { const mp = config.modelProviders as Record[] | undefined; if (!Array.isArray(mp)) return null; for (const provider of mp) { const baseUrl = (provider as Record).apiBase as string | undefined; if (baseUrl) return baseUrl; } return null; } case "cline": return (config.openAiBaseUrl as string | undefined) ?? null; case "droid": case "openclaw": case "kilo": { // Generic search for common endpoint key patterns for (const key of ["baseUrl", "apiBase", "openaiBaseUrl", "baseURL", "endpoint"]) { const value = config[key]; if (typeof value === "string" && value.startsWith("http")) return value; } return null; } case "hermes": { // Hermes uses a text/TOML-like config; already handled via raw text above const match = content.match(/base_url\s*=\s*["']([^"'\n]+)["']/i); return match ? match[1] : null; } default: return null; } } catch { return null; } } /** * GET /api/cli-tools/all-statuses * * Returns detection + config status for ALL CLI tools in one batch round-trip. * Uses mtime-based in-memory cache so repeated calls don't re-execute runtime checks. * * Auth: requireCliToolsAuth (management-level) * Response 200: Record * Response 401: { error: "Unauthorized" } * Response 500: { error: sanitizeErrorMessage(err) } */ export async function GET(request: Request): Promise { const authError = await requireCliToolsAuth(request); if (authError) return authError; try { const toolIds = Object.keys(CLI_TOOLS); const statuses: ToolBatchStatusMap = {}; // Resolve mtime for each tool's primary config path const mtimesMap: Record = {}; await Promise.allSettled( toolIds.map(async (toolId) => { const configPath = getCliPrimaryConfigPath(toolId); if (!configPath) { mtimesMap[toolId] = 0; return; } try { const stat = await fs.stat(configPath); mtimesMap[toolId] = stat.mtimeMs; } catch { mtimesMap[toolId] = 0; } }) ); // For each tool: use cache hit, or run detection + config check in parallel await Promise.allSettled( toolIds.map(async (toolId) => { const mtimeMs = mtimesMap[toolId] ?? 0; const cached = getCached(toolId, mtimeMs); if (cached) { statuses[toolId] = cached; return; } try { const runtimePromise = Promise.race>>([ getCliRuntimeStatus(toolId), new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), TOOL_CHECK_TIMEOUT_MS) ), ]); const configStatusPromise = checkToolConfigStatus(toolId); const [runtimeResult, configStatusResult] = await Promise.allSettled([ runtimePromise, configStatusPromise, ]); const runtime = runtimeResult.status === "fulfilled" ? runtimeResult.value : { installed: false, runnable: false, reason: "Timeout" }; const configStatus = configStatusResult.status === "fulfilled" ? configStatusResult.value : "unknown"; // Determine effective config status const effectiveConfigStatus = !runtime.installed || !runtime.runnable ? "not_installed" : configStatus; // Try to extract endpoint from config file const configPath = getCliPrimaryConfigPath(toolId); const endpoint = configPath ? await extractEndpointFromConfig(toolId, configPath) : null; const result: ToolBatchStatus = { detection: { installed: runtime.installed, runnable: runtime.runnable, command: runtime.command ?? undefined, commandPath: (runtime as Record).commandPath as string | undefined, reason: runtime.reason ?? undefined, }, config: { status: effectiveConfigStatus, endpoint: endpoint ?? null, }, }; setCached(toolId, mtimeMs, result); statuses[toolId] = result; } catch (toolErr) { const errMsg = toolErr instanceof Error && toolErr.message === "Timeout" ? "Timeout" : "Check failed"; logger.warn({ toolId, err: toolErr }, "Failed to check CLI tool status"); const result: ToolBatchStatus = { detection: { installed: false, runnable: false, reason: errMsg }, config: { status: "unknown" }, error: errMsg, }; statuses[toolId] = result; } }) ); // Merge last-configured timestamps from SQLite (non-critical) try { const lastConfigured = getAllCliToolLastConfigured(); for (const [toolId, timestamp] of Object.entries(lastConfigured)) { if (statuses[toolId]) { statuses[toolId].config.lastConfiguredAt = timestamp; } } } catch (dbErr) { logger.warn({ err: dbErr }, "Failed to fetch lastConfiguredAt timestamps"); } return NextResponse.json(statuses); } catch (err) { logger.error({ err }, "Unexpected error in /api/cli-tools/all-statuses"); return NextResponse.json(buildErrorBody(500, err instanceof Error ? err.message : String(err)), { status: 500, }); } }