1/**
2 * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an
3 * `HttpClient` so the version probe runs against real bytes.
4 */
5import * as Effect from "effect/Effect";
6import * as HttpClient from "effect/http/HttpClient";
7import * as HttpClientResponse from "effect/http/HttpClientResponse";
8
9interface RecordedResponse {
10 readonly status: number;
11 readonly contentType?: string;
12 readonly body: string;
13}
14
15// Both versions serve their web UI's index page for unknown paths. Bodies are cut after the
16// first lines; the classifier rejects them on content type alone.
17const SPA_HTML =
18 '<!doctype html>\n<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">\n';
19
20export const OPENCODE_2_RESPONSES = {
21 "/api/info": {
22 status: 200,
23 contentType: "application/json",
24 body: '{"version":"2.0.18","pid":2393556,"urls":["http://127.0.0.1:48771"],"paths":{"tmp":"/tmp/opencode"}}',
25 },
26 "/global/health": { status: 200, contentType: "text/html", body: SPA_HTML },
27 unauthorized: {
28 status: 401,
29 contentType: "application/json",
30 body: '{"_tag":"UnauthorizedError","message":"Authentication required"}',
31 },
32} satisfies Record<string, RecordedResponse>;
33
34/**
35 * A 2.0.18 server's command and skill lists for a directory with one command
36 * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on
37 * 2026-09-29. Skill contents are cut; machine-wide skills are dropped.
38 */
39export const OPENCODE_2_WORKSPACE_RESPONSES = {
40 "/api/command": {
41 status: 200,
42 contentType: "application/json",
43 body: '{"location":{"directory":"/work"},"data":[{"name":"init","description":"guided AGENTS.md setup"},{"name":"review","description":"review changes [commit|branch|pr], defaults to uncommitted"},{"name":"bee","description":"B command"}]}',
44 },
45 "/api/skill": {
46 status: 200,
47 contentType: "application/json",
48 body: '{"location":{"directory":"/work"},"data":[{"id":"opencode","name":"OpenCode","description":"Use this skill for any question about OpenCode itself.","path":"/builtin/opencode.md","content":"# OpenCode"},{"id":"report","name":"Report","description":"Use when the user wants to report an opencode issue or bug.","path":"/builtin/report.md","content":"<!-- Built-in skill. -->"},{"id":"plum","name":"plum","description":"plum skill","path":"/work/.opencode/skills/plum/SKILL.md","content":"Say PLUM."}]}',
49 },
50} satisfies Record<string, RecordedResponse>;
51
52export const OPENCODE_1_RESPONSES = {
53 "/api/info": { status: 200, contentType: "text/html", body: SPA_HTML },
54 "/global/health": {
55 status: 200,
56 contentType: "application/json",
57 body: '{"healthy":true,"version":"1.18.32"}',
58 },
59 // 1.x also answers a wrong password with an empty 401 on every path.
60 unauthorized: { status: 401, body: "" },
61} satisfies Record<string, RecordedResponse>;
62
63/** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */
64export function replayOpenCodeServer(
65 responses:
66 | typeof OPENCODE_1_RESPONSES
67 | typeof OPENCODE_2_RESPONSES
68 | (typeof OPENCODE_2_RESPONSES & typeof OPENCODE_2_WORKSPACE_RESPONSES),
69 password: string,
70 requestedPaths: Array<string> = [],
71) {
72 const answers: Record<string, RecordedResponse | undefined> = responses;
73 // Both versions decode Basic credentials as UTF-8 (checked live with `pässwörd` and `pass€word`).
74 const expected = password
75 ? `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`
76 : undefined;
77 return HttpClient.make((request) => {
78 const path = new URL(request.url).pathname;
79 requestedPaths.push(path);
80 const recorded: RecordedResponse =
81 request.headers.authorization === expected
82 ? (answers[path] ?? responses.unauthorized)
83 : responses.unauthorized;
84 return Effect.succeed(
85 HttpClientResponse.fromWeb(
86 request,
87 new Response(recorded.body, {
88 status: recorded.status,
89 headers: recorded.contentType ? { "content-type": recorded.contentType } : {},
90 }),
91 ),
92 );
93 });
94}