From dc4f2489475da8fa018897be69960c11f324dfea Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:43:30 +0900 Subject: [PATCH] fix(doctor): extract semver from `opencode --version` stdout The Electron-based OpenCode build leaks log lines like `00:24:25.202 > app starting { version: '1.14.33', packaged: true }` into stdout, so `getOpenCodeVersion` was returning the entire log line as the 'version'. `compareVersions` then split that string on '.' and produced a nonsensical numeric array (e.g., `[0, 0, 14, 0]`), which it judged as < the minimum 1.4.0. Result: doctor incorrectly flagged OpenCode 1.14.x as below the minimum required version. Replace the raw `stdout.trim()` return with a small `extractSemverFromOutput` helper that runs a semver-shaped regex across the output. A negative lookbehind `(? + +import { describe, expect, it } from "bun:test" +import { extractSemverFromOutput } from "./system-binary" + +describe("extractSemverFromOutput", () => { + describe("#given clean version output #when extractSemverFromOutput #then returns the semver token", () => { + it("plain semver", () => { + expect(extractSemverFromOutput("1.14.33")).toBe("1.14.33") + }) + + it("v-prefixed semver strips the prefix", () => { + expect(extractSemverFromOutput("v1.14.33")).toBe("1.14.33") + }) + + it("trailing whitespace and newlines are tolerated", () => { + expect(extractSemverFromOutput(" 1.14.33\n")).toBe("1.14.33") + }) + + it("pre-release suffix is preserved", () => { + expect(extractSemverFromOutput("1.0.0-beta.1")).toBe("1.0.0-beta.1") + }) + + it("build metadata is preserved", () => { + expect(extractSemverFromOutput("1.0.0+build.42")).toBe("1.0.0+build.42") + }) + }) + + describe("#given Electron log-polluted stdout #when extractSemverFromOutput #then ignores the timestamp and finds the version", () => { + it("regression for #3765: Electron desktop dumps log lines into stdout", () => { + const polluted = "00:24:25.202 > app starting { version: '1.14.33', packaged: true }" + expect(extractSemverFromOutput(polluted)).toBe("1.14.33") + }) + + it("multi-line stdout with log prefix and trailing version", () => { + const polluted = "12:00:00.001 [info] starting opencode\n1.14.33\n" + expect(extractSemverFromOutput(polluted)).toBe("1.14.33") + }) + + it("timestamp-only stdout returns null", () => { + expect(extractSemverFromOutput("00:24:25.202 some log line")).toBe(null) + }) + }) + + describe("#given empty or invalid output #when extractSemverFromOutput #then returns null", () => { + it("empty string", () => { + expect(extractSemverFromOutput("")).toBe(null) + }) + + it("only whitespace", () => { + expect(extractSemverFromOutput(" \n ")).toBe(null) + }) + + it("text without any semver-shaped token", () => { + expect(extractSemverFromOutput("hello world")).toBe(null) + }) + + it("incomplete semver (only major.minor) is rejected", () => { + expect(extractSemverFromOutput("1.14")).toBe(null) + }) + }) +}) diff --git a/src/cli/doctor/checks/system-binary.ts b/src/cli/doctor/checks/system-binary.ts index da020e4eb..4a76648fd 100644 --- a/src/cli/doctor/checks/system-binary.ts +++ b/src/cli/doctor/checks/system-binary.ts @@ -105,6 +105,17 @@ export async function findOpenCodeBinary(): Promise { return findDesktopBinary() } +export function extractSemverFromOutput(output: string): string | null { + const trimmed = output.trim() + if (!trimmed) return null + // Match a semver-shaped token, allowing optional `v` prefix and `-prerelease+build` suffix. + // The negative lookbehind `(?