diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index f4aae44..4b8e598 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`. diff --git a/install.ps1 b/install.ps1 index 89c8320..9b341b5 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 + Write-Verbose "version.json lookup failed: $($_.Exception.Message); falling back 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 fcd30c8..fd92e76 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 diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index d775c47..9ddd694 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( diff --git a/tests/helpers/session-context-copilot.test.ts b/tests/helpers/session-context-copilot.test.ts index 301f45c..43f0f29 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 () => {