From d7807072e1a99e2a098c8a7d6b2f8402b684e0ba Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 1 Feb 2026 19:42:37 +0900 Subject: [PATCH] feat(doctor): detect OpenCode desktop GUI installations on all platforms (#1352) * feat(doctor): detect OpenCode desktop GUI installations on all platforms - Add getDesktopAppPaths() returning platform-specific desktop app paths - macOS: /Applications/OpenCode.app, ~/Applications/OpenCode.app - Windows: C:\Program Files\OpenCode, %LOCALAPPDATA%\Programs\OpenCode - Linux: /opt/opencode, /snap/bin, ~/.local/bin - Add findDesktopBinary() for testable desktop path detection - Modify findOpenCodeBinary() to check desktop paths as fallback Fixes #1310 * fix: use verified installation paths from OpenCode source Verified paths from sst/opencode Tauri config: macOS: - /Applications/OpenCode.app/Contents/MacOS/OpenCode (capital C) Windows: - C:\Program Files\OpenCode\OpenCode.exe - %LOCALAPPDATA%\OpenCode\OpenCode.exe - Removed hardcoded paths, use ProgramFiles env var - Filter empty paths when env vars undefined Linux: - /usr/bin/opencode (deb symlink) - /usr/lib/opencode/opencode (deb actual binary) - ~/Applications/*.AppImage (user AppImage) - Removed non-existent /opt/opencode and /snap/bin paths * chore: remove unused imports from tests --- src/cli/doctor/checks/opencode.test.ts | 104 +++++++++++++++++++++++++ src/cli/doctor/checks/opencode.ts | 56 +++++++++++++ 2 files changed, 160 insertions(+) diff --git a/src/cli/doctor/checks/opencode.test.ts b/src/cli/doctor/checks/opencode.test.ts index 550c93609..1820a4554 100644 --- a/src/cli/doctor/checks/opencode.test.ts +++ b/src/cli/doctor/checks/opencode.test.ts @@ -224,4 +224,108 @@ describe("opencode check", () => { expect(typeof def.check).toBe("function") }) }) + + describe("getDesktopAppPaths", () => { + it("returns macOS desktop app paths for darwin platform", () => { + // given darwin platform + const platform: NodeJS.Platform = "darwin" + + // when getting desktop paths + const paths = opencode.getDesktopAppPaths(platform) + + // then should include macOS app bundle paths with correct binary name + expect(paths).toContain("/Applications/OpenCode.app/Contents/MacOS/OpenCode") + expect(paths.some((p) => p.includes("Applications/OpenCode.app"))).toBe(true) + }) + + it("returns Windows desktop app paths for win32 platform when env vars set", () => { + // given win32 platform with env vars set + const platform: NodeJS.Platform = "win32" + const originalProgramFiles = process.env.ProgramFiles + const originalLocalAppData = process.env.LOCALAPPDATA + process.env.ProgramFiles = "C:\\Program Files" + process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local" + + // when getting desktop paths + const paths = opencode.getDesktopAppPaths(platform) + + // then should include Windows program paths with correct binary name + expect(paths.some((p) => p.includes("Program Files"))).toBe(true) + expect(paths.some((p) => p.endsWith("OpenCode.exe"))).toBe(true) + expect(paths.every((p) => p.startsWith("C:\\"))).toBe(true) + + // cleanup + process.env.ProgramFiles = originalProgramFiles + process.env.LOCALAPPDATA = originalLocalAppData + }) + + it("returns empty array for win32 when all env vars undefined", () => { + // given win32 platform with no env vars + const platform: NodeJS.Platform = "win32" + const originalProgramFiles = process.env.ProgramFiles + const originalLocalAppData = process.env.LOCALAPPDATA + delete process.env.ProgramFiles + delete process.env.LOCALAPPDATA + + // when getting desktop paths + const paths = opencode.getDesktopAppPaths(platform) + + // then should return empty array (no relative paths) + expect(paths).toEqual([]) + + // cleanup + process.env.ProgramFiles = originalProgramFiles + process.env.LOCALAPPDATA = originalLocalAppData + }) + + it("returns Linux desktop app paths for linux platform", () => { + // given linux platform + const platform: NodeJS.Platform = "linux" + + // when getting desktop paths + const paths = opencode.getDesktopAppPaths(platform) + + // then should include verified Linux installation paths + expect(paths).toContain("/usr/bin/opencode") + expect(paths).toContain("/usr/lib/opencode/opencode") + expect(paths.some((p) => p.includes("AppImage"))).toBe(true) + }) + + it("returns empty array for unsupported platforms", () => { + // given unsupported platform + const platform = "freebsd" as NodeJS.Platform + + // when getting desktop paths + const paths = opencode.getDesktopAppPaths(platform) + + // then should return empty array + expect(paths).toEqual([]) + }) + }) + + describe("findOpenCodeBinary with desktop fallback", () => { + it("falls back to desktop paths when PATH binary not found", async () => { + // given no binary in PATH but desktop app exists + const existsSyncMock = (p: string) => + p === "/Applications/OpenCode.app/Contents/MacOS/OpenCode" + + // when finding binary with mocked filesystem + const result = await opencode.findDesktopBinary("darwin", existsSyncMock) + + // then should find desktop app + expect(result).not.toBeNull() + expect(result?.path).toBe("/Applications/OpenCode.app/Contents/MacOS/OpenCode") + }) + + it("returns null when no desktop binary found", async () => { + // given no binary exists + const existsSyncMock = () => false + + // when finding binary + const result = await opencode.findDesktopBinary("darwin", existsSyncMock) + + // then should return null + expect(result).toBeNull() + }) + }) }) diff --git a/src/cli/doctor/checks/opencode.ts b/src/cli/doctor/checks/opencode.ts index e06ea4dec..1bf91515a 100644 --- a/src/cli/doctor/checks/opencode.ts +++ b/src/cli/doctor/checks/opencode.ts @@ -1,8 +1,45 @@ +import { existsSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" 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 getDesktopAppPaths(platform: NodeJS.Platform): string[] { + const home = homedir() + + switch (platform) { + case "darwin": + return [ + "/Applications/OpenCode.app/Contents/MacOS/OpenCode", + join(home, "Applications", "OpenCode.app", "Contents", "MacOS", "OpenCode"), + ] + case "win32": { + const programFiles = process.env.ProgramFiles + const localAppData = process.env.LOCALAPPDATA + + const paths: string[] = [] + if (programFiles) { + paths.push(join(programFiles, "OpenCode", "OpenCode.exe")) + } + if (localAppData) { + paths.push(join(localAppData, "OpenCode", "OpenCode.exe")) + } + return paths + } + case "linux": + return [ + "/usr/bin/opencode", + "/usr/lib/opencode/opencode", + join(home, "Applications", "opencode-desktop-linux-x86_64.AppImage"), + join(home, "Applications", "opencode-desktop-linux-aarch64.AppImage"), + ] + default: + return [] + } +} + export function getBinaryLookupCommand(platform: NodeJS.Platform): "which" | "where" { return platform === "win32" ? "where" : "which" } @@ -52,6 +89,19 @@ export function buildVersionCommand( return [binaryPath, "--version"] } +export function findDesktopBinary( + platform: NodeJS.Platform = process.platform, + checkExists: (path: string) => boolean = existsSync +): { binary: string; path: string } | null { + const desktopPaths = getDesktopAppPaths(platform) + for (const desktopPath of desktopPaths) { + if (checkExists(desktopPath)) { + return { binary: "opencode", path: desktopPath } + } + } + return null +} + export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> { for (const binary of OPENCODE_BINARIES) { try { @@ -63,6 +113,12 @@ export async function findOpenCodeBinary(): Promise<{ binary: string; path: stri continue } } + + const desktopResult = findDesktopBinary() + if (desktopResult) { + return desktopResult + } + return null }