Compare commits

...

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
6ba1d675b9 fix(installer): improve Windows compatibility for shell detection and paths
Closes #461
2026-02-24 21:42:04 +09:00
10 changed files with 234 additions and 18 deletions

View File

@@ -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<typeof Bun.spawn>)
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()
}
})
})

View File

@@ -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<boolean> {
const result = await runBunInstallWithDetails()
return result.success
@@ -16,7 +24,8 @@ export async function runBunInstall(): Promise<boolean> {
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
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<BunInstallResult> {
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<BunInstallResult> {
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`,
}
}
}

View File

@@ -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")
})
})

View File

@@ -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)
}

View File

@@ -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<string, string | undefined>
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"))
})
})

View File

@@ -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")
}

View File

@@ -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"))
})
})

View File

@@ -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")

View File

@@ -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", () => {

View File

@@ -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"
}
/**