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 `(?<![\d:])` skips the
milliseconds segment of timestamps (e.g., `25.202` in
`00:24:25.202`), so the parser locks onto the real version token.

Adds 12 unit tests covering plain semver, v-prefix, pre-release,
build metadata, the Electron regression, timestamp-only stdout,
and various invalid inputs.

Fixes #3765
This commit is contained in:
YeonGyu-Kim
2026-05-10 14:43:30 +09:00
parent 50699e3af1
commit dc4f248947
2 changed files with 74 additions and 1 deletions
@@ -0,0 +1,62 @@
/// <reference types="bun-types" />
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)
})
})
})
+12 -1
View File
@@ -105,6 +105,17 @@ export async function findOpenCodeBinary(): Promise<OpenCodeBinaryInfo | null> {
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 `(?<![\d:])` prevents matching the milliseconds segment of timestamps
// like `00:24:25.202` that the Electron-based OpenCode binary leaks into stdout.
const semverPattern = /(?<![\d:])v?(\d+\.\d+\.\d+(?:[-+][\w.]+)*)/
const match = trimmed.match(semverPattern)
return match?.[1] ?? null
}
export async function getOpenCodeVersion(
binaryPath: string,
platform: NodeJS.Platform = process.platform
@@ -113,7 +124,7 @@ export async function getOpenCodeVersion(
const command = buildVersionCommand(binaryPath, platform)
const result = await spawnWithTimeout(command, { stdout: "pipe", stderr: "pipe" })
if (result.timedOut || result.exitCode !== 0) return null
return result.stdout.trim() || null
return extractSemverFromOutput(result.stdout)
} catch {
return null
}