From 6ba1d675b94f36d84e26c2021d9ef51633947707 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Feb 2026 21:42:04 +0900 Subject: [PATCH] fix(installer): improve Windows compatibility for shell detection and paths Closes #461 --- src/cli/config-manager/bun-install.test.ts | 47 +++++++++++++++ src/cli/config-manager/bun-install.ts | 15 ++++- .../command-executor/shell-path.test.ts | 39 +++++++++++++ src/shared/command-executor/shell-path.ts | 9 +++ src/shared/data-path.test.ts | 57 +++++++++++++++++++ src/shared/data-path.ts | 9 +++ src/shared/opencode-config-dir.test.ts | 6 +- src/shared/opencode-config-dir.ts | 25 ++++---- src/shared/shell-env.test.ts | 25 ++++++++ src/shared/shell-env.ts | 20 ++++++- 10 files changed, 234 insertions(+), 18 deletions(-) create mode 100644 src/cli/config-manager/bun-install.test.ts create mode 100644 src/shared/command-executor/shell-path.test.ts create mode 100644 src/shared/data-path.test.ts diff --git a/src/cli/config-manager/bun-install.test.ts b/src/cli/config-manager/bun-install.test.ts new file mode 100644 index 00000000..8a517cd6 --- /dev/null +++ b/src/cli/config-manager/bun-install.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { initConfigContext, resetConfigContext } from "./config-context" +import { runBunInstallWithDetails } from "./bun-install" + +describe("bun-install", () => { + let originalPlatform: NodeJS.Platform + + beforeEach(() => { + originalPlatform = process.platform + resetConfigContext() + initConfigContext("opencode", null) + }) + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }) + resetConfigContext() + }) + + test("#given Windows with bun.exe on PATH #when runBunInstallWithDetails is called #then uses bun.exe", async () => { + Object.defineProperty(process, "platform", { value: "win32" }) + + const whichSpy = spyOn(Bun, "which") + .mockImplementation((binary: string) => { + if (binary === "bun.exe") { + return "C:\\Tools\\bun.exe" + } + return null + }) + + const spawnSpy = spyOn(Bun, "spawn").mockReturnValue({ + exited: Promise.resolve(0), + exitCode: 0, + kill: () => {}, + } as unknown as ReturnType) + + try { + const result = await runBunInstallWithDetails() + + expect(result.success).toBe(true) + expect(spawnSpy).toHaveBeenCalledTimes(1) + expect(spawnSpy.mock.calls[0]?.[0]).toEqual(["C:\\Tools\\bun.exe", "install"]) + } finally { + spawnSpy.mockRestore() + whichSpy.mockRestore() + } + }) +}) diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index f24e77fa..35dcd9a2 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -9,6 +9,14 @@ export interface BunInstallResult { error?: string } +function resolveBunCommand(): string { + if (process.platform === "win32") { + return Bun.which("bun.exe") ?? Bun.which("bun") ?? "bun.exe" + } + + return Bun.which("bun") ?? "bun" +} + export async function runBunInstall(): Promise { const result = await runBunInstallWithDetails() return result.success @@ -16,7 +24,8 @@ export async function runBunInstall(): Promise { export async function runBunInstallWithDetails(): Promise { try { - const proc = Bun.spawn(["bun", "install"], { + const bunCommand = resolveBunCommand() + const proc = Bun.spawn([bunCommand, "install"], { cwd: getConfigDir(), stdout: "inherit", stderr: "inherit", @@ -39,7 +48,7 @@ export async function runBunInstallWithDetails(): Promise { return { success: false, timedOut: true, - error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`, + error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually in ${getConfigDir()}: bun install`, } } @@ -55,7 +64,7 @@ export async function runBunInstallWithDetails(): Promise { const message = err instanceof Error ? err.message : String(err) return { success: false, - error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`, + error: `bun install failed: ${message}. Ensure Bun is installed and available in PATH: https://bun.sh/docs/installation`, } } } diff --git a/src/shared/command-executor/shell-path.test.ts b/src/shared/command-executor/shell-path.test.ts new file mode 100644 index 00000000..0d92570e --- /dev/null +++ b/src/shared/command-executor/shell-path.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { findBashPath } from "./shell-path" + +describe("shell-path", () => { + let originalPlatform: NodeJS.Platform + let originalComspec: string | undefined + + beforeEach(() => { + originalPlatform = process.platform + originalComspec = process.env.COMSPEC + }) + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }) + if (originalComspec !== undefined) { + process.env.COMSPEC = originalComspec + return + } + delete process.env.COMSPEC + }) + + test("#given Windows platform with COMSPEC #when findBashPath is called #then returns COMSPEC path", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe" + + const result = findBashPath() + + expect(result).toBe("C:\\Windows\\System32\\cmd.exe") + }) + + test("#given Windows platform without COMSPEC #when findBashPath is called #then returns default cmd path", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + delete process.env.COMSPEC + + const result = findBashPath() + + expect(result).toBe("C:\\Windows\\System32\\cmd.exe") + }) +}) diff --git a/src/shared/command-executor/shell-path.ts b/src/shared/command-executor/shell-path.ts index e3131d44..8a6f2072 100644 --- a/src/shared/command-executor/shell-path.ts +++ b/src/shared/command-executor/shell-path.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs" const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"] const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"] +const DEFAULT_WINDOWS_CMD_PATH = "C:\\Windows\\System32\\cmd.exe" function findShellPath( defaultPaths: string[], @@ -19,9 +20,17 @@ function findShellPath( } export function findZshPath(customZshPath?: string): string | null { + if (process.platform === "win32") { + return process.env.COMSPEC?.trim() || DEFAULT_WINDOWS_CMD_PATH + } + return findShellPath(DEFAULT_ZSH_PATHS, customZshPath) } export function findBashPath(): string | null { + if (process.platform === "win32") { + return process.env.COMSPEC?.trim() || DEFAULT_WINDOWS_CMD_PATH + } + return findShellPath(DEFAULT_BASH_PATHS) } diff --git a/src/shared/data-path.test.ts b/src/shared/data-path.test.ts new file mode 100644 index 00000000..bb2c2288 --- /dev/null +++ b/src/shared/data-path.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { homedir } from "node:os" +import { join } from "node:path" +import { getCacheDir, getDataDir } from "./data-path" + +describe("data-path", () => { + let originalPlatform: NodeJS.Platform + let originalEnv: Record + + beforeEach(() => { + originalPlatform = process.platform + originalEnv = { + LOCALAPPDATA: process.env.LOCALAPPDATA, + APPDATA: process.env.APPDATA, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + XDG_CACHE_HOME: process.env.XDG_CACHE_HOME, + } + }) + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }) + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) { + process.env[key] = value + } else { + delete process.env[key] + } + } + }) + + test("#given Windows with LOCALAPPDATA #when getDataDir is called #then returns LOCALAPPDATA", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + process.env.LOCALAPPDATA = "C:\\Users\\TestUser\\AppData\\Local" + + const result = getDataDir() + + expect(result).toBe("C:\\Users\\TestUser\\AppData\\Local") + }) + + test("#given Windows without LOCALAPPDATA #when getDataDir is called #then falls back to AppData Local", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + delete process.env.LOCALAPPDATA + + const result = getDataDir() + + expect(result).toBe(join(homedir(), "AppData", "Local")) + }) + + test("#given Windows with LOCALAPPDATA #when getCacheDir is called #then returns Local cache path", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + process.env.LOCALAPPDATA = "C:\\Users\\TestUser\\AppData\\Local" + + const result = getCacheDir() + + expect(result).toBe(join("C:\\Users\\TestUser\\AppData\\Local", "cache")) + }) +}) diff --git a/src/shared/data-path.ts b/src/shared/data-path.ts index 28aa9a07..c3d77929 100644 --- a/src/shared/data-path.ts +++ b/src/shared/data-path.ts @@ -10,6 +10,10 @@ import * as os from "node:os" * including Windows, so we match that behavior exactly. */ export function getDataDir(): string { + if (process.platform === "win32") { + return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local") + } + return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share") } @@ -27,6 +31,11 @@ export function getOpenCodeStorageDir(): string { * - All platforms: XDG_CACHE_HOME or ~/.cache */ export function getCacheDir(): string { + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local") + return path.join(localAppData, "cache") + } + return process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache") } diff --git a/src/shared/opencode-config-dir.test.ts b/src/shared/opencode-config-dir.test.ts index 159771fb..e3ee296d 100644 --- a/src/shared/opencode-config-dir.test.ts +++ b/src/shared/opencode-config-dir.test.ts @@ -179,7 +179,7 @@ describe("opencode-config-dir", () => { expect(result).toBe(join(homedir(), ".config", "opencode")) }) - test("returns ~/.config/opencode on Windows by default", () => { + test("returns %APPDATA%/opencode on Windows by default", () => { // given opencode CLI binary detected, platform is Windows Object.defineProperty(process, "platform", { value: "win32" }) delete process.env.APPDATA @@ -188,8 +188,8 @@ describe("opencode-config-dir", () => { // when getOpenCodeConfigDir is called with binary="opencode" const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200", checkExisting: false }) - // then returns ~/.config/opencode (cross-platform default) - expect(result).toBe(join(homedir(), ".config", "opencode")) + // then returns %APPDATA%/opencode + expect(result).toBe(join(homedir(), "AppData", "Roaming", "opencode")) }) }) diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts index 9fe2c6b0..8326fe7e 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -42,29 +42,32 @@ function getTauriConfigDir(identifier: string): string { } } -function getCliConfigDir(): string { +function getCliConfigDir(checkExisting = true): string { const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim() if (envConfigDir) { return resolve(envConfigDir) } if (process.platform === "win32") { - const crossPlatformDir = join(homedir(), ".config", "opencode") - const crossPlatformConfig = join(crossPlatformDir, "opencode.json") - - if (existsSync(crossPlatformConfig)) { - return crossPlatformDir - } - const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming") const appdataDir = join(appData, "opencode") + const crossPlatformDir = join(homedir(), ".config", "opencode") + if (!checkExisting) { + return appdataDir + } + const appdataConfig = join(appdataDir, "opencode.json") + const crossPlatformConfig = join(crossPlatformDir, "opencode.json") if (existsSync(appdataConfig)) { return appdataDir } - return crossPlatformDir + if (existsSync(crossPlatformConfig)) { + return crossPlatformDir + } + + return appdataDir } const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config") @@ -75,14 +78,14 @@ export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string const { binary, version, checkExisting = true } = options if (binary === "opencode") { - return getCliConfigDir() + return getCliConfigDir(checkExisting) } const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER const tauriDir = getTauriConfigDir(identifier) if (checkExisting) { - const legacyDir = getCliConfigDir() + const legacyDir = getCliConfigDir(true) const legacyConfig = join(legacyDir, "opencode.json") const legacyConfigC = join(legacyDir, "opencode.jsonc") diff --git a/src/shared/shell-env.test.ts b/src/shared/shell-env.test.ts index c0e53306..d2605a30 100644 --- a/src/shared/shell-env.test.ts +++ b/src/shared/shell-env.test.ts @@ -10,6 +10,7 @@ describe("shell-env", () => { originalEnv = { SHELL: process.env.SHELL, PSModulePath: process.env.PSModulePath, + COMSPEC: process.env.COMSPEC, } }) @@ -57,6 +58,7 @@ describe("shell-env", () => { test("#given Windows platform without PSModulePath #when detectShellType is called #then returns cmd", () => { delete process.env.PSModulePath delete process.env.SHELL + delete process.env.COMSPEC Object.defineProperty(process, "platform", { value: "win32" }) const result = detectShellType() @@ -77,12 +79,35 @@ describe("shell-env", () => { test("#given PSModulePath takes priority over SHELL #when both are set #then returns powershell", () => { process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" process.env.SHELL = "/bin/bash" + process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe" Object.defineProperty(process, "platform", { value: "win32" }) const result = detectShellType() expect(result).toBe("powershell") }) + + test("#given Windows COMSPEC points to powershell #when detectShellType is called #then returns powershell", () => { + delete process.env.PSModulePath + delete process.env.SHELL + process.env.COMSPEC = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + Object.defineProperty(process, "platform", { value: "win32" }) + + const result = detectShellType() + + expect(result).toBe("powershell") + }) + + test("#given Windows COMSPEC points to bash executable #when detectShellType is called #then returns unix", () => { + delete process.env.PSModulePath + delete process.env.SHELL + process.env.COMSPEC = "C:\\Program Files\\Git\\bin\\bash.exe" + Object.defineProperty(process, "platform", { value: "win32" }) + + const result = detectShellType() + + expect(result).toBe("unix") + }) }) describe("shellEscape", () => { diff --git a/src/shared/shell-env.ts b/src/shared/shell-env.ts index b074baf5..cd4ffe09 100644 --- a/src/shared/shell-env.ts +++ b/src/shared/shell-env.ts @@ -13,11 +13,29 @@ export function detectShellType(): ShellType { return "powershell" } + if (process.platform === "win32") { + const comspec = process.env.COMSPEC ?? process.env.ComSpec + const normalizedComspec = comspec?.toLowerCase() + if (normalizedComspec?.includes("powershell") || normalizedComspec?.includes("pwsh")) { + return "powershell" + } + + if (normalizedComspec?.includes("bash") || normalizedComspec?.includes("zsh")) { + return "unix" + } + + if (process.env.SHELL) { + return "unix" + } + + return "cmd" + } + if (process.env.SHELL) { return "unix" } - return process.platform === "win32" ? "cmd" : "unix" + return "unix" } /**