From 47ef725421b55ccb0db3b902608363a75c34147b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 00:29:21 +0200 Subject: [PATCH 1/9] feat(upgrade): prompt to update editor plugins after CLI upgrade After a successful CLI upgrade, offer to update editor plugins: - TTY: confirm prompt, then editor selection - Non-TTY (agent): auto-update all detected editors - --plugins flag: skip prompt, auto-update all detected Gracefully handles missing credentials (login hint), no editors detected (manual hint), and install failures (non-fatal report). Reuses installForEditor/printManualInstructions from plugin/install by exporting them for cross-command sharing. Signed-off-by: Rhuan Barreto --- .../agent-memory/archgate-developer/MEMORY.md | 2 + src/commands/plugin/install.ts | 23 ++- src/commands/upgrade.ts | 92 +++++++++- tests/commands/plugin/shared.test.ts | 166 ++++++++++++++++++ tests/commands/upgrade.test.ts | 2 +- 5 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 tests/commands/plugin/shared.test.ts diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index d4b2c1df..4493684c 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..61913390 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 @@ -74,8 +83,6 @@ async function installForEditor( break; } case "cursor": { - // Cursor supports plugins via Team Private Marketplaces — not VSIX. - // See https://cursor.com/docs/plugins#team-marketplaces const url = buildCursorMarketplaceUrl(); logInfo( `To install the Archgate plugin for ${label}, add the team marketplace URL in Cursor Settings:` @@ -87,9 +94,6 @@ async function installForEditor( break; } case "opencode": { - // Writing agent files to `~/.config/opencode/agents/` is only useful - // if opencode is actually installed. Skip the install and surface a - // clear message otherwise, matching every other editor's guard. if (!(await isOpencodeCliAvailable())) { logWarn( "opencode CLI not found on PATH — skipping agent install.", @@ -127,7 +131,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(); diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index ba5c373a..eac6257a 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -15,11 +15,17 @@ import { getManualInstallHint, replaceBinary, } from "../helpers/binary-upgrade"; +import { loadCredentials } from "../helpers/credential-store"; +import { detectEditors, promptEditorSelection } from "../helpers/editor-detect"; import { exitWith } from "../helpers/exit"; -import { logDebug, logError } from "../helpers/log"; +import { EDITOR_LABELS } from "../helpers/init-project"; +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 { withPromptFix } from "../helpers/prompt"; import { trackUpgradeResult } from "../helpers/telemetry"; +import { installForEditor, printManualInstructions } from "./plugin/install"; type InstallMethod = | { type: "binary"; binaryPath: string } @@ -305,11 +311,89 @@ 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 { 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 credentials = await loadCredentials(); + if (!credentials) { + logInfo( + "Not logged in.", + "Run `archgate login` first, then `archgate plugin install` to update plugins." + ); + return; + } + + 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); + } + + console.log("Updating editor plugins..."); + + 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), + }); + } + } + + if (failures.length > 0) { + console.log(); + for (const { editor, label, error } of failures) { + logError(`Failed to update plugin for ${label}.`, error); + printManualInstructions(editor); + console.log(); + } + } +} + 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 +455,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 +480,5 @@ export { detectInstallMethod as _detectInstallMethod, formatBytes as _formatBytes, createDownloadProgress as _createDownloadProgress, + maybeUpdatePlugins as _maybeUpdatePlugins, }; 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"; From 20017ad060a43cdbe0902d414bacafb86e41744a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 00:36:46 +0200 Subject: [PATCH 2/9] feat(opencode): set archgate-developer as default agent in opencode.json Write `~/.config/opencode/opencode.json` with `default_agent` set to `archgate-developer` during `archgate init --editor opencode` and `archgate plugin install --editor opencode`. Follows the same additive merge pattern as claude-settings.ts: - Sets `default_agent` only if absent (never overwrites user's choice) - Preserves all existing user config keys - Creates parent directories if missing Signed-off-by: Rhuan Barreto --- src/commands/plugin/install.ts | 4 + src/helpers/init-project.ts | 9 +- src/helpers/opencode-settings.ts | 89 +++++++++++++++ tests/helpers/opencode-settings.test.ts | 138 ++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 src/helpers/opencode-settings.ts create mode 100644 tests/helpers/opencode-settings.test.ts diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 61913390..4a7c46f7 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -105,6 +105,10 @@ export async function installForEditor( break; } await installOpencodePlugin(token); + // Configure opencode.json to set archgate-developer as default agent + const { configureOpencodeSettings } = + await import("../../helpers/opencode-settings"); + await configureOpencodeSettings(); logInfo(`Archgate agents installed for ${label}.`); break; } diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index e293fa84..673c2428 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -158,12 +158,15 @@ async function configureEditorSettings( } case "copilot": return configureCopilotSettings(projectRoot); - case "opencode": + case "opencode": { // 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. + // the project tree. Additionally, configure opencode.json to set the + // default agent to archgate-developer. + const { configureOpencodeSettings } = await import("./opencode-settings"); + await configureOpencodeSettings(); 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..7cd0a135 --- /dev/null +++ b/src/helpers/opencode-settings.ts @@ -0,0 +1,89 @@ +// 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 + * mirrors `opencodeAgentsDir()` in `paths.ts`. + */ + +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { logDebug } from "./log"; +import { opencodeAgentsDir } 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 { + // agents dir is `~/.config/opencode/agents/` — go up one level + return join(dirname(opencodeAgentsDir()), "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 = dirname(configPath); + 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/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")); + }); +}); From 07e84890f94f846cba095285b8bbd5b8f9697fa4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 00:41:56 +0200 Subject: [PATCH 3/9] refactor: move configureOpencodeSettings into installOpencodePlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the opencode.json config write into the single install function so it runs from every entry point (init, plugin install, upgrade --plugins) without duplicate calls. The function is already idempotent — it only sets default_agent if absent. Signed-off-by: Rhuan Barreto --- src/commands/plugin/install.ts | 4 ---- src/helpers/init-project.ts | 10 ++++------ src/helpers/plugin-install.ts | 4 ++++ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 4a7c46f7..61913390 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -105,10 +105,6 @@ export async function installForEditor( break; } await installOpencodePlugin(token); - // Configure opencode.json to set archgate-developer as default agent - const { configureOpencodeSettings } = - await import("../../helpers/opencode-settings"); - await configureOpencodeSettings(); logInfo(`Archgate agents installed for ${label}.`); break; } diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 673c2428..af900d52 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -158,15 +158,13 @@ async function configureEditorSettings( } case "copilot": return configureCopilotSettings(projectRoot); - case "opencode": { + case "opencode": // Opencode agent files are user-scope and written by `tryInstallPlugin` // after authenticating against the plugins service. Nothing lands in - // the project tree. Additionally, configure opencode.json to set the - // default agent to archgate-developer. - const { configureOpencodeSettings } = await import("./opencode-settings"); - await configureOpencodeSettings(); + // the project tree — return the resolved user-scope path so the init + // 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/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(); } // --------------------------------------------------------------------------- From e3901e0024dac3a28b9324a06344362109921b32 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 00:48:05 +0200 Subject: [PATCH 4/9] refactor(upgrade): lazy-load plugin update dependencies Move all imports used only by maybeUpdatePlugins to dynamic import() calls inside the function body. This avoids parsing credential-store, editor-detect, init-project, plugin/install, and prompt modules on code paths that never reach the plugin update flow (--help, --version, already up-to-date, upgrade failure). Signed-off-by: Rhuan Barreto --- src/commands/upgrade.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index eac6257a..985df777 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -15,17 +15,12 @@ import { getManualInstallHint, replaceBinary, } from "../helpers/binary-upgrade"; -import { loadCredentials } from "../helpers/credential-store"; -import { detectEditors, promptEditorSelection } from "../helpers/editor-detect"; import { exitWith } from "../helpers/exit"; -import { EDITOR_LABELS } from "../helpers/init-project"; 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 { withPromptFix } from "../helpers/prompt"; import { trackUpgradeResult } from "../helpers/telemetry"; -import { installForEditor, printManualInstructions } from "./plugin/install"; type InstallMethod = | { type: "binary"; binaryPath: string } @@ -320,6 +315,7 @@ async function maybeUpdatePlugins(pluginsFlag: boolean): Promise { if (!pluginsFlag && isTTY) { const { default: inquirer } = await import("inquirer"); + const { withPromptFix } = await import("../helpers/prompt"); const { updatePlugins } = await withPromptFix(() => inquirer.prompt([ { @@ -333,6 +329,7 @@ async function maybeUpdatePlugins(pluginsFlag: boolean): Promise { if (!updatePlugins) return; } + const { loadCredentials } = await import("../helpers/credential-store"); const credentials = await loadCredentials(); if (!credentials) { logInfo( @@ -342,6 +339,8 @@ async function maybeUpdatePlugins(pluginsFlag: boolean): Promise { return; } + const { detectEditors, promptEditorSelection } = + await import("../helpers/editor-detect"); const detected = await detectEditors(); const available = detected.filter((e) => e.available); @@ -360,6 +359,10 @@ async function maybeUpdatePlugins(pluginsFlag: boolean): Promise { editors = available.map((e) => e.id); } + const { EDITOR_LABELS } = await import("../helpers/init-project"); + const { installForEditor, printManualInstructions } = + await import("./plugin/install"); + console.log("Updating editor plugins..."); const failures: { editor: EditorTarget; label: string; error: string }[] = []; From 054041f24c0a2774ea3fae0f66e2e8a75f3d6310 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 00:53:09 +0200 Subject: [PATCH 5/9] refactor(opencode): extract opencodeConfigDir for explicit path resolution Replace the fragile dirname(opencodeAgentsDir()) derivation with an explicit opencodeConfigDir() in paths.ts. Both opencodeAgentsDir() and opencodeConfigPath() now derive from the same base, removing the coupling between the agents subdirectory and the config path. Signed-off-by: Rhuan Barreto --- src/helpers/opencode-settings.ts | 11 +++++------ src/helpers/paths.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/helpers/opencode-settings.ts b/src/helpers/opencode-settings.ts index 7cd0a135..ba6e1478 100644 --- a/src/helpers/opencode-settings.ts +++ b/src/helpers/opencode-settings.ts @@ -10,14 +10,14 @@ * * opencode resolves its config via `xdg-basedir`, which falls back to * `~/.config` on all platforms (including Windows). The path resolution - * mirrors `opencodeAgentsDir()` in `paths.ts`. + * uses `opencodeConfigDir()` from `paths.ts`. */ import { existsSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { logDebug } from "./log"; -import { opencodeAgentsDir } from "./paths"; +import { opencodeConfigDir } from "./paths"; /** The agent name used in opencode's `default_agent` config field. */ const DEFAULT_AGENT = "archgate-developer"; @@ -50,8 +50,7 @@ export function mergeOpencodeSettings( * as `opencodeAgentsDir()` to stay consistent. */ export function opencodeConfigPath(): string { - // agents dir is `~/.config/opencode/agents/` — go up one level - return join(dirname(opencodeAgentsDir()), "opencode.json"); + return join(opencodeConfigDir(), "opencode.json"); } /** @@ -77,7 +76,7 @@ export async function configureOpencodeSettings(): Promise { const merged = mergeOpencodeSettings(existing); // Ensure parent directory exists - const dir = dirname(configPath); + const dir = opencodeConfigDir(); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 9204c662..2d85fd86 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -53,10 +53,18 @@ function usableEnv(value: string | undefined): string | null { * 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. */ -export function opencodeAgentsDir(): string { +/** + * Resolve the opencode user-scope config directory (`~/.config/opencode/`). + * Used by both `opencodeAgentsDir()` and `opencodeConfigPath()`. + */ +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"); } /** From 8ef74ee61908ca31fa874792e872cf22de4d73ef Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 00:55:02 +0200 Subject: [PATCH 6/9] fix: restore inline comments removed during refactor Re-add the Cursor Team Marketplace and opencode CLI guard comments that were accidentally stripped from installForEditor during the export refactor. Signed-off-by: Rhuan Barreto --- src/commands/plugin/install.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 61913390..0c4d07ad 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -83,6 +83,8 @@ export async function installForEditor( break; } case "cursor": { + // Cursor supports plugins via Team Private Marketplaces — not VSIX. + // See https://cursor.com/docs/plugins#team-marketplaces const url = buildCursorMarketplaceUrl(); logInfo( `To install the Archgate plugin for ${label}, add the team marketplace URL in Cursor Settings:` @@ -94,6 +96,9 @@ export async function installForEditor( break; } case "opencode": { + // Writing agent files to `~/.config/opencode/agents/` is only useful + // if opencode is actually installed. Skip the install and surface a + // clear message otherwise, matching every other editor's guard. if (!(await isOpencodeCliAvailable())) { logWarn( "opencode CLI not found on PATH — skipping agent install.", From e9f87fba37f5d765d392be8d93dfe21630de6e06 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 00:56:36 +0200 Subject: [PATCH 7/9] fix: merge duplicated JSDoc block on opencodeConfigDir Signed-off-by: Rhuan Barreto --- src/helpers/paths.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 2d85fd86..1427cf8f 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -41,21 +41,19 @@ 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. - */ -/** - * Resolve the opencode user-scope config directory (`~/.config/opencode/`). - * Used by both `opencodeAgentsDir()` and `opencodeConfigPath()`. + * + * Used by `opencodeAgentsDir()` and `opencodeConfigPath()`. */ export function opencodeConfigDir(): string { const xdg = usableEnv(Bun.env.XDG_CONFIG_HOME); From 6a7291180a41679cd98f62ae6361b1f2966ad0b4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 00:59:10 +0200 Subject: [PATCH 8/9] fix: format MEMORY.md to pass CI format check Signed-off-by: Rhuan Barreto --- .claude/agent-memory/archgate-developer/MEMORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 4493684c..65c98e3b 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -56,7 +56,7 @@ 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`. +- **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 From 97f15ed7f55b8d991b5cb6926b5c9ebf3df9d919 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 24 May 2026 01:03:51 +0200 Subject: [PATCH 9/9] refactor: extract runPluginInstalls to DRY the install loop The "loop over editors, collect failures, report with manual instructions" block was duplicated between plugin install and upgrade --plugins. Extract it into runPluginInstalls(editors, token, verb) in plugin/install.ts so both callers share the same logic. The verb parameter ("install" vs "update") customizes the error message. Signed-off-by: Rhuan Barreto --- src/commands/plugin/install.ts | 73 +++++++++++++++++++++------------- src/commands/upgrade.ts | 30 +------------- 2 files changed, 48 insertions(+), 55 deletions(-) diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index 0c4d07ad..10f9fc8f 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -198,6 +198,47 @@ export 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") @@ -226,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 985df777..4847633c 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -359,36 +359,10 @@ async function maybeUpdatePlugins(pluginsFlag: boolean): Promise { editors = available.map((e) => e.id); } - const { EDITOR_LABELS } = await import("../helpers/init-project"); - const { installForEditor, printManualInstructions } = - await import("./plugin/install"); + const { runPluginInstalls } = await import("./plugin/install"); console.log("Updating editor plugins..."); - - 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), - }); - } - } - - if (failures.length > 0) { - console.log(); - for (const { editor, label, error } of failures) { - logError(`Failed to update plugin for ${label}.`, error); - printManualInstructions(editor); - console.log(); - } - } + await runPluginInstalls(editors, credentials.token, "update"); } export function registerUpgradeCommand(program: Command) {