File size: 7,359 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"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<string | null> {
  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<string, unknown>;

    switch (toolId) {
      case "claude": {
        const env = config.env as Record<string, unknown> | undefined;
        return (env?.ANTHROPIC_BASE_URL as string | undefined) ?? null;
      }
      case "qwen": {
        const mp = config.modelProviders as Record<string, unknown>[] | undefined;
        if (!Array.isArray(mp)) return null;
        for (const provider of mp) {
          const baseUrl = (provider as Record<string, unknown>).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<toolId, ToolBatchStatus>
 * Response 401: { error: "Unauthorized" }
 * Response 500: { error: sanitizeErrorMessage(err) }
 */
export async function GET(request: Request): Promise<Response> {
  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<string, number> = {};
    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<Awaited<ReturnType<typeof getCliRuntimeStatus>>>([
            getCliRuntimeStatus(toolId),
            new Promise<never>((_, 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<string, unknown>).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,
    });
  }
}