| 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 | */ |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as HttpClient from "effect/http/HttpClient"; |
| 7 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 8 | |
| 9 | interface 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. |
| 17 | const SPA_HTML = |
| 18 | '<!doctype html>\n<html lang="en" style="background-color: var(--v2-background-bg-deep, #fafafa)">\n'; |
| 19 | |
| 20 | export 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 | */ |
| 39 | export 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 | |
| 52 | export 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. */ |
| 64 | export 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 | } |