From 15b91f50f67fd7f9825b29af3259f52dfd46f086 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Fri, 16 Jan 2026 23:42:08 +0800 Subject: [PATCH] fix: handle opencode.ps1 in doctor on Windows Handle Windows where lookup and prefer exe/cmd/bat; fall back to ps1 and run via PowerShell for version detection. Tests: bun test src/cli/doctor/checks/opencode.test.ts --- src/cli/doctor/checks/opencode.test.ts | 88 ++++++++++++++++++++++++++ src/cli/doctor/checks/opencode.ts | 70 ++++++++++++++++++-- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/cli/doctor/checks/opencode.test.ts b/src/cli/doctor/checks/opencode.test.ts index 160dfcbc9..3473a606b 100644 --- a/src/cli/doctor/checks/opencode.test.ts +++ b/src/cli/doctor/checks/opencode.test.ts @@ -43,6 +43,94 @@ describe("opencode check", () => { }) }) + describe("command helpers", () => { + it("selects where on Windows", () => { + // #given win32 platform + // #when selecting lookup command + // #then should use where + expect(opencode.getBinaryLookupCommand("win32")).toBe("where") + }) + + it("selects which on non-Windows", () => { + // #given linux platform + // #when selecting lookup command + // #then should use which + expect(opencode.getBinaryLookupCommand("linux")).toBe("which") + expect(opencode.getBinaryLookupCommand("darwin")).toBe("which") + }) + + it("parses command output into paths", () => { + // #given raw output with multiple lines and spaces + const output = "C:\\\\bin\\\\opencode.ps1\r\nC:\\\\bin\\\\opencode.exe\n\n" + + // #when parsing + const paths = opencode.parseBinaryPaths(output) + + // #then should return trimmed, non-empty paths + expect(paths).toEqual(["C:\\\\bin\\\\opencode.ps1", "C:\\\\bin\\\\opencode.exe"]) + }) + + it("prefers exe/cmd/bat over ps1 on Windows", () => { + // #given windows paths + const paths = [ + "C:\\\\bin\\\\opencode.ps1", + "C:\\\\bin\\\\opencode.cmd", + "C:\\\\bin\\\\opencode.exe", + ] + + // #when selecting binary + const selected = opencode.selectBinaryPath(paths, "win32") + + // #then should prefer exe + expect(selected).toBe("C:\\\\bin\\\\opencode.exe") + }) + + it("falls back to ps1 when it is the only Windows candidate", () => { + // #given only ps1 path + const paths = ["C:\\\\bin\\\\opencode.ps1"] + + // #when selecting binary + const selected = opencode.selectBinaryPath(paths, "win32") + + // #then should return ps1 path + expect(selected).toBe("C:\\\\bin\\\\opencode.ps1") + }) + + it("builds PowerShell command for ps1 on Windows", () => { + // #given a ps1 path on Windows + const command = opencode.buildVersionCommand( + "C:\\\\bin\\\\opencode.ps1", + "win32" + ) + + // #when building command + // #then should use PowerShell + expect(command).toEqual([ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "C:\\\\bin\\\\opencode.ps1", + "--version", + ]) + }) + + it("builds direct command for non-ps1 binaries", () => { + // #given an exe on Windows and a binary on linux + const winCommand = opencode.buildVersionCommand( + "C:\\\\bin\\\\opencode.exe", + "win32" + ) + const linuxCommand = opencode.buildVersionCommand("opencode", "linux") + + // #when building commands + // #then should execute directly + expect(winCommand).toEqual(["C:\\\\bin\\\\opencode.exe", "--version"]) + expect(linuxCommand).toEqual(["opencode", "--version"]) + }) + }) + describe("getOpenCodeInfo", () => { it("returns installed: false when binary not found", async () => { // #given no opencode binary diff --git a/src/cli/doctor/checks/opencode.ts b/src/cli/doctor/checks/opencode.ts index e6a234559..dd1657a5f 100644 --- a/src/cli/doctor/checks/opencode.ts +++ b/src/cli/doctor/checks/opencode.ts @@ -1,14 +1,70 @@ import type { CheckResult, CheckDefinition, OpenCodeInfo } from "../types" import { CHECK_IDS, CHECK_NAMES, MIN_OPENCODE_VERSION, OPENCODE_BINARIES } from "../constants" +const WINDOWS_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".ps1"] + +export function getBinaryLookupCommand(platform: NodeJS.Platform): "which" | "where" { + return platform === "win32" ? "where" : "which" +} + +export function parseBinaryPaths(output: string): string[] { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +export function selectBinaryPath( + paths: string[], + platform: NodeJS.Platform +): string | null { + if (paths.length === 0) return null + if (platform !== "win32") return paths[0] + + const normalized = paths.map((path) => path.toLowerCase()) + for (const ext of WINDOWS_EXECUTABLE_EXTS) { + const index = normalized.findIndex((path) => path.endsWith(ext)) + if (index !== -1) return paths[index] + } + + return paths[0] +} + +export function buildVersionCommand( + binaryPath: string, + platform: NodeJS.Platform +): string[] { + if ( + platform === "win32" && + binaryPath.toLowerCase().endsWith(".ps1") + ) { + return [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + binaryPath, + "--version", + ] + } + + return [binaryPath, "--version"] +} + export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> { for (const binary of OPENCODE_BINARIES) { try { - const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" }) + const lookupCommand = getBinaryLookupCommand(process.platform) + const proc = Bun.spawn([lookupCommand, binary], { stdout: "pipe", stderr: "pipe" }) const output = await new Response(proc.stdout).text() await proc.exited if (proc.exitCode === 0) { - return { binary, path: output.trim() } + const paths = parseBinaryPaths(output) + const selectedPath = selectBinaryPath(paths, process.platform) + if (selectedPath) { + return { binary, path: selectedPath } + } } } catch { continue @@ -17,9 +73,13 @@ export async function findOpenCodeBinary(): Promise<{ binary: string; path: stri return null } -export async function getOpenCodeVersion(binary: string): Promise { +export async function getOpenCodeVersion( + binaryPath: string, + platform: NodeJS.Platform = process.platform +): Promise { try { - const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" }) + const command = buildVersionCommand(binaryPath, platform) + const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" }) const output = await new Response(proc.stdout).text() await proc.exited if (proc.exitCode === 0) { @@ -61,7 +121,7 @@ export async function getOpenCodeInfo(): Promise { } } - const version = await getOpenCodeVersion(binaryInfo.binary) + const version = await getOpenCodeVersion(binaryInfo.path ?? binaryInfo.binary) return { installed: true,