| 1 | /** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */ |
| 2 | import { parseSemver } from "@t3tools/shared/semver"; |
| 3 | import * as Cache from "effect/Cache"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Exit from "effect/Exit"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as HttpClient from "effect/http/HttpClient"; |
| 10 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 11 | |
| 12 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; |
| 13 | import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; |
| 14 | |
| 15 | export interface ProbedOpenCode { |
| 16 | readonly generation: "v1" | "v2"; |
| 17 | readonly version: string; |
| 18 | } |
| 19 | |
| 20 | const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds"; |
| 21 | const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds"; |
| 22 | // 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out. |
| 23 | const decodeApiInfo = Schema.decodeUnknownOption( |
| 24 | Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })), |
| 25 | ); |
| 26 | const decodeGlobalHealth = Schema.decodeUnknownOption( |
| 27 | Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })), |
| 28 | ); |
| 29 | |
| 30 | function probed(version: string | null | undefined): ProbedOpenCode | undefined { |
| 31 | const major = parseSemver(version ?? "")?.major; |
| 32 | if (!version || major === undefined) return undefined; |
| 33 | return { generation: major >= 2 ? "v2" : "v1", version }; |
| 34 | } |
| 35 | |
| 36 | /** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */ |
| 37 | export const classifyOpenCodeCliVersion = (output: string) => |
| 38 | probed(parseGenericCliVersion(output)); |
| 39 | |
| 40 | /** |
| 41 | * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a |
| 42 | * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with |
| 43 | * a 401 on either path, so a 401 says nothing about the version. |
| 44 | */ |
| 45 | function classifyOpenCodeProbeResponse( |
| 46 | path: "/api/info" | "/global/health", |
| 47 | response: { |
| 48 | readonly status: number; |
| 49 | readonly contentType: string | undefined; |
| 50 | readonly body: string; |
| 51 | }, |
| 52 | ): ProbedOpenCode | "unauthorized" | undefined { |
| 53 | if (response.status === 401) return "unauthorized"; |
| 54 | const mediaType = response.contentType?.split(";")[0]?.trim().toLowerCase(); |
| 55 | if (response.status !== 200 || mediaType !== "application/json") return undefined; |
| 56 | const version: Option.Option<string> = |
| 57 | path === "/api/info" |
| 58 | ? Option.map(decodeApiInfo(response.body), (info) => info.version) |
| 59 | : Option.map(decodeGlobalHealth(response.body), (health) => health.version); |
| 60 | return probed(Option.getOrUndefined(version)); |
| 61 | } |
| 62 | |
| 63 | const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( |
| 64 | binaryPath: string, |
| 65 | environment: NodeJS.ProcessEnv | undefined, |
| 66 | ) { |
| 67 | const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; |
| 68 | const { stdout } = yield* runtime |
| 69 | .runOpenCodeCommand({ |
| 70 | binaryPath, |
| 71 | args: ["--version"], |
| 72 | ...(environment === undefined ? {} : { environment }), |
| 73 | }) |
| 74 | .pipe( |
| 75 | Effect.timeoutOrElse({ |
| 76 | duration: OPENCODE_VERSION_PROBE_TIMEOUT, |
| 77 | orElse: () => |
| 78 | Effect.fail( |
| 79 | new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 80 | operation: "probeOpenCodeBinary", |
| 81 | detail: `OpenCode CLI version probe timed out after ${OPENCODE_VERSION_PROBE_TIMEOUT}.`, |
| 82 | }), |
| 83 | ), |
| 84 | }), |
| 85 | ); |
| 86 | const result = classifyOpenCodeCliVersion(stdout); |
| 87 | if (result) return result; |
| 88 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 89 | operation: "probeOpenCodeBinary", |
| 90 | detail: `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, |
| 91 | }); |
| 92 | }); |
| 93 | |
| 94 | // Server failures reach clients through the provider status, so their details are fixed text: |
| 95 | // the underlying error can carry the configured URL, its credentials, or the password header. |
| 96 | const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( |
| 97 | serverUrl: string, |
| 98 | serverPassword: string, |
| 99 | ) { |
| 100 | const client = yield* HttpClient.HttpClient; |
| 101 | const baseUrl = URL.parse(serverUrl.trim()); |
| 102 | if (baseUrl?.protocol !== "http:" && baseUrl?.protocol !== "https:") { |
| 103 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 104 | operation: "probeOpenCodeServer", |
| 105 | detail: "The OpenCode server URL is not a valid http:// or https:// URL.", |
| 106 | }); |
| 107 | } |
| 108 | // UTF-8, as the 1.x SDK client sends it; `HttpClientRequest.basicAuth` uses Latin-1 `btoa`. |
| 109 | const authorization = serverPassword |
| 110 | ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` |
| 111 | : undefined; |
| 112 | for (const path of ["/api/info", "/global/health"] as const) { |
| 113 | // A path prefix and query on the configured URL are kept: `/base/?x=1` → `/base/api/info?x=1`. |
| 114 | const url = new URL(baseUrl); |
| 115 | url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; |
| 116 | const request = HttpClientRequest.get(url.href); |
| 117 | const result = yield* client |
| 118 | .execute( |
| 119 | authorization |
| 120 | ? HttpClientRequest.setHeader(request, "authorization", authorization) |
| 121 | : request, |
| 122 | ) |
| 123 | .pipe( |
| 124 | Effect.flatMap((response) => |
| 125 | Effect.map(response.text, (body) => |
| 126 | classifyOpenCodeProbeResponse(path, { |
| 127 | status: response.status, |
| 128 | contentType: response.headers["content-type"], |
| 129 | body, |
| 130 | }), |
| 131 | ), |
| 132 | ), |
| 133 | Effect.mapError( |
| 134 | (cause) => |
| 135 | new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 136 | operation: "probeOpenCodeServer", |
| 137 | detail: "Couldn't reach the OpenCode server.", |
| 138 | cause, |
| 139 | }), |
| 140 | ), |
| 141 | Effect.timeoutOrElse({ |
| 142 | duration: OPENCODE_SERVER_PROBE_TIMEOUT, |
| 143 | orElse: () => |
| 144 | Effect.fail( |
| 145 | new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 146 | operation: "probeOpenCodeServer", |
| 147 | detail: "Timed out while checking the OpenCode server version.", |
| 148 | }), |
| 149 | ), |
| 150 | }), |
| 151 | ); |
| 152 | if (result === "unauthorized") { |
| 153 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 154 | operation: "probeOpenCodeServer", |
| 155 | detail: "401 Unauthorized: the OpenCode server rejected the password.", |
| 156 | }); |
| 157 | } |
| 158 | if (result !== undefined) return result; |
| 159 | } |
| 160 | return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 161 | operation: "probeOpenCodeServer", |
| 162 | detail: `The server did not identify itself as OpenCode. T3 Code requires OpenCode v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`, |
| 163 | }); |
| 164 | }); |
| 165 | |
| 166 | /** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ |
| 167 | export const probeOpenCodeRuntime = ( |
| 168 | settings: { |
| 169 | readonly binaryPath: string; |
| 170 | readonly serverUrl: string; |
| 171 | readonly serverPassword: string; |
| 172 | }, |
| 173 | environment?: NodeJS.ProcessEnv, |
| 174 | ): Effect.Effect< |
| 175 | ProbedOpenCode, |
| 176 | OpenCodeRuntime.OpenCodeRuntimeError, |
| 177 | HttpClient.HttpClient | OpenCodeRuntime.OpenCodeRuntime |
| 178 | > => |
| 179 | settings.serverUrl.trim().length > 0 |
| 180 | ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) |
| 181 | : probeOpenCodeBinary(settings.binaryPath, environment); |
| 182 | |
| 183 | /** |
| 184 | * One instance's runtime, remembered after the first successful probe. Settings changes rebuild |
| 185 | * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A |
| 186 | * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one. |
| 187 | */ |
| 188 | export const makeOpenCodeRuntimeProbe = <E>(probe: Effect.Effect<ProbedOpenCode, E>) => |
| 189 | Effect.map( |
| 190 | Cache.makeWith(() => probe, { |
| 191 | capacity: 1, |
| 192 | timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), |
| 193 | }), |
| 194 | (cache) => ({ |
| 195 | get: Cache.get(cache, undefined), |
| 196 | refresh: Cache.refresh(cache, undefined), |
| 197 | lastSuccess: Cache.getSuccess(cache, undefined), |
| 198 | }), |
| 199 | ); |