From 1d07295d907d83be803537d4ee24069fc851f15a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 10:45:04 +0200 Subject: [PATCH 1/9] fix(tests): stop vscode-settings tests from touching real user settings The v0.44.0 release failed on an order-dependent test flake: - init-project.test.ts ("vscode returns auto-installed") exercised the real configureVscodeSettings with mocked credentials, writing the marketplace URL into the runner's REAL ~/.config/Code/User/settings.json (and developers' real VS Code settings on local runs). - vscode-settings.test.ts overrode HOME per-test, but Bun caches os.homedir() on Linux, so the override was silently ignored and the tests read/wrote the real (now polluted) settings file. - Bun's test-file execution order is non-deterministic across runs, so the failure only appeared when init-project.test.ts happened to run first - the same tree passed CI on the release PR and failed on main 13 minutes later. Fixes (test-only, no production changes): - init-project.test.ts: spy out configureVscodeSettings so initProject never writes user-scope state. - vscode-settings.test.ts: replace HOME env overrides with spyOn(os, "homedir").mockReturnValue(tempDir) - verified the spy reaches named imports in other modules. Keep the APPDATA env override for the Windows branch, which reads Bun.env at call time. - vscode-settings.test.ts: restore saved env by deleting originally unset keys instead of Object.assign stringifying undefined. - ARCH-005: capture both rules (mock os.homedir(), never write real user-scope state in tests). Signed-off-by: Rhuan Barreto --- .archgate/adrs/ARCH-005-testing-standards.md | 2 + .../agent-memory/archgate-developer/MEMORY.md | 4 ++ .../feedback_no_prod_changes_for_tests.md | 12 +++++ tests/helpers/init-project.test.ts | 30 ++++++++--- tests/helpers/vscode-settings.test.ts | 54 +++++++++++++------ 5 files changed, 81 insertions(+), 21 deletions(-) create mode 100644 .claude/agent-memory/archgate-developer/feedback_no_prod_changes_for_tests.md diff --git a/.archgate/adrs/ARCH-005-testing-standards.md b/.archgate/adrs/ARCH-005-testing-standards.md index f9566e23..60680cd4 100644 --- a/.archgate/adrs/ARCH-005-testing-standards.md +++ b/.archgate/adrs/ARCH-005-testing-standards.md @@ -53,6 +53,7 @@ Use Bun's built-in test runner (`bun test`) for all tests. Test files go in `tes - **Make large production thresholds injectable so tests can use a small value** — When production code only acts past a large numeric threshold (e.g., `SCOPE_FILE_WARN_THRESHOLD = 1000` in `src/engine/git-files.ts`), add an optional parameter that defaults to the module constant (e.g., `resolveScopedFiles(root, globs, { fileWarnThreshold })`). Tests inject a tiny value such as `5` and create only a handful of files to exercise the same code path. This keeps the test fast and deterministic on every platform instead of materializing thousands of fixture files. - **Only ever raise a per-test timeout override above the global, never below it** — the project global is `bun test --timeout 60000` (60s). A per-test `}, 30_000` override makes that test _more_ likely to time out than the default. Use a per-test override solely to grant a genuinely slow test more time than 60s; never set one shorter than the global. - **Mock first-party modules with `import * as mod` + `spyOn(mod, "fn")`, not `mock.module()`** — declare `import * as authMod from "../../src/helpers/auth"`, then in `beforeEach` install `spyOn(authMod, "requestDeviceCode").mockResolvedValue(...)` and restore with `mock.restore()` in `afterEach`. `spyOn` is scoped per-test and auto-restored, and it correctly reaches BOTH static named-import consumers (Bun backs named imports with live bindings) AND dynamic `await import()` consumers, because it mutates the single shared module instance. This is the same pattern used for `spyOn(pathsMod, "findProjectRoot")`. +- **When a test must redirect user-scope paths (home directory), mock `os.homedir()` — a runtime `HOME` env override does NOT work** — Bun caches `os.homedir()` on Linux, so setting `process.env.HOME`/`Bun.env.HOME` inside a test is silently ignored and the code under test resolves the REAL home directory. Declare `import * as os from "node:os"`, install `spyOn(os, "homedir").mockReturnValue(tempDir)` in `beforeEach`, and restore with `mockRestore()` in `afterEach` — the spy reaches named `import { homedir }` consumers in other modules via the same live-binding mechanism as first-party `spyOn`. Env-var overrides remain valid ONLY for code that reads `Bun.env.*` directly at call time (e.g., the `APPDATA` branch in `vscode-settings.ts`, or `paths.ts` helpers documented as "resolved at call time"). Production code MUST NOT be rewritten to read `Bun.env.HOME` just to make env overrides work — mock the implementation in the test instead. ### Don't @@ -70,6 +71,7 @@ Use Bun's built-in test runner (`bun test`) for all tests. Test files go in `tes - **Don't use `mock.module()` on a first-party module that any other test file imports** — Bun's `mock.module()` is process-global and retroactive: it replaces the module for ALL test files in the same Bun process and is NOT undone by `mock.restore()`. Symptom: a test that imports the real implementation intermittently receives a mocked one depending on file execution order — a flaky failure that passes in isolation. Do NOT work around this by splitting production code into a separate `-impl` file (or thin delegating wrapper) just so the "real" tests can import an un-mocked path — that contorts the production module layout to serve a test-tool quirk. Fix the test by switching to `spyOn` (see Do's above). `mock.module()` remains acceptable for third-party modules (e.g. `inquirer`, `node:readline`) that no first-party test needs the real implementation of. - **Don't place `mockRestore()` after assertions without `try/finally` protection** — if the assertion throws, the spy is never restored and contaminates subsequent tests. This is especially dangerous when spying on globals like `console.warn` or `globalThis.fetch`, where a leaked spy silently suppresses or redirects output for every test that follows. - **Don't materialize large filesystem fixtures (1000+ files, plus `git add .`) just to cross a production threshold** — on Windows CI runners that filesystem work is slow enough to exceed the per-test timeout, killing the staging subprocess mid-run (`git add . failed (exit 143)`, where 143 = 128 + SIGTERM) and producing a flaky failure that does not reproduce locally. Inject a small threshold instead (see Do's above). This was the root cause of a recurring Windows smoke-test flake on the `resolveScopedFiles` warning test. +- **Don't let tests write real user-scope state** (`~/.config/Code/User/settings.json`, `%APPDATA%`, `~/.cursor/`, `~/.config/opencode/`, etc.) — cross-file pollution combined with Bun's non-deterministic test-file execution order produces order-dependent flakes that pass on a PR run and fail after merge with identical code. This was the root cause of the v0.44.0 release failure: `init-project.test.ts` exercised the real `configureVscodeSettings` with mocked credentials and wrote the runner's real `~/.config/Code/User/settings.json`; `vscode-settings.test.ts` then read the polluted file whenever the file execution order flipped (its `HOME` env override was defeated by Bun's `homedir()` caching). The same writes also silently polluted developers' real VS Code settings on local runs. Either spy out the writer at the unit boundary (e.g., `spyOn(vscodeSettings, "configureVscodeSettings")`) or mock `os.homedir()` (see Do's above) so every write lands in a `mkdtemp` directory. ## Implementation Pattern diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 8de1cc9a..f4aae448 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -20,6 +20,10 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - [Always commit with --signoff](feedback_git_signoff.md) — DCO CI check rejects commits without `Signed-off-by` +## Approach Guidance + +- [No prod changes for testability](feedback_no_prod_changes_for_tests.md) — mock implementations in tests (spyOn os.homedir works cross-module); never alter prod semantics for test isolation + ## Known Bugs - _(none currently)_ diff --git a/.claude/agent-memory/archgate-developer/feedback_no_prod_changes_for_tests.md b/.claude/agent-memory/archgate-developer/feedback_no_prod_changes_for_tests.md new file mode 100644 index 00000000..4c70f8ce --- /dev/null +++ b/.claude/agent-memory/archgate-developer/feedback_no_prod_changes_for_tests.md @@ -0,0 +1,12 @@ +--- +name: no-prod-changes-for-tests +description: Never change production code semantics (e.g., env-first home resolution) just to make tests isolatable — mock the implementation in tests instead +metadata: + type: feedback +--- + +Do not modify production code behavior solely to enable test isolation. Mock the function implementation in the tests instead. + +**Why:** When fixing the flaky vscode-settings test failures (Bun caches `os.homedir()` on Linux, defeating per-test `HOME` overrides), I changed `getVscodeUserSettingsPath()` to resolve home from `Bun.env.HOME` at call time. The user rejected this: "overriding the caching mechanism only for testing is wrong. the user will never change their home dir." Real users never change HOME mid-process, so the env-first indirection added complexity that only tests needed. + +**How to apply:** When a test needs to redirect user-scope paths (home dir, APPDATA, etc.), use `import * as os from "node:os"` + `spyOn(os, "homedir").mockReturnValue(tempDir)` — verified that Bun's spyOn on a builtin module namespace intercepts named imports in other modules (same live-binding mechanism as the first-party spyOn pattern in ARCH-005). Restore with `mockRestore()` in `afterEach`. Env-var overrides only work for code that reads `Bun.env.*` directly at call time (e.g., the APPDATA branch); they do NOT reach `os.homedir()` on Linux. diff --git a/tests/helpers/init-project.test.ts b/tests/helpers/init-project.test.ts index 53d41b87..234ca1b7 100644 --- a/tests/helpers/init-project.test.ts +++ b/tests/helpers/init-project.test.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import * as credentialStore from "../../src/helpers/credential-store"; import { initProject } from "../../src/helpers/init-project"; import * as pluginInstall from "../../src/helpers/plugin-install"; +import * as vscodeSettings from "../../src/helpers/vscode-settings"; import { safeRmSync } from "../test-utils"; describe("initProject", () => { @@ -291,12 +292,29 @@ describe("tryInstallPlugin via initProject", () => { test("vscode returns auto-installed", async () => { credSpy.mockResolvedValue({ token: "tok", github_user: "user" }); - const result = await initProject(tempDir, { - installPlugin: true, - editor: "vscode", - }); - expect(result.plugin!.installed).toBe(true); - expect(result.plugin!.autoInstalled).toBe(true); + // With credentials present, configureEditorSettings (vscode branch) writes + // the REAL user-level VS Code settings.json — spy it out so the test never + // touches user state. (This previously polluted ~/.config/Code/User/ + // settings.json on CI runners and dev machines, causing order-dependent + // failures in vscode-settings.test.ts.) + const settingsSpy = spyOn( + vscodeSettings, + "configureVscodeSettings" + ).mockResolvedValue(join(tempDir, ".vscode")); + try { + const result = await initProject(tempDir, { + installPlugin: true, + editor: "vscode", + }); + expect(result.plugin!.installed).toBe(true); + expect(result.plugin!.autoInstalled).toBe(true); + expect(settingsSpy).toHaveBeenCalledWith( + tempDir, + expect.stringContaining("vscode.git") + ); + } finally { + settingsSpy.mockRestore(); + } }); test("claude with CLI available auto-installs", async () => { diff --git a/tests/helpers/vscode-settings.test.ts b/tests/helpers/vscode-settings.test.ts index 6bb4758b..bf995336 100644 --- a/tests/helpers/vscode-settings.test.ts +++ b/tests/helpers/vscode-settings.test.ts @@ -1,9 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; import { mkdtempSync, rmSync, existsSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { tmpdir } from "node:os"; +import * as os from "node:os"; import { join } from "node:path"; import { @@ -19,6 +18,21 @@ import { getVscodeUserSettingsPath, } from "../../src/helpers/vscode-settings"; +/** + * Restore saved env vars, deleting keys that were originally unset. + * `Object.assign(process.env, saved)` would stringify `undefined` into the + * literal "undefined", corrupting the env for subsequent test files. + */ +function restoreEnv(saved: Record): void { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + describe("mergeMarketplaceUrl", () => { const URL = "https://user:token@plugins.archgate.dev/archgate.git"; @@ -66,16 +80,23 @@ describe("mergeMarketplaceUrl", () => { describe("configureVscodeSettings", () => { let tempDir: string; let savedEnv: Record; + let homedirSpy: ReturnType; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-vscode-settings-test-")); - savedEnv = { APPDATA: process.env.APPDATA, HOME: process.env.HOME }; + tempDir = mkdtempSync(join(os.tmpdir(), "archgate-vscode-settings-test-")); + // Redirect user-scope path resolution into tempDir so these tests NEVER + // touch the real VS Code settings.json: + // - Windows branch reads Bun.env.APPDATA → env override works + // - macOS/Linux branches call os.homedir() → must be mocked, because Bun + // caches homedir() on Linux and ignores runtime HOME env overrides + savedEnv = { APPDATA: process.env.APPDATA }; process.env.APPDATA = tempDir; - process.env.HOME = tempDir; + homedirSpy = spyOn(os, "homedir").mockReturnValue(tempDir); }); afterEach(() => { - Object.assign(process.env, savedEnv); + homedirSpy.mockRestore(); + restoreEnv(savedEnv); rmSync(tempDir, { recursive: true, force: true }); }); @@ -124,17 +145,20 @@ describe("configureVscodeSettings", () => { describe("addMarketplaceToUserSettings", () => { let tempDir: string; let savedEnv: Record; + let homedirSpy: ReturnType; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-user-settings-test-")); - // Save and override env so getVscodeUserSettingsPath() resolves into tempDir - savedEnv = { APPDATA: process.env.APPDATA, HOME: process.env.HOME }; + tempDir = mkdtempSync(join(os.tmpdir(), "archgate-user-settings-test-")); + // Redirect user-scope path resolution into tempDir so these tests NEVER + // touch the real VS Code settings.json (see configureVscodeSettings above). + savedEnv = { APPDATA: process.env.APPDATA }; process.env.APPDATA = tempDir; // Windows - process.env.HOME = tempDir; // macOS/Linux (homedir()) + homedirSpy = spyOn(os, "homedir").mockReturnValue(tempDir); // macOS/Linux }); afterEach(() => { - Object.assign(process.env, savedEnv); + homedirSpy.mockRestore(); + restoreEnv(savedEnv); rmSync(tempDir, { recursive: true, force: true }); }); @@ -199,7 +223,7 @@ describe("addMarketplaceToUserSettings", () => { // Use a deeply nested subdir that definitely doesn't exist yet const deepHome = join(tempDir, "non", "existent", "deep"); process.env.APPDATA = deepHome; // Windows - process.env.HOME = deepHome; // macOS/Linux + homedirSpy.mockReturnValue(deepHome); // macOS/Linux // addMarketplaceToUserSettings should create the entire directory tree await addMarketplaceToUserSettings(URL); @@ -259,7 +283,7 @@ describe("getVscodeUserSettingsPath", () => { if (isWindows()) { // Windows: %APPDATA%/Code/User/settings.json const appData = ( - process.env.APPDATA ?? join(homedir(), "AppData", "Roaming") + process.env.APPDATA ?? join(os.homedir(), "AppData", "Roaming") ).replaceAll("\\", "/"); expect(normalized.startsWith(appData.replaceAll("\\", "/"))).toBe(true); } else if (isMacOS()) { @@ -269,7 +293,7 @@ describe("getVscodeUserSettingsPath", () => { ); } else if (!isWSL()) { // Linux (non-WSL): ~/.config/Code/User/settings.json - const home = homedir().replaceAll("\\", "/"); + const home = os.homedir().replaceAll("\\", "/"); expect(normalized.startsWith(home)).toBe(true); expect(normalized).toContain(".config/Code/User/settings.json"); } From a851fa84d37710af1a69c0de461f60a66cf18423 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 10:59:40 +0200 Subject: [PATCH 2/9] fix(tests): isolate remaining tests that touched real user-scope dirs Sweep of all test files overriding HOME/APPDATA/XDG_* envs, auditing each against what the production code actually reads: - env overrides are SAFE for consumers of paths.ts resolvers (archgateHomeDir, opencodeConfigDir, opencodeDbPath, cursorUserDir, copilotSessionStateDir) and upgrade.ts/install-info.ts - all read Bun.env.HOME / USERPROFILE / XDG_* at call time. No changes needed for clean, upgrade, auth, binary-upgrade, credential-store, sentry, telemetry*, update-check, opencode-settings, plugin-install-cleanup, session-context-opencode tests. - session-context.test.ts / session-context-cursor.test.ts worked around Bun's homedir() caching by writing into the REAL ~/.claude/projects and ~/.cursor/projects with unique names. Now spy os.homedir() into a mkdtemp tempHome instead (readClaudeCode- Session/readCursorSession call homedir() at call time). - session-context-copilot.test.ts created real ~/.copilot/session-state and tracked created dirs for cleanup. Now overrides Bun.env.HOME to a mkdtemp tempHome (copilotSessionStateDir reads Bun.env at call time). - plugin-install.test.ts was the worst case: installCursorPlugin / installOpencodePlugin tests mocked fetch and Bun.spawn but NOT the filesystem prep in installEditorPluginBundle - every test run created dirs under and DELETED archgate-* agents/skills from the developer's real ~/.cursor and ~/.config/opencode. Now redirects Bun.env.HOME + XDG_CONFIG_HOME to a mkdtemp tempHome file-wide. Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install.test.ts | 24 ++++++++++++++++ tests/helpers/session-context-copilot.test.ts | 28 ++++++++++--------- tests/helpers/session-context-cursor.test.ts | 25 +++++++++-------- tests/helpers/session-context.test.ts | 23 +++++++++------ 4 files changed, 67 insertions(+), 33 deletions(-) diff --git a/tests/helpers/plugin-install.test.ts b/tests/helpers/plugin-install.test.ts index 2c791119..34ca0a45 100644 --- a/tests/helpers/plugin-install.test.ts +++ b/tests/helpers/plugin-install.test.ts @@ -9,6 +9,9 @@ import { spyOn, test, } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; // --------------------------------------------------------------------------- // Module mocks — declared before imports that use them. @@ -91,18 +94,39 @@ function mockFetch(status: number, body: ArrayBuffer | null = null): void { // --------------------------------------------------------------------------- let spawnSpy: ReturnType; +let tempHome: string; +let savedHome: string | undefined; +let savedXdg: string | undefined; beforeEach(() => { originalFetch = globalThis.fetch; mockResolveCommand.mockReset(); mockResolveCommand.mockImplementation(() => Promise.resolve(null)); spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => fakeSpawnResult(0)); + + // Redirect user-scope paths into a temp dir. The install functions create + // directories AND delete stale archgate-* files under cursorUserDir() / + // opencodeConfigDir() / internalPath() before the (mocked) tar extraction — + // without this override they destroy the developer's real installed plugin + // files in ~/.cursor and ~/.config/opencode. All three resolvers read + // Bun.env.HOME / XDG_CONFIG_HOME at call time, so an env override works. + tempHome = mkdtempSync(join(tmpdir(), "archgate-plugin-install-")); + savedHome = Bun.env.HOME; + savedXdg = Bun.env.XDG_CONFIG_HOME; + Bun.env.HOME = tempHome; + delete Bun.env.XDG_CONFIG_HOME; }); afterEach(() => { globalThis.fetch = originalFetch; spawnSpy.mockRestore(); mock.restore(); + + if (savedHome === undefined) delete Bun.env.HOME; + else Bun.env.HOME = savedHome; + if (savedXdg === undefined) delete Bun.env.XDG_CONFIG_HOME; + else Bun.env.XDG_CONFIG_HOME = savedXdg; + rmSync(tempHome, { recursive: true, force: true }); }); // --------------------------------------------------------------------------- diff --git a/tests/helpers/session-context-copilot.test.ts b/tests/helpers/session-context-copilot.test.ts index 4cefa479..301f45c0 100644 --- a/tests/helpers/session-context-copilot.test.ts +++ b/tests/helpers/session-context-copilot.test.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { readCopilotSession } from "../../src/helpers/session-context-copilot"; @@ -13,24 +13,28 @@ describe("readCopilotSession", () => { const uniqueId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // Use a fake project root that we'll put in workspace.yaml cwd const projectRoot = resolve(`/__archgate_copilot_test_${uniqueId}`); + let tempHome: string; + let savedHome: string | undefined; let stateDir: string; beforeEach(() => { - stateDir = join(homedir(), ".copilot", "session-state"); + // Redirect copilotSessionStateDir() into a temp dir so these tests never + // touch the real ~/.copilot/session-state. The resolver reads Bun.env.HOME + // at call time (paths.ts), so an env override works here — unlike + // os.homedir() consumers, which require mocking (ARCH-005). + tempHome = mkdtempSync(join(tmpdir(), "archgate-copilot-session-")); + savedHome = Bun.env.HOME; + Bun.env.HOME = tempHome; + stateDir = join(tempHome, ".copilot", "session-state"); mkdirSync(stateDir, { recursive: true }); }); afterEach(() => { - // Clean up only the session dirs we created (identifiable by uniqueId in events) - // We use a tracking array instead - for (const dir of createdDirs) { - rmSync(dir, { recursive: true, force: true }); - } - createdDirs.length = 0; + if (savedHome === undefined) delete Bun.env.HOME; + else Bun.env.HOME = savedHome; + rmSync(tempHome, { recursive: true, force: true }); }); - const createdDirs: string[] = []; - function makeSession( sessionId: string, cwd: string, @@ -38,7 +42,6 @@ describe("readCopilotSession", () => { ): void { const sessionDir = join(stateDir, sessionId); mkdirSync(sessionDir, { recursive: true }); - createdDirs.push(sessionDir); // Write workspace.yaml — use JSON.stringify to escape backslashes // in Windows paths (YAML double-quoted strings use the same escaping) @@ -186,7 +189,6 @@ describe("readCopilotSession", () => { const sessionId = `copilot-${uniqueId}-bad`; const sessionDir = join(stateDir, sessionId); mkdirSync(sessionDir, { recursive: true }); - createdDirs.push(sessionDir); writeFileSync( join(sessionDir, "workspace.yaml"), `cwd: ${JSON.stringify(projectRoot)}\nid: ${JSON.stringify(sessionId)}\n` diff --git a/tests/helpers/session-context-cursor.test.ts b/tests/helpers/session-context-cursor.test.ts index eefd1fed..77e07c13 100644 --- a/tests/helpers/session-context-cursor.test.ts +++ b/tests/helpers/session-context-cursor.test.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as os from "node:os"; import { join } from "node:path"; import { readCursorSession } from "../../src/helpers/session-context"; @@ -11,20 +11,24 @@ import { readCursorSession } from "../../src/helpers/session-context"; // Error cases for readCursorSession live in session-context.test.ts. describe("readCursorSession", () => { - // Use a unique encoded project name under the *real* homedir so that - // homedir() caching on Linux doesn't break the tests. - const uniqueId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const projectRoot = `/__archgate_cursor_test_${uniqueId}`; + // Redirect homedir() into a temp dir so these tests never touch the real + // ~/.cursor/projects. A HOME env override does NOT work here — Bun caches + // homedir() on Linux — so the implementation is mocked instead (ARCH-005). + const projectRoot = "/__archgate_cursor_test_project"; const encodedProject = projectRoot .replaceAll("/", "-") .replaceAll("\\", "-") .replaceAll(":", "") .replaceAll(".", "-"); + let tempHome: string; + let homedirSpy: ReturnType; let transcriptsDir: string; beforeEach(() => { + tempHome = mkdtempSync(join(os.tmpdir(), "archgate-cursor-session-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(tempHome); transcriptsDir = join( - homedir(), + tempHome, ".cursor", "projects", encodedProject, @@ -34,9 +38,8 @@ describe("readCursorSession", () => { }); afterEach(() => { - // Clean up from .cursor/projects/ level - const projectDir = join(homedir(), ".cursor", "projects", encodedProject); - rmSync(projectDir, { recursive: true, force: true }); + homedirSpy.mockRestore(); + rmSync(tempHome, { recursive: true, force: true }); }); function makeSession(sessionId: string, lines: string[]): void { diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index d0099c7c..70ec25e9 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as os from "node:os"; import { join } from "node:path"; import { @@ -102,24 +102,29 @@ describe("readClaudeCodeSession", () => { }); describe("happy path", () => { - // Use a unique encoded project name under the *real* homedir so that - // homedir() caching on Linux doesn't break the tests. - const uniqueId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const projectRoot = `/__archgate_test_${uniqueId}`; + // Redirect homedir() into a temp dir so these tests never touch the real + // ~/.claude/projects. A HOME env override does NOT work here — Bun caches + // homedir() on Linux — so the implementation is mocked instead (ARCH-005). + const projectRoot = "/__archgate_test_project"; const encodedProject = projectRoot .replaceAll("/", "-") .replaceAll("\\", "-") .replaceAll(":", "-") .replaceAll(".", "-"); + let tempHome: string; + let homedirSpy: ReturnType; let projectsDir: string; beforeEach(() => { - projectsDir = join(homedir(), ".claude", "projects", encodedProject); + tempHome = mkdtempSync(join(os.tmpdir(), "archgate-claude-session-")); + homedirSpy = spyOn(os, "homedir").mockReturnValue(tempHome); + projectsDir = join(tempHome, ".claude", "projects", encodedProject); mkdirSync(projectsDir, { recursive: true }); }); afterEach(() => { - rmSync(projectsDir, { recursive: true, force: true }); + homedirSpy.mockRestore(); + rmSync(tempHome, { recursive: true, force: true }); }); function writeSession(entries: object[]): void { From 2945079cd4ecccf576f072cbf94159d276097303 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 11:08:18 +0200 Subject: [PATCH 3/9] refactor(tests): replace first-party mock.module with spyOn in plugin-install tests mock.module("../../src/helpers/platform") is process-global: it replaces the whole platform module (only resolveCommand exported, everything else undefined) for every test file in the run and is not undone by mock.restore(). ARCH-005 forbids mock.module on first-party modules for exactly this reason - it is the same order-dependent flake class as the user-scope pollution fixed in the previous commits. Convert to the documented pattern: import * as platform + per-test spyOn(platform, "resolveCommand") installed in beforeEach and restored via mock.restore() in afterEach. The spy reaches the named import inside plugin-install.ts through Bun's live bindings. Full suite passes, confirming no other test file depended on the leaked global mock. Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install.test.ts | 29 +++++++++++++--------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/tests/helpers/plugin-install.test.ts b/tests/helpers/plugin-install.test.ts index 34ca0a45..a5b2bef5 100644 --- a/tests/helpers/plugin-install.test.ts +++ b/tests/helpers/plugin-install.test.ts @@ -12,23 +12,11 @@ import { import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; - -// --------------------------------------------------------------------------- -// Module mocks — declared before imports that use them. -// --------------------------------------------------------------------------- - -/** Mock resolveCommand so CLI availability checks are deterministic. */ -const mockResolveCommand = mock<(name: string) => Promise>(() => - Promise.resolve(null) -); -mock.module("../../src/helpers/platform", () => ({ - resolveCommand: mockResolveCommand, -})); - // --------------------------------------------------------------------------- -// Imports under test — loaded AFTER mocks are registered. +// Imports under test // --------------------------------------------------------------------------- +import * as platform from "../../src/helpers/platform"; import { buildCursorMarketplaceUrl, buildMarketplaceUrl, @@ -98,10 +86,19 @@ let tempHome: string; let savedHome: string | undefined; let savedXdg: string | undefined; +/** + * Per-test spy on resolveCommand so CLI availability checks are deterministic. + * spyOn (not mock.module) — mock.module on a first-party module is + * process-global, replaces the WHOLE module for every other test file, and is + * not undone by mock.restore() (ARCH-005). + */ +let mockResolveCommand: ReturnType; + beforeEach(() => { originalFetch = globalThis.fetch; - mockResolveCommand.mockReset(); - mockResolveCommand.mockImplementation(() => Promise.resolve(null)); + mockResolveCommand = spyOn(platform, "resolveCommand").mockImplementation( + () => Promise.resolve(null) + ); spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => fakeSpawnResult(0)); // Redirect user-scope paths into a temp dir. The install functions create From 9fb0be866522f989a12039a6b8a9771ae818633b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 11:25:07 +0200 Subject: [PATCH 4/9] test: explicitly restore mockResolveCommand spy in afterEach mock.restore() already restores all spyOn spies, but restore it explicitly for symmetry with spawnSpy and clearer intent. (CodeRabbit review feedback on PR #401.) Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers/plugin-install.test.ts b/tests/helpers/plugin-install.test.ts index a5b2bef5..d91f8183 100644 --- a/tests/helpers/plugin-install.test.ts +++ b/tests/helpers/plugin-install.test.ts @@ -117,6 +117,7 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch; spawnSpy.mockRestore(); + mockResolveCommand.mockRestore(); mock.restore(); if (savedHome === undefined) delete Bun.env.HOME; From 75a5cccfd8fb2a78135aadcf67dd02609ec5c2cf Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 11:41:29 +0200 Subject: [PATCH 5/9] fix(tests): remove remaining platform/paths mock.module in plugin-install-cleanup CI failed after the previous commit removed plugin-install.test.ts's mock.module("platform"): plugin-install-cleanup.test.ts ALSO globally mocked the platform module (and paths). On Linux file order the cleanup file runs first, its leaked module mock is what plugin-install's new spyOn wrapped and "restored" to, and platform.test.ts / doctor.test.ts then saw a bare mock returning undefined instead of the real resolveCommand. The two competing mock.modules had been masking each other; removing only one exposed the other. Convert the last first-party mock.modules to the ARCH-005 patterns: - platform: per-test spyOn(platform, "resolveCommand") in beforeEach, restored in afterEach. - paths: no mock at all - cursorUserDir(), opencodeConfigDir(), and internalPath() read Bun.env.HOME / XDG_CONFIG_HOME at call time, so the existing tempDir env override (now including HOME, saved and restored per-test) isolates the real resolvers. Drops the cursorDirOverride indirection. - env restore now deletes originally-unset keys instead of assigning undefined. Signed-off-by: Rhuan Barreto --- tests/helpers/plugin-install-cleanup.test.ts | 71 +++++++------------- 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/tests/helpers/plugin-install-cleanup.test.ts b/tests/helpers/plugin-install-cleanup.test.ts index 83805139..e85e0c11 100644 --- a/tests/helpers/plugin-install-cleanup.test.ts +++ b/tests/helpers/plugin-install-cleanup.test.ts @@ -19,42 +19,11 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; - -// --------------------------------------------------------------------------- -// Module mocks — declared before imports that use them. -// --------------------------------------------------------------------------- - -const mockResolveCommand = mock<(name: string) => Promise>(() => - Promise.resolve(null) -); -mock.module("../../src/helpers/platform", () => ({ - resolveCommand: mockResolveCommand, -})); - -// Mock paths so cursorUserDir can be redirected without modifying -// Bun.env.HOME — env changes leak to parallel test files because Bun -// runs all tests in a single process sharing Bun.env. -let cursorDirOverride: string | undefined; -mock.module("../../src/helpers/paths", () => ({ - cursorUserDir: () => - cursorDirOverride ?? - join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".cursor"), - internalPath: (...parts: string[]) => - join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".archgate", ...parts), - opencodeConfigDir: () => { - const xdg = Bun.env.XDG_CONFIG_HOME; - const base = - xdg && xdg !== "undefined" - ? xdg - : join(Bun.env.HOME ?? Bun.env.USERPROFILE ?? "", ".config"); - return join(base, "opencode"); - }, -})); - // --------------------------------------------------------------------------- -// Imports under test — loaded AFTER mocks are registered. +// Imports under test // --------------------------------------------------------------------------- +import * as platform from "../../src/helpers/platform"; import { installCursorPlugin, installOpencodePlugin, @@ -106,24 +75,41 @@ let tempDir: string; let savedHome: string | undefined; let savedXdg: string | undefined; +/** + * Per-test spy on resolveCommand so CLI availability checks are deterministic. + * spyOn (not mock.module) — mock.module on a first-party module is + * process-global, replaces the WHOLE module for every other test file, and is + * not undone by mock.restore() (ARCH-005). + */ +let mockResolveCommand: ReturnType; + beforeEach(() => { originalFetch = globalThis.fetch; - mockResolveCommand.mockReset(); - mockResolveCommand.mockImplementation(() => Promise.resolve(null)); + mockResolveCommand = spyOn(platform, "resolveCommand").mockImplementation( + () => Promise.resolve(null) + ); spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => fakeSpawnResult(0)); + // Redirect user-scope paths into a temp dir. cursorUserDir(), + // opencodeConfigDir(), and internalPath() all read Bun.env.HOME / + // XDG_CONFIG_HOME at call time, so an env override (saved and restored + // per-test) isolates the real paths.ts resolvers — no module mock needed. tempDir = realpathSync(mkdtempSync(join(tmpdir(), "archgate-plugin-test-"))); savedHome = Bun.env.HOME; savedXdg = Bun.env.XDG_CONFIG_HOME; + Bun.env.HOME = tempDir; Bun.env.XDG_CONFIG_HOME = tempDir; }); afterEach(() => { globalThis.fetch = originalFetch; spawnSpy.mockRestore(); + mockResolveCommand.mockRestore(); mock.restore(); - Bun.env.HOME = savedHome; - Bun.env.XDG_CONFIG_HOME = savedXdg; + if (savedHome === undefined) delete Bun.env.HOME; + else Bun.env.HOME = savedHome; + if (savedXdg === undefined) delete Bun.env.XDG_CONFIG_HOME; + else Bun.env.XDG_CONFIG_HOME = savedXdg; try { rmSync(tempDir, { recursive: true, force: true }); } catch { @@ -208,15 +194,8 @@ describe("plugin install — stale file cleanup", () => { }); describe("cursor", () => { - // Redirect cursorUserDir() via mock — NOT via Bun.env.HOME, because - // env changes leak to parallel test files in Bun's single-process runner. - beforeEach(() => { - cursorDirOverride = join(tempDir, ".cursor"); - }); - afterEach(() => { - cursorDirOverride = undefined; - }); - + // cursorUserDir() resolves to tempDir/.cursor via the Bun.env.HOME + // override installed in the file-level beforeEach. test("removes archgate-* agents and skills before extraction", async () => { const agentsDir = join(tempDir, ".cursor", "agents"); const skillsDir = join(tempDir, ".cursor", "skills"); From d7f14e7df91f67a96efc94c1dd3fb2ca8e5c160f Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 12:56:41 +0200 Subject: [PATCH 6/9] fix(install): verify release assets exist before trusting advertised version The static version endpoint (cli.archgate.dev/version.json) is deployed by Cloudflare when the release PR merges to main - before the GitHub release is created and 10-15 minutes before release-binaries.yml uploads the platform assets. During the v0.44 incident the release job failed after the merge, so the endpoint advertised a version with no installable assets and both install scripts failed. Harden version resolution in install.sh and install.ps1: - Verify the platform asset actually exists (HEAD request) before trusting any advertised version - On a miss, warn and fall back to walking the 10 most recent GitHub releases (newest first), installing the newest one whose asset exists - releases/latest alone is not enough since a release is published before its assets finish uploading - Pinned ARCHGATE_VERSION now fails fast with a clear error when its asset is missing instead of dying mid-download - Strip CR from jq/grep output in install.sh: jq on Windows Git Bash emits CRLF, which left a carriage return on every parsed tag and broke the release walk Signed-off-by: Rhuan Barreto --- install.ps1 | 55 +++++++++++++++++++++++++--- install.sh | 103 ++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 125 insertions(+), 33 deletions(-) diff --git a/install.ps1 b/install.ps1 index 89c83206..a3b84639 100644 --- a/install.ps1 +++ b/install.ps1 @@ -14,10 +14,37 @@ if ($Arch -ne "AMD64") { exit 1 } +# --- Release asset helpers --- + +function Get-AssetUrl { + param([string]$Version) + return "https://github.com/$Repo/releases/download/$Version/$Artifact.zip" +} + +# Returns $true when the platform asset for the given version tag actually +# exists on GitHub Releases. A version being advertised (version.json, +# releases/latest) does not guarantee its assets are uploaded yet - releases +# are published before the binary build workflow finishes, and a failed +# release pipeline can advertise a version that never gets assets at all. +function Test-AssetExists { + param([string]$Version) + try { + Invoke-WebRequest -Uri (Get-AssetUrl $Version) -Method Head -UseBasicParsing -ErrorAction Stop | Out-Null + return $true + } catch { + return $false + } +} + # --- Resolve version --- function Get-LatestVersion { if ($env:ARCHGATE_VERSION) { + if (-not (Test-AssetExists $env:ARCHGATE_VERSION)) { + Write-Host "Error: no $Artifact.zip asset found for pinned ARCHGATE_VERSION='$($env:ARCHGATE_VERSION)'." -ForegroundColor Red + Write-Host "Check that the release exists and has finished building: https://github.com/$Repo/releases" + return $null + } return $env:ARCHGATE_VERSION } @@ -25,24 +52,42 @@ function Get-LatestVersion { try { $versionInfo = Invoke-RestMethod -Uri "https://cli.archgate.dev/version.json" -ErrorAction Stop if ($versionInfo.version) { - return $versionInfo.version + # The version endpoint can advertise a release before its binaries + # are uploaded (or one whose release pipeline failed). Trust it + # only when the platform asset is actually downloadable. + if (Test-AssetExists $versionInfo.version) { + return $versionInfo.version + } + Write-Warning "$($versionInfo.version) is advertised but its release assets are not available yet (release may be in progress). Falling back to the newest installable release..." } } catch { # Fall through to GitHub API } - # Fallback: GitHub releases API + # Fallback: walk recent GitHub releases (newest first) and pick the first + # one whose platform asset exists. 'releases/latest' alone is not enough - + # it returns a release as soon as it is published, before assets upload. + $releases = $null try { $headers = @{ "Accept" = "application/vnd.github+json" } if ($env:GITHUB_TOKEN) { $headers["Authorization"] = "token $($env:GITHUB_TOKEN)" } - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers $headers -ErrorAction Stop - return $release.tag_name + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases?per_page=10" -Headers $headers -ErrorAction Stop } catch { Write-Error "Error: failed to query latest archgate version. Details: $($_.Exception.Message)" return $null } + + foreach ($release in $releases) { + if ($release.tag_name -and (Test-AssetExists $release.tag_name)) { + return $release.tag_name + } + } + + Write-Host "Error: none of the recent releases have a $Artifact.zip asset." -ForegroundColor Red + Write-Host "Visit https://github.com/$Repo/releases or https://cli.archgate.dev/getting-started/installation/ for alternative install methods." + return $null } $Version = Get-LatestVersion @@ -53,7 +98,7 @@ if (-not $Version) { # --- Download and install --- -$Url = "https://github.com/$Repo/releases/download/$Version/$Artifact.zip" +$Url = Get-AssetUrl $Version Write-Host "Installing archgate $Version ($Artifact)..." diff --git a/install.sh b/install.sh index fcd30c89..fd92e768 100644 --- a/install.sh +++ b/install.sh @@ -48,6 +48,34 @@ detect_platform() { fi ARTIFACT="archgate-${platform}-${arch}" + + if [ "$platform" = "win32" ]; then + EXT="zip" + else + EXT="tar.gz" + fi +} + +# --- Release asset helpers --- + +asset_url() { + echo "https://github.com/${REPO}/releases/download/${1}/${ARTIFACT}.${EXT}" +} + +# Returns 0 when the platform asset for the given version tag actually exists +# on GitHub Releases. A version being advertised (version.json, releases/latest) +# does not guarantee its assets are uploaded yet - releases are published +# before the binary build workflow finishes, and a failed release pipeline can +# advertise a version that never gets assets at all. +asset_exists() { + check_url="$(asset_url "$1")" + if command -v curl >/dev/null 2>&1; then + curl -fsIL -o /dev/null "$check_url" 2>/dev/null + elif command -v wget >/dev/null 2>&1; then + wget -q --spider "$check_url" 2>/dev/null + else + return 1 + fi } # --- Resolve version --- @@ -55,6 +83,11 @@ detect_platform() { resolve_version() { if [ -n "${ARCHGATE_VERSION:-}" ]; then VERSION="$ARCHGATE_VERSION" + if ! asset_exists "$VERSION"; then + echo "Error: no ${ARTIFACT}.${EXT} asset found for pinned ARCHGATE_VERSION='${VERSION}'." >&2 + echo "Check that the release exists and has finished building: https://github.com/${REPO}/releases" >&2 + exit 1 + fi return fi @@ -69,19 +102,30 @@ resolve_version() { fi if [ -n "$static_response" ]; then + # tr -d '\r': jq on Windows (Git Bash) emits CRLF line endings, which + # would otherwise leave a stray carriage return in the parsed value. if command -v jq >/dev/null 2>&1; then - static_version="$(printf '%s' "$static_response" | jq -r '.version // empty')" + static_version="$(printf '%s' "$static_response" | jq -r '.version // empty' | tr -d '\r')" else - static_version="$(printf '%s' "$static_response" | grep '"version"' | sed 's/.*"version": *"//;s/".*//')" + static_version="$(printf '%s' "$static_response" | grep '"version"' | sed 's/.*"version": *"//;s/".*//' | tr -d '\r')" fi if [ -n "$static_version" ]; then - VERSION="$static_version" - return + # The version endpoint can advertise a release before its binaries are + # uploaded (or one whose release pipeline failed). Trust it only when + # the platform asset is actually downloadable. + if asset_exists "$static_version"; then + VERSION="$static_version" + return + fi + echo "Warning: ${static_version} is advertised but its release assets are not available yet (release may be in progress)." >&2 + echo "Falling back to the newest installable release..." >&2 fi fi - # Fallback: GitHub releases API - api_url="https://api.github.com/repos/${REPO}/releases/latest" + # Fallback: walk recent GitHub releases (newest first) and pick the first + # one whose platform asset exists. 'releases/latest' alone is not enough - + # it returns a release as soon as it is published, before assets upload. + api_url="https://api.github.com/repos/${REPO}/releases?per_page=10" auth_header="" if [ -n "${GITHUB_TOKEN:-}" ]; then auth_header="Authorization: token ${GITHUB_TOKEN}" @@ -96,9 +140,9 @@ resolve_version() { exit 1 fi - # Basic sanity check that we got a JSON-like response + # Basic sanity check that we got a JSON array back case "$response" in - \{*) + \[*) ;; *) echo "Error: unexpected response from GitHub releases API." >&2 @@ -107,36 +151,39 @@ resolve_version() { ;; esac + # tr -d '\r': jq on Windows (Git Bash) emits CRLF line endings, which would + # otherwise leave a carriage return on every tag and fail validation below. if command -v jq >/dev/null 2>&1; then - VERSION="$(printf '%s' "$response" | jq -r '.tag_name // empty')" + tags="$(printf '%s' "$response" | jq -r '.[].tag_name // empty' | tr -d '\r' || true)" else - VERSION="$(printf '%s' "$response" | grep "tag_name" | sed 's/.*"tag_name": *"//;s/".*//')" + tags="$(printf '%s' "$response" | grep '"tag_name"' | sed 's/.*"tag_name": *"//;s/".*//' | tr -d '\r' || true)" fi - if [ -z "$VERSION" ]; then - echo "Error: could not determine latest version (empty tag_name)." >&2 + if [ -z "$tags" ]; then + echo "Error: could not determine latest version (no release tags found)." >&2 exit 1 fi - # Validate that VERSION looks reasonable (non-empty and not an obvious error) - case "$VERSION" in - *[!A-Za-z0-9._-]*) - echo "Error: invalid version tag received: '$VERSION'" >&2 - exit 1 - ;; - esac + for tag in $tags; do + # Validate that the tag looks reasonable before using it in a URL + case "$tag" in + *[!A-Za-z0-9._-]*) continue ;; + esac + if asset_exists "$tag"; then + VERSION="$tag" + return + fi + done + + echo "Error: none of the recent releases have a ${ARTIFACT}.${EXT} asset." >&2 + echo "Visit https://github.com/${REPO}/releases or https://cli.archgate.dev/getting-started/installation/ for alternative install methods." >&2 + exit 1 } # --- Download and install --- download_and_install() { - if [ "$platform" = "win32" ]; then - ext="zip" - else - ext="tar.gz" - fi - - url="https://github.com/${REPO}/releases/download/${VERSION}/${ARTIFACT}.${ext}" + url="$(asset_url "$VERSION")" echo "Installing archgate ${VERSION} (${ARTIFACT})..." @@ -144,9 +191,9 @@ download_and_install() { trap 'rm -rf "$tmpdir"' EXIT if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$tmpdir/archgate.${ext}" + curl -fsSL "$url" -o "$tmpdir/archgate.${EXT}" elif command -v wget >/dev/null 2>&1; then - wget -qO "$tmpdir/archgate.${ext}" "$url" + wget -qO "$tmpdir/archgate.${EXT}" "$url" else echo "Error: neither 'curl' nor 'wget' is installed. Please install one of them to download archgate." >&2 exit 1 From 1211aa01d1c3e79e30494f80940b93bc04a24cb6 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 12:56:41 +0200 Subject: [PATCH 7/9] fix(plugin-install): use Bun.file().json() instead of text() + JSON.parse Resolves the ARCH-010 prefer-bun-json warning in mergeCursorHooks. Signed-off-by: Rhuan Barreto --- src/helpers/plugin-install.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index d775c478..9ddd694e 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -166,9 +166,8 @@ async function mergeCursorHooks(cursorDir: string): Promise { if (!existsSync(hooksPath)) return; try { - const existing: { event: string; command?: string }[] = JSON.parse( - await Bun.file(hooksPath).text() - ); + const existing: { event: string; command?: string }[] = + await Bun.file(hooksPath).json(); // Remove any previous archgate hooks const filtered = existing.filter( From 652c0f9c5ef3001b560e8d9ac964e4854860ac1d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 12:56:50 +0200 Subject: [PATCH 8/9] chore(memory): capture release-race, jq CRLF, and review-context lessons - version.json is deployed before release assets exist (v0.44 incident) - jq on Windows Git Bash emits CRLF line endings - review-context misses uncommitted changes when a base ref is detected (tracked in #403) Signed-off-by: Rhuan Barreto --- .claude/agent-memory/archgate-developer/MEMORY.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index f4aae448..4b8e5983 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -54,6 +54,8 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **`Bun.env` modifications in parallel test files leak into integration test subprocesses** — Bun test runner runs all test files in a single process sharing `Bun.env`. Tests that set `Bun.env.HOME`, `Bun.env.GIT_CONFIG_NOSYSTEM`, or `Bun.env.GIT_CONFIG_GLOBAL` (e.g., `auth.test.ts`, `credential-store.test.ts`) modify the shared environment. Integration tests that spawn CLI subprocesses via `runCli()` spread `process.env` (which IS `Bun.env`) into the child, inheriting the leaked values. Symptom: git operations in the subprocess fail with "not a git repo" or similar, but the test passes in isolation. Fix: integration tests that rely on git must explicitly reset git-related env vars in the `runCli` call: `runCli(args, dir, { GIT_CONFIG_NOSYSTEM: "", GIT_CONFIG_GLOBAL: "" })`. Applied in `tests/integration/check.test.ts` for the `--base` tests. - **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`. - **macOS `/var` → `/private/var` symlink breaks temp dir path comparisons in tests** — On macOS, `/var` is a symlink to `/private/var`. `mkdtempSync(join(tmpdir(), ...))` returns `/var/folders/...` but `process.cwd()` after `chdir()` resolves the symlink to `/private/var/folders/...`. Tests that compare `tempDir` against paths derived from `process.cwd()` or `findProjectRoot()` will fail. Fix: always wrap `mkdtempSync` with `realpathSync` in test setup: `tempDir = realpathSync(mkdtempSync(join(tmpdir(), "archgate-test-")))`. This normalizes the path upfront. Discovered in v0.38.0/v0.39.0 release builds — PR CI runs on ubuntu-latest only, so macOS-specific issues are invisible until the release workflow. +- **jq on Windows Git Bash emits CRLF line endings** — `jq -r` output ends lines with `\r\n`. In sh scripts, command substitution strips only trailing newlines, so parsed values carry a trailing `\r` (and multi-line lists get `\r` on every entry except the last). Symptom: charset validations reject valid values, or URLs get an embedded CR. Fix: pipe jq (and grep/sed fallbacks) through `tr -d '\r'`. Bit us in `install.sh` resolve_version — the release-walk skipped every tag except the last one. +- **`archgate review-context` misses uncommitted changes when a base ref is detected** — `getFilesChangedSinceRef` (src/engine/git-files.ts) runs `git diff base...HEAD`, which only sees COMMITTED changes. During a normal dev session (Write/Edit tools, nothing committed), `review-context` lists the branch's committed files but silently omits the working-tree edits actually under review. Workaround: scope reviewer sub-agents manually from `git status` / `git diff --name-only main`. Tracked in issue #403; candidate CLI fix: union `base...HEAD` with `getChangedFiles()` (staged+unstaged). - **Don't test that well-known tools exist on PATH** — Tests like `expect(resolveCommand("bun")).toBe("bun")` assert CI environment state, not application logic. They fail when the runner installs tools via shims (e.g., proto on macOS ARM64 where `Bun.which` returns null). Delete such tests entirely — the "returns null for non-existent command" tests already cover `resolveCommand`'s actual logic, and WSL-specific tests cover the `.exe` fallback path. ## Translation Quality @@ -75,4 +77,5 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **npm shim + GitHub Releases** — The npm package is a thin shim (`bin/archgate.cjs`). On first run, the shim downloads the platform binary from GitHub Releases and caches it to `~/.archgate/bin/`. No platform-specific npm packages. - **`.cjs` extension is mandatory** — Root `package.json` has `"type": "module"`. Any Node.js CJS wrapper script placed at the package root MUST use `.cjs`, not `.js`, or Node.js will attempt to parse it as ESM and fail. - [Shim publishing pipeline gotchas](project_shim_publishing.md) — PyPI README, RubyGem Rakefile/working-dir, Maven waitUntil; build reqs not caught by `archgate check` +- **Advertised version != installable version** — `docs/public/version.json` is committed in the release PR and deployed by Cloudflare on merge to main, BEFORE `release.yml` creates the GitHub release and `release-binaries.yml` uploads assets (~15-25 min gap; indefinite if the release job fails, as in the v0.44 incident). `install.sh`/`install.ps1` therefore verify the platform asset exists (HEAD request) before trusting any advertised version, and fall back to walking `releases?per_page=10` for the newest release whose asset exists. The shims (npm/pypi/go/etc.) pin version constants at release time and share this exposure — if they get the same hardening, codify the rule in ARCH-017. - **Registering a subdir Go module on pkg.go.dev** — A subdir Go module's zip only contains files under its subtree, so the repo-root `LICENSE.md` is excluded and pkg.go.dev shows "no license" until `shims/go/LICENSE.md` exists (the shim LICENSE sync is enforced by ARCH-013). To trigger registration, hit the proxy: `curl https://proxy.golang.org//@v/.info`. From 724041bd9cbfb89ed19970b790fc92902cc70ade Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 10 Jun 2026 13:05:15 +0200 Subject: [PATCH 9/9] fix(tests): exercise the missing session-state dir branch; log version.json fallback Address CodeRabbit review on PR #404: - session-context-copilot: beforeEach always created stateDir, so the 'directory does not exist' test never hit that branch. Remove the dir inside the test and assert the specific error message. - install.ps1: log the version.json lookup failure via Write-Verbose instead of swallowing it silently before falling back to the GitHub API. Signed-off-by: Rhuan Barreto --- install.ps1 | 2 +- tests/helpers/session-context-copilot.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/install.ps1 b/install.ps1 index a3b84639..9b341b5b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -61,7 +61,7 @@ function Get-LatestVersion { Write-Warning "$($versionInfo.version) is advertised but its release assets are not available yet (release may be in progress). Falling back to the newest installable release..." } } catch { - # Fall through to GitHub API + Write-Verbose "version.json lookup failed: $($_.Exception.Message); falling back to GitHub API" } # Fallback: walk recent GitHub releases (newest first) and pick the first diff --git a/tests/helpers/session-context-copilot.test.ts b/tests/helpers/session-context-copilot.test.ts index 301f45c0..43f0f292 100644 --- a/tests/helpers/session-context-copilot.test.ts +++ b/tests/helpers/session-context-copilot.test.ts @@ -227,12 +227,16 @@ describe("readCopilotSession", () => { }); test("returns error when session-state directory does not exist", async () => { + // beforeEach creates stateDir — remove it so this test actually exercises + // the missing-directory branch (afterEach recreates the temp home anyway) + rmSync(stateDir, { recursive: true, force: true }); const result = await readCopilotSession( "/nonexistent/path/that/wont/match" ); - // This may return "no sessions found for this project" or "no session-state dir" - // depending on whether ~/.copilot/session-state/ exists expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("No Copilot CLI session-state directory found"); + } }); test("skip option reads the second-most-recent matching session", async () => {