From 8bbe566730de155f89215266aa89d906e75d934a Mon Sep 17 00:00:00 2001 From: salee Date: Wed, 1 Jul 2026 19:30:53 +0900 Subject: [PATCH] Fix Windows OpenCode auth path --- lib/auth.ts | 39 +++++++++++++++++++-- lib/commands/doctor.ts | 8 +++++ lib/paths.ts | 3 ++ lib/platform/path-resolver.test.ts | 43 +++++++++++++++++++++++- lib/platform/path-resolver.ts | 24 +++++++++++-- lib/switch.test.ts | 54 +++++++++++++++++++++++++++++- 6 files changed, 164 insertions(+), 7 deletions(-) diff --git a/lib/auth.ts b/lib/auth.ts index 480ac13..d4fd035 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -15,8 +15,35 @@ const readExistingJson = async (filePath: string): Promise => { - const { authPath } = getPaths(); +const normalizeAuthPathKey = ( + authPath: string, + platform: NodeJS.Platform, +): string => { + const normalized = path.normalize(authPath); + return platform === "win32" ? normalized.toLowerCase() : normalized; +}; + +export const dedupeAuthPaths = ( + authPaths: string[], + platform: NodeJS.Platform = process.platform, +): string[] => { + const seen = new Set(); + const result: string[] = []; + + for (const authPath of authPaths) { + const key = normalizeAuthPathKey(authPath, platform); + if (seen.has(key)) continue; + seen.add(key); + result.push(authPath); + } + + return result; +}; + +const writeOpenCodeAuthFileAtPath = async ( + authPath: string, + payload: OAuthPayload, +): Promise => { const authDir = path.dirname(authPath); await mkdir(authDir, { recursive: true }); @@ -33,6 +60,14 @@ export const writeAuthFile = async (payload: OAuthPayload): Promise => { await writeFile(authPath, JSON.stringify(existing, null, 2), "utf8"); }; +export const writeAuthFile = async (payload: OAuthPayload): Promise => { + const { authPath, authCompatPaths } = getPaths(); + + for (const targetPath of dedupeAuthPaths([authPath, ...authCompatPaths])) { + await writeOpenCodeAuthFileAtPath(targetPath, payload); + } +}; + export const writeCodexAuthFile = async (payload: OAuthPayload): Promise => { const { codexAuthPath } = getPaths(); const codexAuthDir = path.dirname(codexAuthPath); diff --git a/lib/commands/doctor.ts b/lib/commands/doctor.ts index 7c4f599..b39502b 100644 --- a/lib/commands/doctor.ts +++ b/lib/commands/doctor.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { spawn } from "node:child_process"; import * as p from "@clack/prompts"; import type { Command } from "commander"; +import { dedupeAuthPaths } from "../auth"; import { getPaths } from "../paths"; import { getStatus } from "../status"; import { getKeychainDecryptAccessByServiceAsync } from "../keychain-acl"; @@ -795,6 +796,13 @@ export const registerDoctorCommand = (program: Command): void => { : "not found"; process.stdout.write(` OpenCode: ${ocStatus}\n`); process.stdout.write(` Path: ${paths.authPath}\n`); + const [, ...compatPaths] = dedupeAuthPaths([ + paths.authPath, + ...paths.authCompatPaths, + ]); + for (const compatPath of compatPaths) { + process.stdout.write(` Compatibility path: ${compatPath}\n`); + } const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` diff --git a/lib/paths.ts b/lib/paths.ts index 0be59ad..e6e65b2 100644 --- a/lib/paths.ts +++ b/lib/paths.ts @@ -10,6 +10,7 @@ export type PathConfig = { configDir: string; configPath: string; authPath: string; + authCompatPaths: string[]; codexAuthPath: string; piAuthPath: string; }; @@ -23,6 +24,7 @@ const toPathConfig = (paths: ResolvedPathValues): PathConfig => ({ configDir: paths.configDir, configPath: paths.configPath, authPath: paths.authPath, + authCompatPaths: paths.authCompatPaths, codexAuthPath: paths.codexAuthPath, piAuthPath: paths.piAuthPath, }); @@ -71,6 +73,7 @@ export const createTestPaths = (testDir: string): PathConfig => ({ configDir: path.join(testDir, "config"), configPath: path.join(testDir, "config", "accounts.json"), authPath: path.join(testDir, "auth", "auth.json"), + authCompatPaths: [], codexAuthPath: path.join(testDir, "codex", "auth.json"), piAuthPath: path.join(testDir, "pi", "auth.json"), }); diff --git a/lib/platform/path-resolver.test.ts b/lib/platform/path-resolver.test.ts index 3ed4412..9fef054 100644 --- a/lib/platform/path-resolver.test.ts +++ b/lib/platform/path-resolver.test.ts @@ -16,6 +16,7 @@ describe("resolveRuntimePaths", () => { expect(resolved.authPath).toBe( path.join(home, ".local", "share", "opencode", "auth.json"), ); + expect(resolved.authCompatPaths).toEqual([]); }); it("respects XDG env overrides", () => { @@ -33,9 +34,10 @@ describe("resolveRuntimePaths", () => { expect(resolved.configDir).toBe(path.join(configHome, "cdx")); expect(resolved.authPath).toBe(path.join(dataHome, "opencode", "auth.json")); + expect(resolved.authCompatPaths).toEqual([]); }); - it("uses APPDATA/LOCALAPPDATA on win32", () => { + it("uses XDG data default for OpenCode and LOCALAPPDATA compatibility on win32", () => { const resolved = resolveRuntimePaths({ platform: "win32", env: { @@ -49,9 +51,48 @@ describe("resolveRuntimePaths", () => { expect(resolved.configDir).toBe( "C:\\Users\\tester\\AppData\\Roaming\\cdx", ); + expect(resolved.authPath).toBe( + "C:\\Users\\tester\\.local\\share\\opencode\\auth.json", + ); + expect(resolved.authCompatPaths).toEqual([ + "C:\\Users\\tester\\AppData\\Local\\opencode\\auth.json", + ]); + }); + + it("respects XDG_DATA_HOME for OpenCode auth on win32", () => { + const resolved = resolveRuntimePaths({ + platform: "win32", + env: { + APPDATA: "C:\\Users\\tester\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\tester\\AppData\\Local", + XDG_DATA_HOME: "D:\\xdg-data", + }, + homeDir: "C:\\Users\\tester", + }); + + expect(resolved.authPath).toBe("D:\\xdg-data\\opencode\\auth.json"); + expect(resolved.authCompatPaths).toEqual([ + "C:\\Users\\tester\\AppData\\Local\\opencode\\auth.json", + ]); + }); + + it("can resolve the same win32 primary and compatibility OpenCode auth path", () => { + const resolved = resolveRuntimePaths({ + platform: "win32", + env: { + APPDATA: "C:\\Users\\tester\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\tester\\AppData\\Local", + XDG_DATA_HOME: "C:\\Users\\tester\\AppData\\Local", + }, + homeDir: "C:\\Users\\tester", + }); + expect(resolved.authPath).toBe( "C:\\Users\\tester\\AppData\\Local\\opencode\\auth.json", ); + expect(resolved.authCompatPaths).toEqual([ + "C:\\Users\\tester\\AppData\\Local\\opencode\\auth.json", + ]); }); it("respects PI_CODING_AGENT_DIR override", () => { diff --git a/lib/platform/path-resolver.ts b/lib/platform/path-resolver.ts index 03c1435..fcba84d 100644 --- a/lib/platform/path-resolver.ts +++ b/lib/platform/path-resolver.ts @@ -13,6 +13,7 @@ export type ResolvedPathValues = { configDir: string; configPath: string; authPath: string; + authCompatPaths: string[]; codexAuthPath: string; piAuthPath: string; }; @@ -41,13 +42,28 @@ const resolvePiAuthPath = ( : path.join(homeDir, ".pi", "agent", "auth.json"); }; +const resolveOpenCodeAuthPath = ( + env: NodeJS.ProcessEnv, + homeDir: string, + platform: NodeJS.Platform, +): string => { + const xdgDataHome = envValue(env, "XDG_DATA_HOME"); + + if (platform === "win32") { + const dataHome = xdgDataHome ?? path.win32.join(homeDir, ".local", "share"); + return path.win32.join(dataHome, "opencode", "auth.json"); + } + + const dataHome = xdgDataHome ?? path.join(homeDir, ".local", "share"); + return path.join(dataHome, "opencode", "auth.json"); +}; + const resolveXdgPaths = ( env: NodeJS.ProcessEnv, homeDir: string, platform: NodeJS.Platform, ): ResolvedPathValues => { const configHome = envValue(env, "XDG_CONFIG_HOME") ?? path.join(homeDir, ".config"); - const dataHome = envValue(env, "XDG_DATA_HOME") ?? path.join(homeDir, ".local", "share"); const configDir = path.join(configHome, "cdx"); @@ -55,7 +71,8 @@ const resolveXdgPaths = ( profile: "xdg", configDir, configPath: path.join(configDir, "accounts.json"), - authPath: path.join(dataHome, "opencode", "auth.json"), + authPath: resolveOpenCodeAuthPath(env, homeDir, platform), + authCompatPaths: [], codexAuthPath: path.join(homeDir, ".codex", "auth.json"), piAuthPath: resolvePiAuthPath(env, homeDir, platform), }; @@ -74,7 +91,8 @@ const resolveWindowsPaths = (env: NodeJS.ProcessEnv, homeDir: string): ResolvedP profile: "windows-appdata", configDir, configPath: winPath.join(configDir, "accounts.json"), - authPath: winPath.join(localAppData, "opencode", "auth.json"), + authPath: resolveOpenCodeAuthPath(env, homeDir, "win32"), + authCompatPaths: [winPath.join(localAppData, "opencode", "auth.json")], codexAuthPath: winPath.join(homeDir, ".codex", "auth.json"), piAuthPath: resolvePiAuthPath(env, homeDir, "win32"), }; diff --git a/lib/switch.test.ts b/lib/switch.test.ts index 519bcd9..d8f704a 100644 --- a/lib/switch.test.ts +++ b/lib/switch.test.ts @@ -3,7 +3,13 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile } from "./auth"; +import { + dedupeAuthPaths, + writeAllAuthFiles, + writeAuthFile, + writeCodexAuthFile, + writePiAuthFile, +} from "./auth"; import { loadConfig, saveConfig } from "./config"; import { createTestPaths, getPaths, resetPaths, setPaths } from "./paths"; import { writeActiveAuthFilesIfCurrent } from "./refresh"; @@ -134,6 +140,52 @@ describe("switch command utilities", () => { expect(parsed.openai.type).toBe("oauth"); expect(parsed.openai.accountId).toBe(TEST_PAYLOAD_1.accountId); }); + + it("writes OpenCode auth to compatibility paths while preserving each file", async () => { + const paths = getPaths(); + const compatPath = path.join(testDir, "compat", "opencode", "auth.json"); + setPaths({ authCompatPaths: [compatPath] }); + + mkdirSync(path.dirname(paths.authPath), { recursive: true }); + mkdirSync(path.dirname(compatPath), { recursive: true }); + await writeFile( + paths.authPath, + JSON.stringify({ "opencode-go": { type: "api", key: "keep-primary" } }, null, 2), + "utf8", + ); + await writeFile( + compatPath, + JSON.stringify({ openrouter: { type: "api", key: "keep-compat" } }, null, 2), + "utf8", + ); + + await writeAuthFile(TEST_PAYLOAD_1); + + const primary = JSON.parse(await readFile(paths.authPath, "utf8")); + const compat = JSON.parse(await readFile(compatPath, "utf8")); + + expect(primary["opencode-go"].key).toBe("keep-primary"); + expect(primary.openai.accountId).toBe(TEST_PAYLOAD_1.accountId); + expect(compat.openrouter.key).toBe("keep-compat"); + expect(compat.openai.accountId).toBe(TEST_PAYLOAD_1.accountId); + }); + + it("deduplicates OpenCode auth write paths", () => { + const authPath = path.join(testDir, "auth", "auth.json"); + + expect(dedupeAuthPaths([authPath, authPath])).toEqual([authPath]); + }); + + it("deduplicates Windows OpenCode auth paths case-insensitively", () => { + const authPath = "C:\\Users\\alice\\AppData\\Local\\opencode\\auth.json"; + + expect( + dedupeAuthPaths( + [authPath, "c:\\users\\alice\\appdata\\local\\opencode\\auth.json"], + "win32", + ), + ).toEqual([authPath]); + }); }); describe("writeCodexAuthFile", () => {