diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index d4b2c1df..65c98e3b 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -56,6 +56,8 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - **GitHub CodeQL "default setup" can silently skip PRs — use an explicit workflow** — The repository-level "default setup" for CodeQL does not guarantee analysis on every PR. PR #279 (Renovate deps update) had zero CodeQL analyses, dropping the Scorecard SAST score from 10 to 9. Fix: add an explicit `.github/workflows/codeql.yml` that runs on `push: [main]`, `pull_request: [main]`, and a weekly schedule. After merging, disable the "default setup" in repository Settings > Code security > Code scanning to avoid duplicate analyses. The explicit workflow gives Scorecard a detectable `github/codeql-action` reference and guarantees coverage. - **`GITHUB_TOKEN`-authored pushes do NOT trigger downstream workflows — release.yml MUST use the GH App token** — When an Actions workflow pushes commits or opens PRs using `${{ github.token }}` / `secrets.GITHUB_TOKEN`, GitHub intentionally suppresses the resulting `push` / `pull_request` events to prevent recursion. Symptom on release PRs: the head SHA has no `pull_request`-event check runs, so `Validate Code` / `Lint, Test & Check` / `DCO Sign-off Check` are missing from the PR rollup and branch protection treats the PR as missing required checks. PR [#131](https://github.com/archgate/cli/pull/131) papered over this by manually `gh workflow run` + posting commit statuses, but `workflow_dispatch` runs land on `head_branch: release` with `pull_requests: []` — they are not associated with the PR ref, so `Lint, Test & Check` stayed orphaned and the bug recurred on PR [#251](https://github.com/archgate/cli/pull/251). Root-cause fix: in `release.yml` the `pull-request` job MUST generate a GitHub App installation token via `actions/create-github-app-token` (using `secrets.GH_APP_APP_ID` / `secrets.GH_APP_PRIVATE_KEY`) and pass it to BOTH `actions/checkout` and `simple-release-action`. App-token-authored pushes DO trigger `pull_request` events naturally. Apply the same pattern to any future workflow that pushes to a branch whose downstream CI must run. +- **Cross-command I/O sharing: export from the existing command file, don't create shared files** — When two commands need to share I/O functions (console.log with styleText), you CANNOT put them in `src/helpers/` (ARCH-002 forbids console.log in helpers) or create a new file under `src/commands//` without a register function (ARCH-001 requires register\*Command export, ARCH-016 requires docs heading). The correct pattern: export the shared functions from the command file that already defines them (e.g., `plugin/install.ts` exports `installForEditor()` and `printManualInstructions()`) and import them in the other command. Applied in `upgrade.ts` importing from `./plugin/install`. + ## Validation Pipeline - `bun run validate` is the mandatory gate: lint → typecheck → format:check → test → ADR check → knip → build:check diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 6f370ba4..10f9fc8f 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -35,7 +35,16 @@ const editorOption = new Option( "target editor (omit to auto-detect and select)" ).choices(["claude", "cursor", "vscode", "copilot", "opencode"] as const); -async function installForEditor( +/** + * Install the archgate plugin for a single editor. + * + * Dispatches to the editor-specific install function, checks CLI availability, + * and surfaces manual instructions when the CLI is missing. Throws on failure + * so callers can collect errors and report them together. + * + * Exported for reuse by the `upgrade --plugins` flow. + */ +export async function installForEditor( editor: EditorTarget, label: string, token: string @@ -127,7 +136,12 @@ async function installForEditor( } } -function printManualInstructions(editor: EditorTarget): void { +/** + * Print manual installation instructions for a given editor. + * + * Exported for reuse by the `upgrade --plugins` flow. + */ +export function printManualInstructions(editor: EditorTarget): void { switch (editor) { case "claude": { const url = buildMarketplaceUrl(); @@ -184,6 +198,47 @@ function printManualInstructions(editor: EditorTarget): void { } } +/** + * Run plugin installs for a list of editors, collecting failures. + * + * Returns the failure list so callers can decide how to handle them + * (e.g., exit 1 for `plugin install`, or just report for `upgrade`). + * + * Exported for reuse by the `upgrade --plugins` flow. + */ +export async function runPluginInstalls( + editors: EditorTarget[], + token: string, + verb: string = "install" +): Promise<{ editor: EditorTarget; label: string; error: string }[]> { + const failures: { editor: EditorTarget; label: string; error: string }[] = []; + + for (const editor of editors) { + const label = EDITOR_LABELS[editor]; + try { + // oxlint-disable-next-line no-await-in-loop -- sequential install with per-editor output + await installForEditor(editor, label, token); + } catch (err) { + failures.push({ + editor, + label, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + if (failures.length > 0) { + console.log(); + for (const { editor, label, error } of failures) { + logError(`Failed to ${verb} plugin for ${label}.`, error); + printManualInstructions(editor); + console.log(); + } + } + + return failures; +} + export function registerPluginInstallCommand(plugin: Command) { plugin .command("install") @@ -212,34 +267,12 @@ export function registerPluginInstallCommand(plugin: Command) { editors = ["claude"]; } - const failures: { - editor: EditorTarget; - label: string; - error: string; - }[] = []; - - for (const editor of editors) { - const label = EDITOR_LABELS[editor]; - try { - // oxlint-disable-next-line no-await-in-loop -- sequential install with per-editor output - await installForEditor(editor, label, credentials.token); - } catch (err) { - failures.push({ - editor, - label, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // Print all failures together at the end so they are easy to review + const failures = await runPluginInstalls( + editors, + credentials.token, + "install" + ); if (failures.length > 0) { - console.log(); - for (const { editor, label, error } of failures) { - logError(`Failed to install plugin for ${label}.`, error); - printManualInstructions(editor); - console.log(); - } await exitWith(1); } } catch (err) { diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index ba5c373a..4847633c 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -16,7 +16,8 @@ import { replaceBinary, } from "../helpers/binary-upgrade"; import { exitWith } from "../helpers/exit"; -import { logDebug, logError } from "../helpers/log"; +import type { EditorTarget } from "../helpers/init-project"; +import { logDebug, logError, logInfo } from "../helpers/log"; import { internalPath } from "../helpers/paths"; import { getPlatformInfo, resolveCommand } from "../helpers/platform"; import { trackUpgradeResult } from "../helpers/telemetry"; @@ -305,11 +306,71 @@ async function runExternalUpgrade( } } +/** + * Offer to update editor plugins after a successful CLI upgrade. + * Plugin update failures are reported but do NOT change the exit code. + */ +async function maybeUpdatePlugins(pluginsFlag: boolean): Promise { + const isTTY = process.stdin.isTTY === true; + + if (!pluginsFlag && isTTY) { + const { default: inquirer } = await import("inquirer"); + const { withPromptFix } = await import("../helpers/prompt"); + const { updatePlugins } = await withPromptFix(() => + inquirer.prompt([ + { + type: "confirm", + name: "updatePlugins", + message: "Would you like to update your editor plugins too?", + default: true, + }, + ]) + ); + if (!updatePlugins) return; + } + + const { loadCredentials } = await import("../helpers/credential-store"); + const credentials = await loadCredentials(); + if (!credentials) { + logInfo( + "Not logged in.", + "Run `archgate login` first, then `archgate plugin install` to update plugins." + ); + return; + } + + const { detectEditors, promptEditorSelection } = + await import("../helpers/editor-detect"); + const detected = await detectEditors(); + const available = detected.filter((e) => e.available); + + if (available.length === 0) { + logInfo( + "No supported editors detected.", + "Run `archgate plugin install --editor ` to install manually." + ); + return; + } + + let editors: EditorTarget[]; + if (!pluginsFlag && isTTY) { + editors = await promptEditorSelection(detected); + } else { + editors = available.map((e) => e.id); + } + + const { runPluginInstalls } = await import("./plugin/install"); + + console.log("Updating editor plugins..."); + await runPluginInstalls(editors, credentials.token, "update"); +} + export function registerUpgradeCommand(program: Command) { program .command("upgrade") .description("Upgrade Archgate to the latest version") - .action(async () => { + .option("--plugins", "also update editor plugins after upgrading") + .action(async (opts) => { try { console.log("Checking for latest Archgate release..."); @@ -371,6 +432,9 @@ export function registerUpgradeCommand(program: Command) { }); console.log(`Archgate upgraded to ${latestVersion} successfully.`); + + // Offer plugin updates after a successful CLI upgrade + await maybeUpdatePlugins(opts.plugins === true); } catch (err) { if (err instanceof Error && err.name === "ExitPromptError") throw err; trackUpgradeResult({ @@ -393,4 +457,5 @@ export { detectInstallMethod as _detectInstallMethod, formatBytes as _formatBytes, createDownloadProgress as _createDownloadProgress, + maybeUpdatePlugins as _maybeUpdatePlugins, }; diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index e293fa84..af900d52 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -162,7 +162,8 @@ async function configureEditorSettings( // Opencode agent files are user-scope and written by `tryInstallPlugin` // after authenticating against the plugins service. Nothing lands in // the project tree — return the resolved user-scope path so the init - // summary has something meaningful to print. + // summary has something meaningful to print. The opencode.json config + // (default_agent) is set inside installOpencodePlugin() itself. return opencodeAgentsDir(); default: return configureClaudeSettings(projectRoot); diff --git a/src/helpers/opencode-settings.ts b/src/helpers/opencode-settings.ts new file mode 100644 index 00000000..ba6e1478 --- /dev/null +++ b/src/helpers/opencode-settings.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * opencode-settings.ts — Configure opencode user-scope settings. + * + * Writes `opencode.json` to the XDG config directory + * (`~/.config/opencode/opencode.json`) with `default_agent` set to + * `archgate-developer`. Merges additively — existing user settings + * are preserved. + * + * opencode resolves its config via `xdg-basedir`, which falls back to + * `~/.config` on all platforms (including Windows). The path resolution + * uses `opencodeConfigDir()` from `paths.ts`. + */ + +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import { logDebug } from "./log"; +import { opencodeConfigDir } from "./paths"; + +/** The agent name used in opencode's `default_agent` config field. */ +const DEFAULT_AGENT = "archgate-developer"; + +type OpencodeConfig = Record; + +/** + * Pure, additive merge of archgate settings into existing opencode config. + * + * - `default_agent`: set only if absent (never overwrite user's choice) + * - All existing user settings are preserved (unknown keys pass through) + */ +export function mergeOpencodeSettings( + existing: OpencodeConfig +): OpencodeConfig { + const merged: OpencodeConfig = { ...existing }; + + if (!("default_agent" in merged)) { + merged.default_agent = DEFAULT_AGENT; + } + + return merged; +} + +/** + * Resolve the path to the opencode user-scope config file. + * + * The config lives in the same XDG config directory as the agents: + * `~/.config/opencode/opencode.json`. Uses the same resolution logic + * as `opencodeAgentsDir()` to stay consistent. + */ +export function opencodeConfigPath(): string { + return join(opencodeConfigDir(), "opencode.json"); +} + +/** + * Configure opencode settings for archgate integration. + * + * Reads existing `opencode.json` (if any), merges archgate settings + * additively, and writes the result. Creates parent directories if missing. + * + * @returns Absolute path to the config file. + */ +export async function configureOpencodeSettings(): Promise { + const configPath = opencodeConfigPath(); + + let existing: OpencodeConfig = {}; + if (existsSync(configPath)) { + try { + existing = (await Bun.file(configPath).json()) as OpencodeConfig; + } catch { + // Corrupted config file — start fresh + } + } + + const merged = mergeOpencodeSettings(existing); + + // Ensure parent directory exists + const dir = opencodeConfigDir(); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + logDebug("Writing opencode config:", configPath); + await Bun.write(configPath, JSON.stringify(merged, null, 2) + "\n"); + + return configPath; +} diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 9204c662..1427cf8f 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -41,22 +41,28 @@ function usableEnv(value: string | undefined): string | null { } /** - * Resolve the opencode user-scope agents directory. + * Resolve the opencode user-scope config directory (`~/.config/opencode/`). * * Opencode uses the `xdg-basedir` package to locate its config root. That * package reads `$XDG_CONFIG_HOME` when set and otherwise falls back to * `~/.config` on **all platforms** — including Windows, where the resolved - * path is `C:\Users\\.config\opencode\agents` rather than anything - * under `%APPDATA%`. We mirror the same resolution here so the CLI writes - * to the exact directory opencode reads from. + * path is `C:\Users\\.config\opencode\` rather than anything under + * `%APPDATA%`. We mirror the same resolution here so the CLI writes to + * the exact directory opencode reads from. * * The path is resolved at call time, not cached — tests override `HOME` / * `XDG_CONFIG_HOME` per-test and expect the helper to pick up the override. + * + * Used by `opencodeAgentsDir()` and `opencodeConfigPath()`. */ -export function opencodeAgentsDir(): string { +export function opencodeConfigDir(): string { const xdg = usableEnv(Bun.env.XDG_CONFIG_HOME); const base = xdg ?? join(archgateHomeDir(), ".config"); - return join(base, "opencode", "agents"); + return join(base, "opencode"); +} + +export function opencodeAgentsDir(): string { + return join(opencodeConfigDir(), "agents"); } /** diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 0fd3b51b..251bf990 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -205,6 +205,10 @@ export async function installOpencodePlugin(token: string): Promise { // Ignore cleanup errors } } + + // Configure opencode.json with default_agent (idempotent — only sets if absent) + const { configureOpencodeSettings } = await import("./opencode-settings"); + await configureOpencodeSettings(); } // --------------------------------------------------------------------------- diff --git a/tests/commands/plugin/shared.test.ts b/tests/commands/plugin/shared.test.ts new file mode 100644 index 00000000..daef6018 --- /dev/null +++ b/tests/commands/plugin/shared.test.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * Tests for maybeUpdatePlugins (exported from upgrade.ts as _maybeUpdatePlugins). + * + * Uses spyOn instead of mock.module to avoid the global mock.module leak + * that breaks plugin/install.test.ts when both files run in the same Bun + * process. + */ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import * as pluginInstall from "../../../src/commands/plugin/install"; +import * as credentialStore from "../../../src/helpers/credential-store"; +import * as editorDetect from "../../../src/helpers/editor-detect"; + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +let logSpy: ReturnType; +let warnSpy: ReturnType; +let errorSpy: ReturnType; +let originalIsTTY: boolean | undefined; + +let credSpy: ReturnType; +let detectSpy: ReturnType; +let promptSpy: ReturnType; +let installSpy: ReturnType; +let manualSpy: ReturnType; + +beforeEach(() => { + logSpy = spyOn(console, "log").mockImplementation(() => {}); + warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + originalIsTTY = process.stdin.isTTY; + + credSpy = spyOn(credentialStore, "loadCredentials").mockResolvedValue(null); + detectSpy = spyOn(editorDetect, "detectEditors").mockResolvedValue([]); + promptSpy = spyOn(editorDetect, "promptEditorSelection").mockResolvedValue([ + "claude", + ]); + installSpy = spyOn(pluginInstall, "installForEditor").mockResolvedValue(); + manualSpy = spyOn( + pluginInstall, + "printManualInstructions" + ).mockImplementation(() => {}); +}); + +afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + credSpy.mockRestore(); + detectSpy.mockRestore(); + promptSpy.mockRestore(); + installSpy.mockRestore(); + manualSpy.mockRestore(); + Object.defineProperty(process.stdin, "isTTY", { + value: originalIsTTY, + configurable: true, + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function importUpgrade() { + return import(`../../../src/commands/upgrade?t=${Date.now()}`); +} + +function setTTY(value: boolean | undefined) { + Object.defineProperty(process.stdin, "isTTY", { value, configurable: true }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("maybeUpdatePlugins", () => { + test("prints login hint when no credentials", async () => { + setTTY(false); + credSpy.mockResolvedValue(null); + + const { _maybeUpdatePlugins } = await importUpgrade(); + await _maybeUpdatePlugins(true); + + expect(logSpy).toHaveBeenCalled(); + const allLogOutput = logSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(allLogOutput).toContain("archgate login"); + expect(detectSpy).not.toHaveBeenCalled(); + }); + + test("prints hint when no editors detected", async () => { + setTTY(false); + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + detectSpy.mockResolvedValue([ + { id: "claude" as const, label: "Claude Code", available: false }, + { id: "cursor" as const, label: "Cursor", available: false }, + ]); + + const { _maybeUpdatePlugins } = await importUpgrade(); + await _maybeUpdatePlugins(true); + + expect(logSpy).toHaveBeenCalled(); + const allLogOutput = logSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(allLogOutput).toContain("No supported editors detected"); + expect(installSpy).not.toHaveBeenCalled(); + }); + + test("auto-updates all detected editors with --plugins flag", async () => { + setTTY(false); + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + detectSpy.mockResolvedValue([ + { id: "claude" as const, label: "Claude Code", available: true }, + { id: "vscode" as const, label: "VS Code", available: true }, + { id: "cursor" as const, label: "Cursor", available: false }, + ]); + + const { _maybeUpdatePlugins } = await importUpgrade(); + await _maybeUpdatePlugins(true); + + expect(installSpy).toHaveBeenCalledTimes(2); + expect(installSpy).toHaveBeenCalledWith("claude", "Claude Code", "tok"); + expect(installSpy).toHaveBeenCalledWith("vscode", "VS Code", "tok"); + expect(promptSpy).not.toHaveBeenCalled(); + }); + + test("auto-updates in non-TTY agent context without prompt", async () => { + setTTY(false); + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + detectSpy.mockResolvedValue([ + { id: "claude" as const, label: "Claude Code", available: true }, + ]); + + const { _maybeUpdatePlugins } = await importUpgrade(); + await _maybeUpdatePlugins(false); + + expect(installSpy).toHaveBeenCalledTimes(1); + expect(installSpy).toHaveBeenCalledWith("claude", "Claude Code", "tok"); + expect(promptSpy).not.toHaveBeenCalled(); + }); + + test("reports install failures without changing exit code", async () => { + setTTY(false); + credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); + detectSpy.mockResolvedValue([ + { id: "claude" as const, label: "Claude Code", available: true }, + ]); + installSpy.mockRejectedValue(new Error("install failed")); + + const { _maybeUpdatePlugins } = await importUpgrade(); + await _maybeUpdatePlugins(true); + + expect(errorSpy).toHaveBeenCalled(); + const allErrorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(allErrorOutput).toContain("Failed to update plugin"); + expect(manualSpy).toHaveBeenCalledWith("claude"); + }); +}); diff --git a/tests/commands/upgrade.test.ts b/tests/commands/upgrade.test.ts index 154e2915..ed2ef1ff 100644 --- a/tests/commands/upgrade.test.ts +++ b/tests/commands/upgrade.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/tests/helpers/opencode-settings.test.ts b/tests/helpers/opencode-settings.test.ts new file mode 100644 index 00000000..87d7ba60 --- /dev/null +++ b/tests/helpers/opencode-settings.test.ts @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + configureOpencodeSettings, + mergeOpencodeSettings, + opencodeConfigPath, +} from "../../src/helpers/opencode-settings"; + +describe("mergeOpencodeSettings", () => { + test("sets default_agent when existing config is empty", () => { + const result = mergeOpencodeSettings({}); + + expect(result.default_agent).toBe("archgate-developer"); + }); + + test("preserves existing default_agent (does not overwrite)", () => { + const result = mergeOpencodeSettings({ default_agent: "my-custom-agent" }); + + expect(result.default_agent).toBe("my-custom-agent"); + }); + + test("preserves unknown top-level keys", () => { + const result = mergeOpencodeSettings({ + model: "anthropic/claude-sonnet-4-5", + autoupdate: true, + }); + + expect(result.model).toBe("anthropic/claude-sonnet-4-5"); + expect(result.autoupdate).toBe(true); + expect(result.default_agent).toBe("archgate-developer"); + }); + + test("preserves existing nested config objects", () => { + const result = mergeOpencodeSettings({ + server: { port: 4096 }, + tools: { write: false }, + }); + + expect(result.server).toEqual({ port: 4096 }); + expect(result.tools).toEqual({ write: false }); + expect(result.default_agent).toBe("archgate-developer"); + }); +}); + +describe("opencodeConfigPath", () => { + let originalXdg: string | undefined; + let originalHome: string | undefined; + + beforeEach(() => { + originalXdg = Bun.env.XDG_CONFIG_HOME; + originalHome = Bun.env.HOME; + }); + + afterEach(() => { + if (originalXdg === undefined) delete Bun.env.XDG_CONFIG_HOME; + else Bun.env.XDG_CONFIG_HOME = originalXdg; + if (originalHome === undefined) delete Bun.env.HOME; + else Bun.env.HOME = originalHome; + }); + + test("resolves to XDG_CONFIG_HOME when set", () => { + Bun.env.XDG_CONFIG_HOME = "/custom/xdg"; + + const path = opencodeConfigPath(); + + expect(path).toBe(join("/custom/xdg", "opencode", "opencode.json")); + }); +}); + +describe("configureOpencodeSettings", () => { + let tempDir: string; + let originalXdg: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-opencode-settings-test-")); + originalXdg = Bun.env.XDG_CONFIG_HOME; + Bun.env.XDG_CONFIG_HOME = tempDir; + }); + + afterEach(() => { + if (originalXdg === undefined) delete Bun.env.XDG_CONFIG_HOME; + else Bun.env.XDG_CONFIG_HOME = originalXdg; + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Cleanup may fail on Windows + } + }); + + test("creates config file with default_agent when none exists", async () => { + const configPath = await configureOpencodeSettings(); + + expect(existsSync(configPath)).toBe(true); + const content = await Bun.file(configPath).json(); + expect(content.default_agent).toBe("archgate-developer"); + }); + + test("merges into existing file without overwriting user entries", async () => { + const configDir = join(tempDir, "opencode"); + mkdirSync(configDir, { recursive: true }); + + const existingConfig = { + default_agent: "my-custom-agent", + model: "anthropic/claude-sonnet-4-5", + }; + await Bun.write( + join(configDir, "opencode.json"), + JSON.stringify(existingConfig, null, 2) + ); + + await configureOpencodeSettings(); + + const content = await Bun.file(join(configDir, "opencode.json")).json(); + // Existing default_agent preserved + expect(content.default_agent).toBe("my-custom-agent"); + // Existing model preserved + expect(content.model).toBe("anthropic/claude-sonnet-4-5"); + }); + + test("creates parent directories when missing", async () => { + // XDG_CONFIG_HOME points to tempDir; opencode/ subdirectory does not exist + const configPath = await configureOpencodeSettings(); + + expect(existsSync(configPath)).toBe(true); + expect(configPath).toContain("opencode.json"); + }); + + test("returns correct absolute path", async () => { + const configPath = await configureOpencodeSettings(); + + expect(configPath).toBe(join(tempDir, "opencode", "opencode.json")); + }); +});