From 00b8f622d57354211246b6135f6edc9a12f400b3 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 3 Jan 2026 14:00:36 +0900 Subject: [PATCH] feat(installer): add opencode-desktop compatibility with dynamic config paths (#442) The installer now dynamically detects and uses the appropriate config directory based on whether opencode CLI or opencode-desktop (Tauri) is being used: - opencode CLI: ~/.config/opencode/ (all platforms) - opencode-desktop on Linux: ~/.config/ai.opencode.desktop/ - opencode-desktop on macOS: ~/Library/Application Support/ai.opencode.desktop/ - opencode-desktop on Windows: %APPDATA%/ai.opencode.desktop/ Key changes: - Add new opencode-config-dir.ts module with platform-specific path resolution - Support dev builds with ai.opencode.desktop.dev identifier - Backward compatibility: checks legacy ~/.config/opencode/ first - Refactor config-manager.ts to use dynamic paths via config context - Update doctor plugin check to use shared path utilities Fixes #440 Co-authored-by: sisyphus-dev-ai --- src/cli/config-manager.ts | 144 +++++++++++----- src/cli/doctor/checks/plugin.ts | 21 +-- src/shared/index.ts | 1 + src/shared/opencode-config-dir.test.ts | 224 +++++++++++++++++++++++++ src/shared/opencode-config-dir.ts | 132 +++++++++++++++ 5 files changed, 465 insertions(+), 57 deletions(-) create mode 100644 src/shared/opencode-config-dir.test.ts create mode 100644 src/shared/opencode-config-dir.ts diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index ec9c127..be93a48 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -1,17 +1,60 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs" -import { homedir } from "node:os" import { join } from "node:path" -import { parseJsonc } from "../shared" +import { + parseJsonc, + getOpenCodeConfigPaths, + type OpenCodeBinaryType, + type OpenCodeConfigPaths, +} from "../shared" import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types" -const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") -const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json") -const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc") -const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json") -const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json") - const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const +interface ConfigContext { + binary: OpenCodeBinaryType + version: string | null + paths: OpenCodeConfigPaths +} + +let configContext: ConfigContext | null = null + +export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void { + const paths = getOpenCodeConfigPaths({ binary, version }) + configContext = { binary, version, paths } +} + +export function getConfigContext(): ConfigContext { + if (!configContext) { + const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) + configContext = { binary: "opencode", version: null, paths } + } + return configContext +} + +export function resetConfigContext(): void { + configContext = null +} + +function getConfigDir(): string { + return getConfigContext().paths.configDir +} + +function getConfigJson(): string { + return getConfigContext().paths.configJson +} + +function getConfigJsonc(): string { + return getConfigContext().paths.configJsonc +} + +function getPackageJson(): string { + return getConfigContext().paths.packageJson +} + +function getOmoConfig(): string { + return getConfigContext().paths.omoConfig +} + const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools" const BUN_INSTALL_TIMEOUT_SECONDS = 60 @@ -76,13 +119,16 @@ interface OpenCodeConfig { } export function detectConfigFormat(): { format: ConfigFormat; path: string } { - if (existsSync(OPENCODE_JSONC)) { - return { format: "jsonc", path: OPENCODE_JSONC } + const configJsonc = getConfigJsonc() + const configJson = getConfigJson() + + if (existsSync(configJsonc)) { + return { format: "jsonc", path: configJsonc } } - if (existsSync(OPENCODE_JSON)) { - return { format: "json", path: OPENCODE_JSON } + if (existsSync(configJson)) { + return { format: "json", path: configJson } } - return { format: "none", path: OPENCODE_JSON } + return { format: "none", path: configJson } } interface ParseConfigResult { @@ -129,8 +175,9 @@ function parseConfigWithError(path: string): ParseConfigResult { } function ensureConfigDir(): void { - if (!existsSync(OPENCODE_CONFIG_DIR)) { - mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }) + const configDir = getConfigDir() + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) } } @@ -138,7 +185,7 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult { try { ensureConfigDir() } catch (err) { - return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") } + return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } } const { format, path } = detectConfigFormat() @@ -270,50 +317,52 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult try { ensureConfigDir() } catch (err) { - return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") } + return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } } + const omoConfigPath = getOmoConfig() + try { const newConfig = generateOmoConfig(installConfig) - if (existsSync(OMO_CONFIG)) { + if (existsSync(omoConfigPath)) { try { - const stat = statSync(OMO_CONFIG) - const content = readFileSync(OMO_CONFIG, "utf-8") + const stat = statSync(omoConfigPath) + const content = readFileSync(omoConfigPath, "utf-8") if (stat.size === 0 || isEmptyOrWhitespace(content)) { - writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: OMO_CONFIG } + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } } const existing = parseJsonc>(content) if (!existing || typeof existing !== "object" || Array.isArray(existing)) { - writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: OMO_CONFIG } + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } } delete existing.agents const merged = deepMerge(existing, newConfig) - writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n") + writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n") } catch (parseErr) { if (parseErr instanceof SyntaxError) { - writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n") - return { success: true, configPath: OMO_CONFIG } + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") + return { success: true, configPath: omoConfigPath } } throw parseErr } } else { - writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n") + writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n") } - return { success: true, configPath: OMO_CONFIG } + return { success: true, configPath: omoConfigPath } } catch (err) { - return { success: false, configPath: OMO_CONFIG, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") } + return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") } } } interface OpenCodeBinaryResult { - binary: string + binary: OpenCodeBinaryType version: string } @@ -327,7 +376,9 @@ async function findOpenCodeBinaryWithVersion(): Promise = {} - if (existsSync(OPENCODE_PACKAGE_JSON)) { + if (existsSync(packageJsonPath)) { try { - const stat = statSync(OPENCODE_PACKAGE_JSON) - const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8") + const stat = statSync(packageJsonPath) + const content = readFileSync(packageJsonPath, "utf-8") if (stat.size > 0 && !isEmptyOrWhitespace(content)) { packageJson = JSON.parse(content) @@ -423,10 +476,10 @@ export function setupChatGPTHotfix(): ConfigMergeResult { deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO packageJson.dependencies = deps - writeFileSync(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n") - return { success: true, configPath: OPENCODE_PACKAGE_JSON } + writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n") + return { success: true, configPath: packageJsonPath } } catch (err) { - return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") } + return { success: false, configPath: packageJsonPath, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") } } } @@ -444,7 +497,7 @@ export async function runBunInstall(): Promise { export async function runBunInstallWithDetails(): Promise { try { const proc = Bun.spawn(["bun", "install"], { - cwd: OPENCODE_CONFIG_DIR, + cwd: getConfigDir(), stdout: "pipe", stderr: "pipe", }) @@ -548,7 +601,7 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult { try { ensureConfigDir() } catch (err) { - return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") } + return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") } } const { format, path } = detectConfigFormat() @@ -622,17 +675,18 @@ export function detectCurrentConfig(): DetectedConfig { result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth")) - if (!existsSync(OMO_CONFIG)) { + const omoConfigPath = getOmoConfig() + if (!existsSync(omoConfigPath)) { return result } try { - const stat = statSync(OMO_CONFIG) + const stat = statSync(omoConfigPath) if (stat.size === 0) { return result } - const content = readFileSync(OMO_CONFIG, "utf-8") + const content = readFileSync(omoConfigPath, "utf-8") if (isEmptyOrWhitespace(content)) { return result } diff --git a/src/cli/doctor/checks/plugin.ts b/src/cli/doctor/checks/plugin.ts index 05a0f85..5bfc063 100644 --- a/src/cli/doctor/checks/plugin.ts +++ b/src/cli/doctor/checks/plugin.ts @@ -1,20 +1,16 @@ import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" import type { CheckResult, CheckDefinition, PluginInfo } from "../types" import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" -import { parseJsonc } from "../../../shared" - -const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") -const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json") -const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc") +import { parseJsonc, getOpenCodeConfigPaths } from "../../../shared" function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null { - if (existsSync(OPENCODE_JSONC)) { - return { path: OPENCODE_JSONC, format: "jsonc" } + const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) + + if (existsSync(paths.configJsonc)) { + return { path: paths.configJsonc, format: "jsonc" } } - if (existsSync(OPENCODE_JSON)) { - return { path: OPENCODE_JSON, format: "json" } + if (existsSync(paths.configJson)) { + return { path: paths.configJson, format: "json" } } return null } @@ -81,13 +77,14 @@ export async function checkPluginRegistration(): Promise { const info = getPluginInfo() if (!info.configPath) { + const expectedPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) return { name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], status: "fail", message: "OpenCode config file not found", details: [ "Run: bunx oh-my-opencode install", - `Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`, + `Expected: ${expectedPaths.configJson} or ${expectedPaths.configJsonc}`, ], } } diff --git a/src/shared/index.ts b/src/shared/index.ts index d39fc94..2eef06b 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -16,3 +16,4 @@ export * from "./config-errors" export * from "./claude-config-dir" export * from "./jsonc-parser" export * from "./migration" +export * from "./opencode-config-dir" diff --git a/src/shared/opencode-config-dir.test.ts b/src/shared/opencode-config-dir.test.ts new file mode 100644 index 0000000..792a8ca --- /dev/null +++ b/src/shared/opencode-config-dir.test.ts @@ -0,0 +1,224 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { homedir } from "node:os" +import { join } from "node:path" +import { + getOpenCodeConfigDir, + getOpenCodeConfigPaths, + isDevBuild, + detectExistingConfigDir, + TAURI_APP_IDENTIFIER, + TAURI_APP_IDENTIFIER_DEV, +} from "./opencode-config-dir" + +describe("opencode-config-dir", () => { + let originalPlatform: NodeJS.Platform + let originalEnv: Record + + beforeEach(() => { + originalPlatform = process.platform + originalEnv = { + APPDATA: process.env.APPDATA, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + XDG_DATA_HOME: process.env.XDG_DATA_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] + } + } + }) + + describe("isDevBuild", () => { + test("returns false for null version", () => { + expect(isDevBuild(null)).toBe(false) + }) + + test("returns false for undefined version", () => { + expect(isDevBuild(undefined)).toBe(false) + }) + + test("returns false for production version", () => { + expect(isDevBuild("1.0.200")).toBe(false) + expect(isDevBuild("2.1.0")).toBe(false) + }) + + test("returns true for version containing -dev", () => { + expect(isDevBuild("1.0.0-dev")).toBe(true) + expect(isDevBuild("1.0.0-dev.123")).toBe(true) + }) + + test("returns true for version containing .dev", () => { + expect(isDevBuild("1.0.0.dev")).toBe(true) + expect(isDevBuild("1.0.0.dev.456")).toBe(true) + }) + }) + + describe("getOpenCodeConfigDir", () => { + describe("for opencode CLI binary", () => { + test("returns ~/.config/opencode on Linux", () => { + // #given opencode CLI binary detected, platform is Linux + Object.defineProperty(process, "platform", { value: "linux" }) + delete process.env.XDG_CONFIG_HOME + + // #when getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then returns ~/.config/opencode + expect(result).toBe(join(homedir(), ".config", "opencode")) + }) + + test("returns $XDG_CONFIG_HOME/opencode on Linux when XDG_CONFIG_HOME is set", () => { + // #given opencode CLI binary detected, platform is Linux with XDG_CONFIG_HOME set + Object.defineProperty(process, "platform", { value: "linux" }) + process.env.XDG_CONFIG_HOME = "/custom/config" + + // #when getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then returns $XDG_CONFIG_HOME/opencode + expect(result).toBe("/custom/config/opencode") + }) + + test("returns ~/.config/opencode on macOS", () => { + // #given opencode CLI binary detected, platform is macOS + Object.defineProperty(process, "platform", { value: "darwin" }) + delete process.env.XDG_CONFIG_HOME + + // #when getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then returns ~/.config/opencode + expect(result).toBe(join(homedir(), ".config", "opencode")) + }) + + test("returns ~/.config/opencode on Windows by default", () => { + // #given opencode CLI binary detected, platform is Windows + Object.defineProperty(process, "platform", { value: "win32" }) + delete process.env.APPDATA + + // #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")) + }) + }) + + describe("for opencode-desktop Tauri binary", () => { + test("returns ~/.config/ai.opencode.desktop on Linux", () => { + // #given opencode-desktop binary detected, platform is Linux + Object.defineProperty(process, "platform", { value: "linux" }) + delete process.env.XDG_CONFIG_HOME + + // #when getOpenCodeConfigDir is called with binary="opencode-desktop" + const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false }) + + // #then returns ~/.config/ai.opencode.desktop + expect(result).toBe(join(homedir(), ".config", TAURI_APP_IDENTIFIER)) + }) + + test("returns ~/Library/Application Support/ai.opencode.desktop on macOS", () => { + // #given opencode-desktop binary detected, platform is macOS + Object.defineProperty(process, "platform", { value: "darwin" }) + + // #when getOpenCodeConfigDir is called with binary="opencode-desktop" + const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false }) + + // #then returns ~/Library/Application Support/ai.opencode.desktop + expect(result).toBe(join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER)) + }) + + test("returns %APPDATA%/ai.opencode.desktop on Windows", () => { + // #given opencode-desktop binary detected, platform is Windows + Object.defineProperty(process, "platform", { value: "win32" }) + process.env.APPDATA = "C:\\Users\\TestUser\\AppData\\Roaming" + + // #when getOpenCodeConfigDir is called with binary="opencode-desktop" + const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false }) + + // #then returns %APPDATA%/ai.opencode.desktop + expect(result).toBe(join("C:\\Users\\TestUser\\AppData\\Roaming", TAURI_APP_IDENTIFIER)) + }) + }) + + describe("dev build detection", () => { + test("returns ai.opencode.desktop.dev path when dev version detected", () => { + // #given opencode-desktop dev version + Object.defineProperty(process, "platform", { value: "linux" }) + delete process.env.XDG_CONFIG_HOME + + // #when getOpenCodeConfigDir is called with dev version + const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.0-dev.123", checkExisting: false }) + + // #then returns path with ai.opencode.desktop.dev + expect(result).toBe(join(homedir(), ".config", TAURI_APP_IDENTIFIER_DEV)) + }) + + test("returns ai.opencode.desktop.dev on macOS for dev build", () => { + // #given opencode-desktop dev version on macOS + Object.defineProperty(process, "platform", { value: "darwin" }) + + // #when getOpenCodeConfigDir is called with dev version + const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.0-dev", checkExisting: false }) + + // #then returns path with ai.opencode.desktop.dev + expect(result).toBe(join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER_DEV)) + }) + }) + }) + + describe("getOpenCodeConfigPaths", () => { + test("returns all config paths for CLI binary", () => { + // #given opencode CLI binary on Linux + Object.defineProperty(process, "platform", { value: "linux" }) + delete process.env.XDG_CONFIG_HOME + + // #when getOpenCodeConfigPaths is called + const paths = getOpenCodeConfigPaths({ binary: "opencode", version: "1.0.200" }) + + // #then returns all expected paths + const expectedDir = join(homedir(), ".config", "opencode") + expect(paths.configDir).toBe(expectedDir) + expect(paths.configJson).toBe(join(expectedDir, "opencode.json")) + expect(paths.configJsonc).toBe(join(expectedDir, "opencode.jsonc")) + expect(paths.packageJson).toBe(join(expectedDir, "package.json")) + expect(paths.omoConfig).toBe(join(expectedDir, "oh-my-opencode.json")) + }) + + test("returns all config paths for desktop binary", () => { + // #given opencode-desktop binary on macOS + Object.defineProperty(process, "platform", { value: "darwin" }) + + // #when getOpenCodeConfigPaths is called + const paths = getOpenCodeConfigPaths({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false }) + + // #then returns all expected paths + const expectedDir = join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER) + expect(paths.configDir).toBe(expectedDir) + expect(paths.configJson).toBe(join(expectedDir, "opencode.json")) + expect(paths.configJsonc).toBe(join(expectedDir, "opencode.jsonc")) + expect(paths.packageJson).toBe(join(expectedDir, "package.json")) + expect(paths.omoConfig).toBe(join(expectedDir, "oh-my-opencode.json")) + }) + }) + + describe("detectExistingConfigDir", () => { + test("returns null when no config exists", () => { + // #given no config files exist + Object.defineProperty(process, "platform", { value: "linux" }) + delete process.env.XDG_CONFIG_HOME + + // #when detectExistingConfigDir is called + const result = detectExistingConfigDir("opencode", "1.0.200") + + // #then result is either null or a valid string path + expect(result === null || typeof result === "string").toBe(true) + }) + }) +}) diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts new file mode 100644 index 0000000..3a11ee9 --- /dev/null +++ b/src/shared/opencode-config-dir.ts @@ -0,0 +1,132 @@ +import { existsSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +export type OpenCodeBinaryType = "opencode" | "opencode-desktop" + +export interface OpenCodeConfigDirOptions { + binary: OpenCodeBinaryType + version?: string | null + checkExisting?: boolean +} + +export interface OpenCodeConfigPaths { + configDir: string + configJson: string + configJsonc: string + packageJson: string + omoConfig: string +} + +export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop" +export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev" + +export function isDevBuild(version: string | null | undefined): boolean { + if (!version) return false + return version.includes("-dev") || version.includes(".dev") +} + +function getTauriConfigDir(identifier: string): string { + const platform = process.platform + + switch (platform) { + case "darwin": + return join(homedir(), "Library", "Application Support", identifier) + + case "win32": { + const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming") + return join(appData, identifier) + } + + case "linux": + default: { + const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config") + return join(xdgConfig, identifier) + } + } +} + +function getCliConfigDir(): string { + 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 appdataConfig = join(appdataDir, "opencode.json") + + if (existsSync(appdataConfig)) { + return appdataDir + } + + return crossPlatformDir + } + + const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config") + return join(xdgConfig, "opencode") +} + +export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string { + const { binary, version, checkExisting = true } = options + + if (binary === "opencode") { + return getCliConfigDir() + } + + const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER + const tauriDir = getTauriConfigDir(identifier) + + if (checkExisting) { + const legacyDir = getCliConfigDir() + const legacyConfig = join(legacyDir, "opencode.json") + const legacyConfigC = join(legacyDir, "opencode.jsonc") + + if (existsSync(legacyConfig) || existsSync(legacyConfigC)) { + return legacyDir + } + } + + return tauriDir +} + +export function getOpenCodeConfigPaths(options: OpenCodeConfigDirOptions): OpenCodeConfigPaths { + const configDir = getOpenCodeConfigDir(options) + + return { + configDir, + configJson: join(configDir, "opencode.json"), + configJsonc: join(configDir, "opencode.jsonc"), + packageJson: join(configDir, "package.json"), + omoConfig: join(configDir, "oh-my-opencode.json"), + } +} + +export function detectExistingConfigDir(binary: OpenCodeBinaryType, version?: string | null): string | null { + const locations: string[] = [] + + if (binary === "opencode-desktop") { + const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER + locations.push(getTauriConfigDir(identifier)) + + if (isDevBuild(version)) { + locations.push(getTauriConfigDir(TAURI_APP_IDENTIFIER)) + } + } + + locations.push(getCliConfigDir()) + + for (const dir of locations) { + const configJson = join(dir, "opencode.json") + const configJsonc = join(dir, "opencode.jsonc") + + if (existsSync(configJson) || existsSync(configJsonc)) { + return dir + } + } + + return null +}