Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,35 @@ const readExistingJson = async (filePath: string): Promise<Record<string, unknow
}
};

export const writeAuthFile = async (payload: OAuthPayload): Promise<void> => {
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<string>();
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<void> => {
const authDir = path.dirname(authPath);
await mkdir(authDir, { recursive: true });

Expand All @@ -33,6 +60,14 @@ export const writeAuthFile = async (payload: OAuthPayload): Promise<void> => {
await writeFile(authPath, JSON.stringify(existing, null, 2), "utf8");
};

export const writeAuthFile = async (payload: OAuthPayload): Promise<void> => {
const { authPath, authCompatPaths } = getPaths();

for (const targetPath of dedupeAuthPaths([authPath, ...authCompatPaths])) {
await writeOpenCodeAuthFileAtPath(targetPath, payload);
}
};

export const writeCodexAuthFile = async (payload: OAuthPayload): Promise<void> => {
const { codexAuthPath } = getPaths();
const codexAuthDir = path.dirname(codexAuthPath);
Expand Down
8 changes: 8 additions & 0 deletions lib/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)}`
Expand Down
3 changes: 3 additions & 0 deletions lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type PathConfig = {
configDir: string;
configPath: string;
authPath: string;
authCompatPaths: string[];
codexAuthPath: string;
piAuthPath: string;
};
Expand All @@ -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,
});
Expand Down Expand Up @@ -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"),
});
43 changes: 42 additions & 1 deletion lib/platform/path-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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: {
Expand All @@ -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", () => {
Expand Down
24 changes: 21 additions & 3 deletions lib/platform/path-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type ResolvedPathValues = {
configDir: string;
configPath: string;
authPath: string;
authCompatPaths: string[];
codexAuthPath: string;
piAuthPath: string;
};
Expand Down Expand Up @@ -41,21 +42,37 @@ 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");

return {
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),
};
Expand All @@ -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"),
};
Expand Down
54 changes: 53 additions & 1 deletion lib/switch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down