diff --git a/src/commands/code/__tests__/auth-sync.test.ts b/src/commands/code/__tests__/auth-sync.test.ts index 68c17c8..e449fbc 100644 --- a/src/commands/code/__tests__/auth-sync.test.ts +++ b/src/commands/code/__tests__/auth-sync.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { decodeJwtPayload, hasBergetCodeSeat } from '../../../auth/jwt.js'; import { @@ -17,6 +17,26 @@ import { FakeAuthService } from './fake-auth-service.js'; import { FakeFileStore } from './fake-file-store.js'; import { confirm, FakePrompter, select } from './fake-prompter.js'; +const ENV_KEYS = [ + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'OPENCODE_CONFIG', + 'OPENCODE_CONFIG_DIR', + 'PI_CODING_AGENT_DIR', +]; + +beforeEach(() => { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +}); + function base64urlEncode(data: string): string { return Buffer.from(data).toString('base64url'); } diff --git a/src/commands/code/__tests__/init.test.ts b/src/commands/code/__tests__/init.test.ts index 2c3337a..dea07d4 100644 --- a/src/commands/code/__tests__/init.test.ts +++ b/src/commands/code/__tests__/init.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ApiKeyServicePort, AuthServicePort } from '../ports/auth-services.js'; import type { CommandRunner } from '../ports/command-runner.js'; @@ -11,6 +11,26 @@ import { FakeCommandRunner } from './fake-command-runner.js'; import { FakeFileStore } from './fake-file-store.js'; import { CANCEL, confirm, FakePrompter, multiselect, select } from './fake-prompter.js'; +const ENV_KEYS = [ + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'OPENCODE_CONFIG', + 'OPENCODE_CONFIG_DIR', + 'PI_CODING_AGENT_DIR', +]; + +beforeEach(() => { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +}); + const makeDeps = ( overrides: Partial[0]> = {}, ): Parameters[0] => { diff --git a/src/commands/code/auth-sync.ts b/src/commands/code/auth-sync.ts index 0ed2231..23b7fdf 100644 --- a/src/commands/code/auth-sync.ts +++ b/src/commands/code/auth-sync.ts @@ -10,6 +10,7 @@ import { } from '../../auth/jwt.js'; import { logger } from '../../utils/logger.js'; import { FatalError } from './errors.js'; +import { getOpencodeAuthPath } from './xdg-paths.js'; export interface AuthDeps { apiKeyService: ApiKeyServicePort; @@ -32,7 +33,7 @@ export interface CliAuth { const CLI_AUTH_PATH = (homeDir: string) => homeDir + '/.berget/auth.json'; const TOOL_AUTH_PATHS = { - opencode: (homeDir: string) => homeDir + '/.local/share/opencode/auth.json', + opencode: getOpencodeAuthPath, pi: (homeDir: string) => homeDir + '/.pi/agent/auth.json', } as const; diff --git a/src/commands/code/opencode.ts b/src/commands/code/opencode.ts index 6bc92ce..d5a1b1c 100644 --- a/src/commands/code/opencode.ts +++ b/src/commands/code/opencode.ts @@ -8,6 +8,7 @@ import type { Prompter } from './ports/prompter.js'; import { getAllAgents, toMarkdown } from '../../agents/index.js'; import { CancelledError } from './errors.js'; import { readJsonMaybe } from './utils.js'; +import { getOpencodeConfigDir, resolveGlobalConfigPath } from './xdg-paths.js'; const OPENCODE_PLUGIN = '@bergetai/opencode-auth@1.0.24'; const OPENCODE_PLUGIN_NAME = '@bergetai/opencode-auth'; @@ -33,14 +34,9 @@ export async function getOpencodeState( ): Promise<{ global: boolean; project: boolean }> { const projectJsonc = await readJsonMaybe(files, path.join(cwd, 'opencode.jsonc')); const projectJson = await readJsonMaybe(files, path.join(cwd, 'opencode.json')); - const globalJsonc = await readJsonMaybe( - files, - path.join(homeDir, '.config', 'opencode', 'opencode.jsonc'), - ); - const globalJson = await readJsonMaybe( - files, - path.join(homeDir, '.config', 'opencode', 'opencode.json'), - ); + const globalDir = getOpencodeConfigDir(homeDir); + const globalJsonc = await readJsonMaybe(files, path.join(globalDir, 'opencode.jsonc')); + const globalJson = await readJsonMaybe(files, path.join(globalDir, 'opencode.json')); return { global: hasPluginInConfig(globalJsonc) || hasPluginInConfig(globalJson), @@ -102,7 +98,7 @@ export async function initOpenCodeAgents(deps: { const agentsDir = scope === 'project' ? path.join(cwd, '.opencode', 'agents') - : path.join(homeDir, '.config', 'opencode', 'agents'); + : path.join(getOpencodeConfigDir(homeDir), 'agents'); prompter.note('Space to toggle, Enter to confirm.', 'Agent Setup'); @@ -273,10 +269,5 @@ async function resolveOpencodeConfigPath( return jsonPath; } - const globalDir = path.join(homeDir, '.config', 'opencode'); - const jsoncPath = path.join(globalDir, 'opencode.jsonc'); - const jsonPath = path.join(globalDir, 'opencode.json'); - if (await files.exists(jsoncPath)) return jsoncPath; - if (await files.exists(jsonPath)) return jsonPath; - return jsonPath; + return resolveGlobalConfigPath(homeDir, (p) => files.exists(p)); } diff --git a/src/commands/code/xdg-paths.ts b/src/commands/code/xdg-paths.ts new file mode 100644 index 0000000..cf03906 --- /dev/null +++ b/src/commands/code/xdg-paths.ts @@ -0,0 +1,54 @@ +import * as path from 'node:path'; + +/** + * Resolve the path to the OpenCode auth file. + */ +export function getOpencodeAuthPath(homeDir: string): string { + return path.join(getOpencodeDataDir(homeDir), 'auth.json'); +} + +/** + * Resolve the OpenCode global config directory. + * Honors XDG_CONFIG_HOME, then falls back to ~/.config/opencode. + * Also honors OPENCODE_CONFIG_DIR if set (highest priority). + */ +export function getOpencodeConfigDir(homeDir: string): string { + const envDir = process.env.OPENCODE_CONFIG_DIR; + if (envDir) { + return envDir; + } + const xdgConfig = process.env.XDG_CONFIG_HOME; + return path.join(xdgConfig || path.join(homeDir, '.config'), 'opencode'); +} + +/** + * Resolve the OpenCode data directory (for auth.json, sessions, etc.). + * Honors XDG_DATA_HOME, then falls back to ~/.local/share/opencode. + */ +export function getOpencodeDataDir(homeDir: string): string { + const xdgData = process.env.XDG_DATA_HOME; + return path.join(xdgData || path.join(homeDir, '.local', 'share'), 'opencode'); +} + +/** + * Resolve the path to the global opencode config file. + * Honors OPENCODE_CONFIG (single-file override), then probes + * OPENCODE_CONFIG_DIR / XDG_CONFIG_HOME / ~/.config. + */ +export async function resolveGlobalConfigPath( + homeDir: string, + exists: (p: string) => Promise, +): Promise { + const envConfig = process.env.OPENCODE_CONFIG; + if (envConfig) { + return envConfig; + } + + const configDir = getOpencodeConfigDir(homeDir); + const jsoncPath = path.join(configDir, 'opencode.jsonc'); + const jsonPath = path.join(configDir, 'opencode.json'); + + if (await exists(jsoncPath)) return jsoncPath; + if (await exists(jsonPath)) return jsonPath; + return jsonPath; +}