Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<parent>/` 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
Expand All @@ -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/<module>/@v/<version>.info`.
57 changes: 51 additions & 6 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,80 @@ 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
}

# Primary: static version endpoint (no rate limits)
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"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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
Expand All @@ -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)..."

Expand Down
103 changes: 75 additions & 28 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,46 @@ 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 ---

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

Expand All @@ -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}"
Expand All @@ -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
Expand All @@ -107,46 +151,49 @@ 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})..."

tmpdir="$(mktemp -d)"
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
Expand Down
5 changes: 2 additions & 3 deletions src/helpers/plugin-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,8 @@ async function mergeCursorHooks(cursorDir: string): Promise<void> {
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(
Expand Down
8 changes: 6 additions & 2 deletions tests/helpers/session-context-copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading