import { parseSemver } from "@t3tools/shared/semver"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; export interface ProbedOpenCode { readonly generation: "v1" | "v2"; readonly version: string; } // Meant to stop a hung `--version`; wrapped installs can take several seconds to start cold. const OPENCODE_VERSION_PROBE_TIMEOUT = "10 seconds"; const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; // 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out. const decodeApiInfo = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })), ); const decodeGlobalHealth = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), ); function probed(version: string | null | undefined): ProbedOpenCode | undefined { const major = parseSemver(version ?? "")?.major; if (!version || major === undefined) return undefined; return { generation: major >= 2 ? "v2" : "v1", version }; } export const classifyOpenCodeCliVersion = (output: string) => probed(parseGenericCliVersion(output)); // Cross-version paths return HTML 200s, so require JSON; either version returns 401 for bad passwords. function classifyOpenCodeProbeResponse( path: "/api/info" | "/global/health", response: { readonly status: number; readonly contentType: string | undefined; readonly body: string; }, ): ProbedOpenCode | "unauthorized" | undefined { if (response.status === 401) return "unauthorized"; const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase(); if (response.status !== 200 || mediaType !== "application/json") return undefined; const version: Option.Option = path === "/api/info" ? Option.map(decodeApiInfo(response.body), (info) => info.version) : Option.map(decodeGlobalHealth(response.body), (health) => health.version); return probed(Option.getOrUndefined(version)); } const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( binaryPath: string, environment: NodeJS.ProcessEnv | undefined, ) { const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; const { stdout } = yield* runtime .runOpenCodeCommand({ binaryPath, args: ["--version"], ...(environment === undefined ? {} : { environment }), }) .pipe( Effect.timeoutOrElse({ duration: OPENCODE_VERSION_PROBE_TIMEOUT, orElse: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeBinary", detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, }), ), }), ); const result = classifyOpenCodeCliVersion(stdout); if (result) return result; return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeBinary", detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, }); }); // Use fixed status text; underlying errors may expose URLs, credentials, or password headers. const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( serverUrl: string, serverPassword: string, ) { const client = yield* HttpClient.HttpClient; const baseUrl = URL.parse(serverUrl.trim()); if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") { return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: "The OpenCode server URL is not a valid http:// or https:// URL.", }); } // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`. const authorization = serverPassword ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` : undefined; for (const path of ["/api/info", "/global/health"] as const) { const url = new URL(baseUrl); url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; const request = HttpClientRequest.get(url.href); const result = yield* client .execute( authorization ? HttpClientRequest.setHeader(request, "authorization", authorization) : request, ) .pipe( Effect.flatMap((response) => Effect.map(response.text, (body) => classifyOpenCodeProbeResponse(path, { status: response.status, contentType: response.headers["content-type"], body, }), ), ), Effect.mapError( (cause) => new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: "Couldn't reach the OpenCode server.", cause, }), ), Effect.timeoutOrElse({ duration: OPENCODE_SERVER_PROBE_TIMEOUT, orElse: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: "Timed out while checking the OpenCode server version.", }), ), }), ); if (result === "unauthorized") { return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: "401 Unauthorized: the OpenCode server rejected the password.", }); } if (result !== undefined) return result; } return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "probeOpenCodeServer", detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, }); }); export const probeOpenCodeRuntime = ( settings: { readonly binaryPath: string; readonly serverUrl: string; readonly serverPassword: string; }, environment?: NodeJS.ProcessEnv, ): Effect.Effect< ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError, HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime > => settings.serverUrl.trim().length > 0 ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) : probeOpenCodeBinary(settings.binaryPath, environment); // Only 2.x is kept: 1.x can be upgraded in place while T3 runs, and 2.x converts the shared // database, so going back is unsupported. export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect) => Effect.map( Cache.makeWith(() => probe, { capacity: 1, timeToLive: (exit) => Exit.isSuccess(exit) && exit.value.generation === "v2" ? Duration.infinity : Duration.zero, }), (cache) => ({ get: Cache.get(cache, undefined), refresh: Cache.refresh(cache, undefined), lastSuccess: Cache.getSuccess(cache, undefined), }), );