diff --git a/src/cli/config-manager/opencode-binary.test.ts b/src/cli/config-manager/opencode-binary.test.ts
new file mode 100644
index 000000000..b298c1027
--- /dev/null
+++ b/src/cli/config-manager/opencode-binary.test.ts
@@ -0,0 +1,85 @@
+///
+
+import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
+
+import * as configContext from "./config-context"
+import * as spawnHelpers from "../../shared/spawn-with-windows-hide"
+
+type OpenCodeBinaryModule = typeof import("./opencode-binary")
+
+type CreateProcOptions = {
+ exitCode?: number | null
+ output?: { stdout?: string; stderr?: string }
+}
+
+function createProc(options: CreateProcOptions = {}): ReturnType {
+ const exitCode = options.exitCode ?? 0
+ return {
+ exited: Promise.resolve(exitCode),
+ exitCode,
+ stdout: options.output?.stdout !== undefined ? new Blob([options.output.stdout]).stream() : undefined,
+ stderr: options.output?.stderr !== undefined ? new Blob([options.output.stderr]).stream() : undefined,
+ kill: () => {},
+ } satisfies ReturnType
+}
+
+describe("getOpenCodeVersion (installer)", () => {
+ let spawnSpy: ReturnType
+ let initConfigContextSpy: ReturnType
+ let getOpenCodeVersion: OpenCodeBinaryModule["getOpenCodeVersion"]
+
+ beforeEach(async () => {
+ spawnSpy = spyOn(spawnHelpers, "spawnWithWindowsHide")
+ initConfigContextSpy = spyOn(configContext, "initConfigContext").mockImplementation(() => {})
+ const mod = await import(`./opencode-binary?test=${Date.now()}-${Math.random()}`)
+ getOpenCodeVersion = mod.getOpenCodeVersion
+ })
+
+ afterEach(() => {
+ spawnSpy.mockRestore()
+ initConfigContextSpy.mockRestore()
+ })
+
+ describe("#given clean opencode --version stdout #when getOpenCodeVersion #then returns the semver string", () => {
+ it("plain semver", async () => {
+ spawnSpy.mockReturnValue(createProc({ output: { stdout: "1.14.33\n" } }))
+
+ const result = await getOpenCodeVersion()
+
+ expect(result).toBe("1.14.33")
+ })
+ })
+
+ describe("#given Electron-polluted opencode --version stdout #when getOpenCodeVersion #then returns extracted semver, not the timestamp-prefixed line", () => {
+ it("regression for #3765 installer caller", async () => {
+ const polluted = "00:24:25.202 > app starting { version: '1.14.33', packaged: true }"
+ spawnSpy.mockReturnValue(createProc({ output: { stdout: polluted } }))
+
+ const result = await getOpenCodeVersion()
+
+ expect(result).toBe("1.14.33")
+ })
+ })
+
+ describe("#given non-semver-shaped stdout #when getOpenCodeVersion #then falls back to trimmed output", () => {
+ it("preserves legacy behavior for unrecognized formats", async () => {
+ spawnSpy.mockReturnValue(createProc({ output: { stdout: " custom-build\n" } }))
+
+ const result = await getOpenCodeVersion()
+
+ expect(result).toBe("custom-build")
+ })
+ })
+
+ describe("#given no opencode binary on PATH #when getOpenCodeVersion #then returns null", () => {
+ it("all candidate spawns throw", async () => {
+ spawnSpy.mockImplementation(() => {
+ throw new Error("ENOENT")
+ })
+
+ const result = await getOpenCodeVersion()
+
+ expect(result).toBe(null)
+ })
+ })
+})
diff --git a/src/cli/config-manager/opencode-binary.ts b/src/cli/config-manager/opencode-binary.ts
index 6fb140403..d5256a0b0 100644
--- a/src/cli/config-manager/opencode-binary.ts
+++ b/src/cli/config-manager/opencode-binary.ts
@@ -1,3 +1,4 @@
+import { extractSemverFromOutput } from "../../shared/extract-semver"
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { initConfigContext } from "./config-context"
@@ -19,7 +20,7 @@ async function findOpenCodeBinaryWithVersion(): Promise
import { describe, expect, it } from "bun:test"
-import { extractSemverFromOutput } from "./system-binary"
+import { extractSemverFromOutput } from "../../../shared/extract-semver"
describe("extractSemverFromOutput", () => {
describe("#given clean version output #when extractSemverFromOutput #then returns the semver token", () => {
diff --git a/src/cli/doctor/checks/system-binary.ts b/src/cli/doctor/checks/system-binary.ts
index 4a76648fd..9a92f7232 100644
--- a/src/cli/doctor/checks/system-binary.ts
+++ b/src/cli/doctor/checks/system-binary.ts
@@ -1,10 +1,13 @@
import { existsSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
+import { extractSemverFromOutput } from "../../../shared/extract-semver"
import { spawnWithTimeout } from "../spawn-with-timeout"
import { OPENCODE_BINARIES } from "../constants"
+export { extractSemverFromOutput }
+
const WINDOWS_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".ps1"]
export interface OpenCodeBinaryInfo {
@@ -105,17 +108,6 @@ 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 `(?