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:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user