From f9b9b5965875b5973324f343779597b93724239b Mon Sep 17 00:00:00 2001 From: MotorwaySouth9 Date: Fri, 16 Jan 2026 08:40:19 +0800 Subject: [PATCH 1/4] fix(lsp): improve Windows server detection --- src/tools/lsp/config.test.ts | 110 +++++++++++++++++++++++++++++++++++ src/tools/lsp/config.ts | 43 +++++++++----- 2 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 src/tools/lsp/config.test.ts diff --git a/src/tools/lsp/config.test.ts b/src/tools/lsp/config.test.ts new file mode 100644 index 000000000..977b6af00 --- /dev/null +++ b/src/tools/lsp/config.test.ts @@ -0,0 +1,110 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { isServerInstalled } from "./config" +import { mkdtempSync, rmSync, writeFileSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" + +describe("isServerInstalled", () => { + let tempDir: string + let savedEnv: { [key: string]: string | undefined } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "lsp-config-test-")) + savedEnv = { + PATH: process.env.PATH, + Path: process.env.Path, + PATHEXT: process.env.PATHEXT, + } + }) + + afterEach(() => { + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch (e) { + console.error(`Failed to clean up temp dir: ${e}`) + } + + const keys = ["PATH", "Path", "PATHEXT"] + for (const key of keys) { + const val = savedEnv[key] + if (val === undefined) { + delete process.env[key] + } else { + process.env[key] = val + } + } + }) + + test("detects executable in PATH", () => { + const binName = "test-lsp-server" + const ext = process.platform === "win32" ? ".cmd" : "" + const binPath = join(tempDir, binName + ext) + + writeFileSync(binPath, "echo hello") + + const pathSep = process.platform === "win32" ? ";" : ":" + process.env.PATH = `${tempDir}${pathSep}${process.env.PATH || ""}` + + expect(isServerInstalled([binName])).toBe(true) + }) + + test("returns false for missing executable", () => { + expect(isServerInstalled(["non-existent-server"])).toBe(false) + }) + + if (process.platform === "win32") { + test("Windows: detects executable with Path env var", () => { + const binName = "test-lsp-server-case" + const binPath = join(tempDir, binName + ".cmd") + writeFileSync(binPath, "echo hello") + + delete process.env.PATH + process.env.Path = tempDir + + expect(isServerInstalled([binName])).toBe(true) + }) + + test("Windows: respects PATHEXT", () => { + const binName = "test-lsp-server-custom" + const binPath = join(tempDir, binName + ".COM") + writeFileSync(binPath, "echo hello") + + process.env.PATH = tempDir + process.env.PATHEXT = ".COM;.EXE" + + expect(isServerInstalled([binName])).toBe(true) + }) + + test("Windows: ensures default extensions are checked even if PATHEXT is missing", () => { + const binName = "test-lsp-server-default" + const binPath = join(tempDir, binName + ".bat") + writeFileSync(binPath, "echo hello") + + process.env.PATH = tempDir + delete process.env.PATHEXT + + expect(isServerInstalled([binName])).toBe(true) + }) + + test("Windows: ensures default extensions are checked even if PATHEXT does not include them", () => { + const binName = "test-lsp-server-ps1" + const binPath = join(tempDir, binName + ".ps1") + writeFileSync(binPath, "echo hello") + + process.env.PATH = tempDir + process.env.PATHEXT = ".COM" + + expect(isServerInstalled([binName])).toBe(true) + }) + } else { + test("Non-Windows: does not use windows extensions", () => { + const binName = "test-lsp-server-win" + const binPath = join(tempDir, binName + ".cmd") + writeFileSync(binPath, "echo hello") + + process.env.PATH = tempDir + + expect(isServerInstalled([binName])).toBe(false) + }) + } +}) diff --git a/src/tools/lsp/config.ts b/src/tools/lsp/config.ts index b29f9ac63..10a6febcf 100644 --- a/src/tools/lsp/config.ts +++ b/src/tools/lsp/config.ts @@ -170,31 +170,46 @@ export function isServerInstalled(command: string[]): boolean { } const isWindows = process.platform === "win32" - const ext = isWindows ? ".exe" : "" + + let exts = [""] + if (isWindows) { + const pathExt = process.env.PATHEXT || "" + if (pathExt) { + const systemExts = pathExt.split(";").filter(Boolean) + exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])] + } else { + exts = ["", ".exe", ".cmd", ".bat", ".ps1"] + } + } - const pathEnv = process.env.PATH || "" + let pathEnv = process.env.PATH || "" + if (isWindows && !pathEnv) { + pathEnv = process.env.Path || "" + } + const pathSeparator = isWindows ? ";" : ":" const paths = pathEnv.split(pathSeparator) for (const p of paths) { - if (existsSync(join(p, cmd)) || existsSync(join(p, cmd + ext))) { - return true + for (const suffix of exts) { + if (existsSync(join(p, cmd + suffix))) { + return true + } } } const cwd = process.cwd() - const additionalPaths = [ - join(cwd, "node_modules", ".bin", cmd), - join(cwd, "node_modules", ".bin", cmd + ext), - join(homedir(), ".config", "opencode", "bin", cmd), - join(homedir(), ".config", "opencode", "bin", cmd + ext), - join(homedir(), ".config", "opencode", "node_modules", ".bin", cmd), - join(homedir(), ".config", "opencode", "node_modules", ".bin", cmd + ext), + const additionalBases = [ + join(cwd, "node_modules", ".bin"), + join(homedir(), ".config", "opencode", "bin"), + join(homedir(), ".config", "opencode", "node_modules", ".bin"), ] - for (const p of additionalPaths) { - if (existsSync(p)) { - return true + for (const base of additionalBases) { + for (const suffix of exts) { + if (existsSync(join(base, cmd + suffix))) { + return true + } } } From ca2f8059a6b00eea35177fdcf5c37fa2e080e38e Mon Sep 17 00:00:00 2001 From: MotorwaySouth9 Date: Fri, 16 Jan 2026 08:40:37 +0800 Subject: [PATCH 2/4] fix(cli): avoid unix which in lsp doctor check --- src/cli/doctor/checks/lsp.test.ts | 15 +++++++++++++++ src/cli/doctor/checks/lsp.ts | 12 ++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/cli/doctor/checks/lsp.test.ts b/src/cli/doctor/checks/lsp.test.ts index b266cc0a6..e21ebc418 100644 --- a/src/cli/doctor/checks/lsp.test.ts +++ b/src/cli/doctor/checks/lsp.test.ts @@ -17,6 +17,21 @@ describe("lsp check", () => { expect(Array.isArray(s.extensions)).toBe(true) }) }) + + it("does not spawn 'which' command (windows compatibility)", async () => { + // #given + const spawnSpy = spyOn(Bun, "spawn") + + // #when getting servers info + await lsp.getLspServersInfo() + + // #then should not spawn which + const calls = spawnSpy.mock.calls + const whichCalls = calls.filter((c) => Array.isArray(c) && Array.isArray(c[0]) && c[0][0] === "which") + expect(whichCalls.length).toBe(0) + + spawnSpy.mockRestore() + }) }) describe("getLspServerStats", () => { diff --git a/src/cli/doctor/checks/lsp.ts b/src/cli/doctor/checks/lsp.ts index 70350edd3..254e3d673 100644 --- a/src/cli/doctor/checks/lsp.ts +++ b/src/cli/doctor/checks/lsp.ts @@ -12,21 +12,13 @@ const DEFAULT_LSP_SERVERS: Array<{ { id: "gopls", binary: "gopls", extensions: [".go"] }, ] -async function checkBinaryExists(binary: string): Promise { - try { - const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" }) - await proc.exited - return proc.exitCode === 0 - } catch { - return false - } -} +import { isServerInstalled } from "../../../tools/lsp/config" export async function getLspServersInfo(): Promise { const servers: LspServerInfo[] = [] for (const server of DEFAULT_LSP_SERVERS) { - const installed = await checkBinaryExists(server.binary) + const installed = isServerInstalled([server.binary]) servers.push({ id: server.id, installed, From 7b9e20f2fa23f1415649fe6c5a5d9f705398770f Mon Sep 17 00:00:00 2001 From: MotorwaySouth9 Date: Fri, 16 Jan 2026 09:02:02 +0800 Subject: [PATCH 3/4] test: harden windows lsp test cleanup --- src/cli/doctor/checks/lsp.test.ts | 18 ++++++++++-------- src/tools/lsp/config.test.ts | 22 ++++++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/cli/doctor/checks/lsp.test.ts b/src/cli/doctor/checks/lsp.test.ts index e21ebc418..259456faa 100644 --- a/src/cli/doctor/checks/lsp.test.ts +++ b/src/cli/doctor/checks/lsp.test.ts @@ -22,15 +22,17 @@ describe("lsp check", () => { // #given const spawnSpy = spyOn(Bun, "spawn") - // #when getting servers info - await lsp.getLspServersInfo() + try { + // #when getting servers info + await lsp.getLspServersInfo() - // #then should not spawn which - const calls = spawnSpy.mock.calls - const whichCalls = calls.filter((c) => Array.isArray(c) && Array.isArray(c[0]) && c[0][0] === "which") - expect(whichCalls.length).toBe(0) - - spawnSpy.mockRestore() + // #then should not spawn which + const calls = spawnSpy.mock.calls + const whichCalls = calls.filter((c) => Array.isArray(c) && Array.isArray(c[0]) && c[0][0] === "which") + expect(whichCalls.length).toBe(0) + } finally { + spawnSpy.mockRestore() + } }) }) diff --git a/src/tools/lsp/config.test.ts b/src/tools/lsp/config.test.ts index 977b6af00..66c4a6f30 100644 --- a/src/tools/lsp/config.test.ts +++ b/src/tools/lsp/config.test.ts @@ -24,14 +24,20 @@ describe("isServerInstalled", () => { console.error(`Failed to clean up temp dir: ${e}`) } - const keys = ["PATH", "Path", "PATHEXT"] - for (const key of keys) { - const val = savedEnv[key] - if (val === undefined) { - delete process.env[key] - } else { - process.env[key] = val - } + const pathVal = savedEnv.PATH ?? savedEnv.Path + if (pathVal === undefined) { + delete process.env.PATH + delete process.env.Path + } else { + process.env.PATH = pathVal + process.env.Path = pathVal + } + + const pathextVal = savedEnv.PATHEXT + if (pathextVal === undefined) { + delete process.env.PATHEXT + } else { + process.env.PATHEXT = pathextVal } }) From 8e02cab3075c923a51e08b121bc439639ad439e8 Mon Sep 17 00:00:00 2001 From: MotorwaySouth9 Date: Fri, 16 Jan 2026 10:31:53 +0800 Subject: [PATCH 4/4] test: stub gh cli spawn and refine PATH cleanup --- src/cli/doctor/checks/gh.test.ts | 61 +++++++++++++++++++++++++++----- src/tools/lsp/config.test.ts | 26 ++++++++++---- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/cli/doctor/checks/gh.test.ts b/src/cli/doctor/checks/gh.test.ts index 95a9bf157..8411b649e 100644 --- a/src/cli/doctor/checks/gh.test.ts +++ b/src/cli/doctor/checks/gh.test.ts @@ -3,15 +3,60 @@ import * as gh from "./gh" describe("gh cli check", () => { describe("getGhCliInfo", () => { - it("returns gh cli info structure", async () => { - // #given - // #when checking gh cli info - const info = await gh.getGhCliInfo() + function createProc(opts: { stdout?: string; stderr?: string; exitCode?: number }) { + const stdoutText = opts.stdout ?? "" + const stderrText = opts.stderr ?? "" + const exitCode = opts.exitCode ?? 0 + const encoder = new TextEncoder() - // #then should return valid info structure - expect(typeof info.installed).toBe("boolean") - expect(info.authenticated === true || info.authenticated === false).toBe(true) - expect(Array.isArray(info.scopes)).toBe(true) + return { + stdout: new ReadableStream({ + start(controller) { + if (stdoutText) controller.enqueue(encoder.encode(stdoutText)) + controller.close() + }, + }), + stderr: new ReadableStream({ + start(controller) { + if (stderrText) controller.enqueue(encoder.encode(stderrText)) + controller.close() + }, + }), + exited: Promise.resolve(exitCode), + exitCode, + } as unknown as ReturnType + } + + it("returns gh cli info structure", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation((cmd) => { + if (Array.isArray(cmd) && cmd[0] === "which" && cmd[1] === "gh") { + return createProc({ stdout: "/usr/bin/gh\n" }) + } + + if (Array.isArray(cmd) && cmd[0] === "gh" && cmd[1] === "--version") { + return createProc({ stdout: "gh version 2.40.0\n" }) + } + + if (Array.isArray(cmd) && cmd[0] === "gh" && cmd[1] === "auth" && cmd[2] === "status") { + return createProc({ + exitCode: 0, + stderr: "Logged in to github.com account octocat (keyring)\nToken scopes: 'repo', 'read:org'\n", + }) + } + + throw new Error(`Unexpected Bun.spawn call: ${Array.isArray(cmd) ? cmd.join(" ") : String(cmd)}`) + }) + + try { + const info = await gh.getGhCliInfo() + + expect(info.installed).toBe(true) + expect(info.version).toBe("2.40.0") + expect(typeof info.authenticated).toBe("boolean") + expect(Array.isArray(info.scopes)).toBe(true) + } finally { + spawnSpy.mockRestore() + } }) }) diff --git a/src/tools/lsp/config.test.ts b/src/tools/lsp/config.test.ts index 66c4a6f30..da65e67ee 100644 --- a/src/tools/lsp/config.test.ts +++ b/src/tools/lsp/config.test.ts @@ -24,13 +24,27 @@ describe("isServerInstalled", () => { console.error(`Failed to clean up temp dir: ${e}`) } - const pathVal = savedEnv.PATH ?? savedEnv.Path - if (pathVal === undefined) { - delete process.env.PATH - delete process.env.Path + if (process.platform === "win32") { + const pathVal = savedEnv.PATH ?? savedEnv.Path + if (pathVal === undefined) { + delete process.env.PATH + delete process.env.Path + } else { + process.env.PATH = pathVal + process.env.Path = pathVal + } } else { - process.env.PATH = pathVal - process.env.Path = pathVal + if (savedEnv.PATH === undefined) { + delete process.env.PATH + } else { + process.env.PATH = savedEnv.PATH + } + + if (savedEnv.Path === undefined) { + delete process.env.Path + } else { + process.env.Path = savedEnv.Path + } } const pathextVal = savedEnv.PATHEXT