1/** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */
2import { parseSemver } from "@t3tools/shared/semver";
3import * as Cache from "effect/Cache";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11
12import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
13import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe";
14
15export interface ProbedOpenCode {
16 readonly generation: "v1" | "v2";
17 readonly version: string;
18}
19
20const OPENCODE_VERSION_PROBE_TIMEOUT = "4 seconds";
21const OPENCODE_SERVER_PROBE_TIMEOUT = "5 seconds";
22// 2.x's own CLI decodes `{version, pid}` from `/api/info`; requiring both keeps unrelated JSON out.
23const decodeApiInfo = Schema.decodeUnknownOption(
24 Schema.fromJsonString(Schema.Struct({ version: Schema.String, pid: Schema.Int })),
25);
26const decodeGlobalHealth = Schema.decodeUnknownOption(
27 Schema.fromJsonString(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String })),
28);
29
30function 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. */
37export 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 */
45function 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
63const 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.
96const 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. */
167export 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 */
188export 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 );