From f76d47bf7a3568aa6dffc8f267e2d92ff9511370 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 28 May 2026 16:18:59 +0200 Subject: [PATCH 01/19] feat: add multi-ecosystem shim distribution (PyPI, NuGet, Go, Maven, RubyGems) Replicate the existing npm thin-shim pattern for five additional package managers so users can install archgate via their preferred ecosystem. Each shim is a zero-dependency wrapper that downloads the platform binary from GitHub Releases on first invocation, verifies SHA256 checksums, and caches to ~/.archgate/bin/. All shims share the same cache directory. Changes: - Relocate npm shim from bin/ to shims/npm/ and add SHA256 verification - Add PyPI shim (Python 3.8+ stdlib only) - Add NuGet .NET global tool shim (.NET 8.0) - Add Go module shim (Go 1.21+ stdlib only) - Add Maven Central + jbang shim (Java 11+ stdlib only) - Add RubyGem shim (Ruby 2.7+ stdlib only) - Extend .simple-release.js to sync versions across all 6 shim packages - Add publish-shims.yml CI workflow (triggers on release, publishes all) - Add shim-tests job to code-pull-request.yml (PyPI + Go unit tests) - Extend ARCH-013 ADR + rules with shim-version-sync enforcement - Create DIST-001 ADR documenting the multi-ecosystem distribution pattern - Update installation docs with new package manager instructions Signed-off-by: Rhuan Barreto --- .../adrs/ARCH-013-version-synchronization.md | 24 +- .../ARCH-013-version-synchronization.rules.ts | 70 +++++ .../DIST-001-multi-ecosystem-distribution.md | 84 +++++ .github/workflows/code-pull-request.yml | 40 +++ .github/workflows/publish-shims.yml | 158 ++++++++++ .gitignore | 15 +- .npmignore | 3 +- .simple-release.js | 81 +++++ .../docs/getting-started/installation.mdx | 62 +++- package.json | 4 +- shims/go/cmd/archgate/main.go | 11 + shims/go/go.mod | 3 + shims/go/internal/shim/exec_unix.go | 17 ++ shims/go/internal/shim/exec_windows.go | 23 ++ shims/go/internal/shim/platform.go | 46 +++ shims/go/internal/shim/shim.go | 214 +++++++++++++ shims/go/internal/shim/shim_test.go | 123 ++++++++ shims/maven/jbang-catalog.json | 9 + shims/maven/pom.xml | 136 +++++++++ .../main/java/dev/archgate/cli/Platform.java | 62 ++++ .../src/main/java/dev/archgate/cli/Shim.java | 289 ++++++++++++++++++ .../test/java/dev/archgate/cli/ShimTest.java | 125 ++++++++ {bin => shims/npm}/archgate.cjs | 84 +++-- .../nuget/Archgate.Tool/Archgate.Tool.csproj | 19 ++ shims/nuget/Archgate.Tool/Program.cs | 250 +++++++++++++++ .../Archgate.Tool.Tests.csproj | 27 ++ .../tests/Archgate.Tool.Tests/ShimTests.cs | 85 ++++++ shims/pypi/archgate/__init__.py | 1 + shims/pypi/archgate/__main__.py | 3 + shims/pypi/archgate/_shim.py | 194 ++++++++++++ shims/pypi/archgate/_version.py | 1 + shims/pypi/pyproject.toml | 45 +++ shims/pypi/tests/test_shim.py | 144 +++++++++ shims/rubygem/Gemfile | 9 + shims/rubygem/archgate.gemspec | 25 ++ shims/rubygem/exe/archgate | 6 + shims/rubygem/lib/archgate.rb | 4 + shims/rubygem/lib/archgate/shim.rb | 207 +++++++++++++ shims/rubygem/lib/archgate/version.rb | 5 + shims/rubygem/test/test_shim.rb | 135 ++++++++ 40 files changed, 2812 insertions(+), 31 deletions(-) create mode 100644 .archgate/adrs/DIST-001-multi-ecosystem-distribution.md create mode 100644 .github/workflows/publish-shims.yml create mode 100644 shims/go/cmd/archgate/main.go create mode 100644 shims/go/go.mod create mode 100644 shims/go/internal/shim/exec_unix.go create mode 100644 shims/go/internal/shim/exec_windows.go create mode 100644 shims/go/internal/shim/platform.go create mode 100644 shims/go/internal/shim/shim.go create mode 100644 shims/go/internal/shim/shim_test.go create mode 100644 shims/maven/jbang-catalog.json create mode 100644 shims/maven/pom.xml create mode 100644 shims/maven/src/main/java/dev/archgate/cli/Platform.java create mode 100644 shims/maven/src/main/java/dev/archgate/cli/Shim.java create mode 100644 shims/maven/src/test/java/dev/archgate/cli/ShimTest.java rename {bin => shims/npm}/archgate.cjs (72%) mode change 100755 => 100644 create mode 100644 shims/nuget/Archgate.Tool/Archgate.Tool.csproj create mode 100644 shims/nuget/Archgate.Tool/Program.cs create mode 100644 shims/nuget/tests/Archgate.Tool.Tests/Archgate.Tool.Tests.csproj create mode 100644 shims/nuget/tests/Archgate.Tool.Tests/ShimTests.cs create mode 100644 shims/pypi/archgate/__init__.py create mode 100644 shims/pypi/archgate/__main__.py create mode 100644 shims/pypi/archgate/_shim.py create mode 100644 shims/pypi/archgate/_version.py create mode 100644 shims/pypi/pyproject.toml create mode 100644 shims/pypi/tests/test_shim.py create mode 100644 shims/rubygem/Gemfile create mode 100644 shims/rubygem/archgate.gemspec create mode 100644 shims/rubygem/exe/archgate create mode 100644 shims/rubygem/lib/archgate.rb create mode 100644 shims/rubygem/lib/archgate/shim.rb create mode 100644 shims/rubygem/lib/archgate/version.rb create mode 100644 shims/rubygem/test/test_shim.rb diff --git a/.archgate/adrs/ARCH-013-version-synchronization.md b/.archgate/adrs/ARCH-013-version-synchronization.md index 0396d8f6..c10d54cc 100644 --- a/.archgate/adrs/ARCH-013-version-synchronization.md +++ b/.archgate/adrs/ARCH-013-version-synchronization.md @@ -3,7 +3,7 @@ id: ARCH-013 title: Version Synchronization domain: architecture rules: true -files: ["package.json", "docs/**"] +files: ["package.json", "docs/**", "shims/**"] --- # Version Synchronization @@ -14,25 +14,35 @@ The CLI version appears in multiple locations that must stay in sync: 1. `package.json` `version` — canonical source of truth 2. `docs/astro.config.mjs` — `softwareVersion` in the JSON-LD structured data +3. `shims/pypi/pyproject.toml` — PyPI package version +4. `shims/pypi/archgate/_version.py` — Python `__version__` constant +5. `shims/nuget/Archgate.Tool/Archgate.Tool.csproj` — NuGet package version +6. `shims/go/internal/shim/shim.go` — Go `Version` constant +7. `shims/maven/pom.xml` — Maven artifact version +8. `shims/rubygem/lib/archgate/version.rb` — RubyGem `VERSION` constant -When versions diverge, search engines display outdated version info. This was discovered during a consistency review where `package.json` was at `0.16.0` but `docs/astro.config.mjs` was still at `0.11.0`. +When versions diverge, users installing via different package managers get mismatched binaries. This was discovered during a consistency review where `package.json` was at `0.16.0` but `docs/astro.config.mjs` was still at `0.11.0`. ## Decision `package.json` `version` is the single source of truth. All other version references MUST match it. -**Automated via release process:** The `.simple-release.js` bump hook updates `softwareVersion` in `docs/astro.config.mjs` to match `package.json`. This is fully automated and requires no manual intervention. +**Automated via release process:** The `.simple-release.js` bump hook updates all version locations to match `package.json` during the release commit. This is fully automated and requires no manual intervention. + +The shim packages (npm, PyPI, NuGet, Go, Maven Central, RubyGems) are thin wrappers that download the platform binary from GitHub Releases. Their embedded version determines which release to download, so version drift causes download failures (404) or installs the wrong version. ## Do's and Don'ts ### Do -- Rely on `.simple-release.js` for `softwareVersion` sync (do not update manually) +- Rely on `.simple-release.js` for all version sync (do not update manually) - Use the companion rules to catch version drift in CI as a safety net +- When adding a new shim ecosystem, add its version file to `.simple-release.js` and the companion rules ### Don't - Don't manually edit `softwareVersion` in `docs/astro.config.mjs` — the release hook handles this +- Don't manually edit version strings in any `shims/` package — the release hook handles this ## Consequences @@ -49,10 +59,12 @@ When versions diverge, search engines display outdated version info. This was di ### Automated Enforcement -- **Release hook** `.simple-release.js`: Syncs `docs/astro.config.mjs` `softwareVersion` during `bump()`. Fully automated. +- **Release hook** `.simple-release.js`: Syncs all version locations during `bump()`. Fully automated. - **Archgate rule** `ARCH-013/docs-version-sync`: Checks that `softwareVersion` in `docs/astro.config.mjs` matches `package.json` version. Severity: `error`. +- **Archgate rule** `ARCH-013/shim-version-sync`: Checks that all shim package versions match `package.json` version. Severity: `error`. ## References - [GEN-001 — Documentation Site](./GEN-001-documentation-site.md) — Docs site structure and configuration -- [`.simple-release.js`](../../.simple-release.js) — Release bump hook that syncs softwareVersion +- [DIST-001 — Multi-Ecosystem Distribution](./DIST-001-multi-ecosystem-distribution.md) — Shim pattern and behavioral contract +- [`.simple-release.js`](../../.simple-release.js) — Release bump hook that syncs all version locations diff --git a/.archgate/adrs/ARCH-013-version-synchronization.rules.ts b/.archgate/adrs/ARCH-013-version-synchronization.rules.ts index 7edd8239..352e0858 100644 --- a/.archgate/adrs/ARCH-013-version-synchronization.rules.ts +++ b/.archgate/adrs/ARCH-013-version-synchronization.rules.ts @@ -31,5 +31,75 @@ export default { } }, }, + "shim-version-sync": { + description: "All shim package versions must match package.json version", + severity: "error", + async check(ctx) { + const pkgJson = await ctx.readJSON("package.json"); + if (!pkgJson.version) return; + const expected = pkgJson.version as string; + + const shimFiles: Array<{ + file: string; + pattern: RegExp; + label: string; + }> = [ + { + file: "shims/pypi/pyproject.toml", + pattern: /^version\s*=\s*"([^"]+)"/mu, + label: "PyPI pyproject.toml", + }, + { + file: "shims/pypi/archgate/_version.py", + pattern: /__version__\s*=\s*"([^"]+)"/u, + label: "PyPI _version.py", + }, + { + file: "shims/nuget/Archgate.Tool/Archgate.Tool.csproj", + pattern: /([^<]+)<\/Version>/u, + label: "NuGet .csproj", + }, + { + file: "shims/go/internal/shim/shim.go", + pattern: /const Version = "([^"]+)"/u, + label: "Go shim.go", + }, + { + file: "shims/maven/pom.xml", + pattern: + /archgate-cli<\/artifactId>\s*([^<]+)<\/version>/u, + label: "Maven pom.xml", + }, + { + file: "shims/rubygem/lib/archgate/version.rb", + pattern: /VERSION\s*=\s*"([^"]+)"/u, + label: "RubyGem version.rb", + }, + ]; + + for (const { file, pattern, label } of shimFiles) { + let content: string; + try { + // oxlint-disable-next-line no-await-in-loop -- sequential read is intentional; files are few and order-independent but must check each + content = await ctx.readFile(file); + } catch { + // Shim file may not exist yet + continue; + } + + const match = content.match(pattern); + if (!match) continue; + + const shimVersion = match[1]; + if (shimVersion !== expected) { + ctx.report.violation({ + message: `${label} version "${shimVersion}" does not match package.json version "${expected}"`, + file, + fix: `Update version to "${expected}" in ${file} (automated by .simple-release.js)`, + }); + } + } + }, + }, }, } satisfies RuleSet; diff --git a/.archgate/adrs/DIST-001-multi-ecosystem-distribution.md b/.archgate/adrs/DIST-001-multi-ecosystem-distribution.md new file mode 100644 index 00000000..52e0e4b2 --- /dev/null +++ b/.archgate/adrs/DIST-001-multi-ecosystem-distribution.md @@ -0,0 +1,84 @@ +--- +id: DIST-001 +title: Multi-Ecosystem Distribution +domain: distribution +rules: false +--- + +# Multi-Ecosystem Distribution + +## Context + +The archgate CLI is a standalone binary compiled with Bun. To maximize reach, it is distributed through multiple package managers (npm, PyPI, NuGet, Go, Maven Central, RubyGems) using a "thin shim" pattern: each package contains a minimal wrapper in the target ecosystem's language that downloads and caches the platform binary from GitHub Releases on first invocation. + +## Decision + +All distribution shims live under `shims/` in the main repository. Each shim is a self-contained package for its target ecosystem with zero runtime dependencies beyond the ecosystem's own standard library. + +### Shared Behavioral Contract + +Every shim implements the same algorithm: + +1. Detect platform/architecture and map to artifact name (`archgate-darwin-arm64`, `archgate-linux-x64`, `archgate-win32-x64`) +2. Check for cached binary at `~/.archgate/bin/archgate[.exe]` +3. If missing, download from `https://github.com/archgate/cli/releases/download/v{VERSION}/{artifact}.{ext}` +4. Verify SHA256 checksum against the companion `.sha256` file +5. Extract binary with proper permissions (0755 on Unix) +6. Execute the binary, forwarding all arguments and inheriting stdio +7. Propagate the exit code + +### Shared Cache + +All shim packages share the same cache directory (`~/.archgate/bin/`). If the binary is already cached by any install method (npm, pip, standalone installer, etc.), no download occurs. + +### Error Messages + +All shims produce identical user-facing error messages on stderr: + +- Unsupported platform: `archgate: Unsupported platform: {os}/{arch}\narchgate supports darwin/arm64, linux/x64, and win32/x64.` +- Download failure: `archgate: failed to download binary: {detail}\nVisit https://cli.archgate.dev/getting-started/installation/ for alternative install methods.` +- Checksum mismatch: `archgate: checksum verification failed for v{version} (expected {expected}, got {actual})` +- Download started: `archgate: binary not found, downloading v{version}...` +- Download complete: `archgate: binary downloaded successfully.` + +### Version Synchronization + +`package.json` `version` is the single source of truth. The `.simple-release.js` bump hook updates all shim version files automatically during the release commit. See ARCH-013 for enforcement details. + +## Do's and Don'ts + +### Do + +- Use only the target ecosystem's standard library (zero runtime dependencies) +- Share the `~/.archgate/bin/` cache directory across all shim packages +- Verify SHA256 checksums before extracting downloaded archives +- Use identical error messages across all shims +- Add new shim version files to `.simple-release.js` and the ARCH-013 companion rules + +### Don't + +- Don't bundle the compiled binary into any shim package (download on demand) +- Don't add runtime dependencies to any shim package +- Don't use a different cache location per ecosystem +- Don't skip SHA256 verification + +## Consequences + +### Positive + +- Users can install archgate through their preferred package manager without requiring Node.js or Bun +- All install methods converge on the same cached binary, avoiding duplicate downloads +- Thin packages are fast to install and have minimal footprint in each registry +- Version synchronization is automated via the release hook + +### Negative + +- First-run latency: the binary must be downloaded on the first invocation after install +- Multiple codebases to maintain (one per ecosystem), though the logic is simple and rarely changes +- Network dependency on GitHub Releases for the initial download + +## References + +- [ARCH-013 -- Version Synchronization](./ARCH-013-version-synchronization.md) -- Enforces version parity across all shim packages +- [CI-001 -- Pin GitHub Actions by Commit SHA](./CI-001-pin-github-actions-by-hash.md) -- SHA pinning for the publish-shims workflow +- [`.simple-release.js`](../../.simple-release.js) -- Release bump hook that syncs all shim versions diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index 7afe86bf..01c0d787 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -72,6 +72,46 @@ jobs: path: /home/runner/.bun/install/cache key: ${{ steps.restore-bun-cache.outputs.cache-primary-key }} + shim-tests: + name: Shim Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Check for shim changes + id: changes + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- shims/ || true) + else + CHANGED=$(git diff --name-only HEAD~1 -- shims/ || true) + fi + if [ -n "$CHANGED" ]; then + echo "shims_changed=true" >> "$GITHUB_OUTPUT" + else + echo "shims_changed=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Python + if: steps.changes.outputs.shims_changed == 'true' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Test PyPI shim + if: steps.changes.outputs.shims_changed == 'true' + working-directory: shims/pypi + run: python -m unittest discover tests/ + - name: Setup Go + if: steps.changes.outputs.shims_changed == 'true' + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: "1.21" + - name: Test Go shim + if: steps.changes.outputs.shims_changed == 'true' + working-directory: shims/go + run: go test ./... + smoke-windows: name: Smoke Test (Windows) if: github.event_name != 'pull_request' || github.event.pull_request.draft == false diff --git a/.github/workflows/publish-shims.yml b/.github/workflows/publish-shims.yml new file mode 100644 index 00000000..9375bb01 --- /dev/null +++ b/.github/workflows/publish-shims.yml @@ -0,0 +1,158 @@ +name: Publish Shims + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish shims for (e.g. v0.39.0)" + required: true + +permissions: {} + +env: + ARCHGATE_TELEMETRY: "0" + +jobs: + # Wait for platform binaries to be uploaded by release-binaries.yml. + # Shim packages download binaries on first use, but smoke tests need them. + wait-for-binaries: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Wait for release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ github.event.release.tag_name || inputs.tag }}" + REQUIRED_ASSETS=("archgate-darwin-arm64.tar.gz" "archgate-linux-x64.tar.gz" "archgate-win32-x64.zip") + for i in $(seq 1 60); do + ASSETS=$(gh release view "$TAG" --repo archgate/cli --json assets --jq '.assets[].name') + ALL_FOUND=true + for asset in "${REQUIRED_ASSETS[@]}"; do + if ! echo "$ASSETS" | grep -qF "$asset"; then + ALL_FOUND=false + break + fi + done + if $ALL_FOUND; then + echo "All required release assets found" + exit 0 + fi + echo "Waiting for release assets... attempt $i/60" + sleep 30 + done + echo "::error::Timed out waiting for release assets" + exit 1 + + publish-pypi: + needs: wait-for-binaries + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write # PyPI trusted publishers (OIDC) + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Build package + working-directory: shims/pypi + run: | + pip install build + python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@6733eb7d741f0b11ec6a39b58540dab7590f9b7d # v1.14.0 + with: + packages-dir: shims/pypi/dist/ + + publish-nuget: + needs: wait-for-binaries + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + - name: Pack and publish + working-directory: shims/nuget/Archgate.Tool + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + dotnet pack -c Release + dotnet nuget push bin/Release/*.nupkg \ + --source https://api.nuget.org/v3/index.json \ + --api-key "$NUGET_API_KEY" + + publish-go-tag: + needs: wait-for-binaries + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} + - name: Create Go module tag + run: | + TAG="${{ github.event.release.tag_name || inputs.tag }}" + GO_TAG="shims/go/${TAG}" + git tag "$GO_TAG" + git push origin "$GO_TAG" + + publish-maven: + needs: wait-for-binaries + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: "temurin" + java-version: "11" + server-id: ossrh + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + - name: Build and publish + working-directory: shims/maven + env: + MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + mvn clean deploy -P release -B --no-transfer-progress + + publish-rubygem: + needs: wait-for-binaries + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} + - uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: "3.3" + - name: Build and publish + working-directory: shims/rubygem + env: + GEM_HOST_API_KEY: ${{ secrets.GEM_HOST_API_KEY }} + run: | + gem build archgate.gemspec + gem push archgate-*.gem diff --git a/.gitignore b/.gitignore index 8ac6f9c2..93c430e1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,10 +34,21 @@ coverage # Docs site build artifacts docs/.astro/ -bin - # Archgate generated type definitions (regenerated by archgate) .archgate/rules.d.ts +# Shim build artifacts +shims/nuget/**/bin/ +shims/nuget/**/obj/ +shims/maven/target/ +shims/pypi/dist/ +shims/pypi/*.egg-info/ +shims/go/cmd/archgate/archgate +shims/go/cmd/archgate/archgate.exe +shims/rubygem/pkg/ +shims/rubygem/*.gem +shims/pypi/**/__pycache__/ +shims/pypi/.pytest_cache/ + # Invalid HOME/USERPROFILE (e.g. literal "undefined") can create this under cwd — never commit /undefined/ \ No newline at end of file diff --git a/.npmignore b/.npmignore index a837b7a5..5e749f75 100644 --- a/.npmignore +++ b/.npmignore @@ -13,4 +13,5 @@ dist bunfig.toml bun.lock docs -plans \ No newline at end of file +plans +shims \ No newline at end of file diff --git a/.simple-release.js b/.simple-release.js index 59323447..ff293ede 100644 --- a/.simple-release.js +++ b/.simple-release.js @@ -30,6 +30,87 @@ class ArchgateProject extends NpmProject { const versionPayload = `{ "version": "v${version}" }\n`; writeFileSync(versionJsonPath, versionPayload); this.changedFiles.push(versionJsonPath); + + // --------------------------------------------------------------- + // Sync shim package versions + // --------------------------------------------------------------- + + // PyPI: pyproject.toml + const pyprojectPath = "shims/pypi/pyproject.toml"; + if (existsSync(pyprojectPath)) { + const content = readFileSync(pyprojectPath, "utf8"); + const updated = content.replace( + /^version\s*=\s*"[^"]+"/mu, + `version = "${version}"` + ); + if (updated !== content) { + writeFileSync(pyprojectPath, updated); + this.changedFiles.push(pyprojectPath); + } + } + + // PyPI: _version.py + const pyVersionPath = "shims/pypi/archgate/_version.py"; + if (existsSync(pyVersionPath)) { + writeFileSync(pyVersionPath, `__version__ = "${version}"\n`); + this.changedFiles.push(pyVersionPath); + } + + // NuGet: .csproj + const csprojPath = "shims/nuget/Archgate.Tool/Archgate.Tool.csproj"; + if (existsSync(csprojPath)) { + const content = readFileSync(csprojPath, "utf8"); + const updated = content.replace( + /[^<]+<\/Version>/u, + `${version}` + ); + if (updated !== content) { + writeFileSync(csprojPath, updated); + this.changedFiles.push(csprojPath); + } + } + + // Go: shim.go version constant + const goShimPath = "shims/go/internal/shim/shim.go"; + if (existsSync(goShimPath)) { + const content = readFileSync(goShimPath, "utf8"); + const updated = content.replace( + /const Version = "[^"]+"/u, + `const Version = "${version}"` + ); + if (updated !== content) { + writeFileSync(goShimPath, updated); + this.changedFiles.push(goShimPath); + } + } + + // Maven: pom.xml (project version, not dependency versions) + const pomPath = "shims/maven/pom.xml"; + if (existsSync(pomPath)) { + const content = readFileSync(pomPath, "utf8"); + const updated = content.replace( + /(archgate-cli<\/artifactId>\s*)[^<]+(<\/version>)/u, + `$1${version}$2` + ); + if (updated !== content) { + writeFileSync(pomPath, updated); + this.changedFiles.push(pomPath); + } + } + + // RubyGem: version.rb + const rubyVersionPath = "shims/rubygem/lib/archgate/version.rb"; + if (existsSync(rubyVersionPath)) { + const content = readFileSync(rubyVersionPath, "utf8"); + const updated = content.replace( + /VERSION = "[^"]+"/u, + `VERSION = "${version}"` + ); + if (updated !== content) { + writeFileSync(rubyVersionPath, updated); + this.changedFiles.push(rubyVersionPath); + } + } } return result; diff --git a/docs/src/content/docs/getting-started/installation.mdx b/docs/src/content/docs/getting-started/installation.mdx index 8dacae1f..2f6ae52e 100644 --- a/docs/src/content/docs/getting-started/installation.mdx +++ b/docs/src/content/docs/getting-started/installation.mdx @@ -1,6 +1,6 @@ --- title: Installation -description: Install the Archgate CLI on macOS, Linux, or Windows via a one-line installer, npm, or standalone binary. Start enforcing ADRs as executable code rules in minutes. +description: Install the Archgate CLI on macOS, Linux, or Windows via a one-line installer, npm, pip, dotnet, go, gem, or standalone binary. Start enforcing ADRs as executable code rules in minutes. --- ## Install standalone (recommended) @@ -93,6 +93,66 @@ yarn check:adrs pnpm check:adrs ``` +## Install via pip (Python) + +Install Archgate globally using pip or pipx: + +```bash +# pip +pip install archgate + +# pipx (recommended for CLI tools) +pipx install archgate +``` + +This installs a lightweight Python wrapper that delegates to a platform-specific binary. Python 3.8+ is required. + +## Install via dotnet + +Install Archgate as a .NET global tool: + +```bash +dotnet tool install -g archgate +``` + +Requires .NET 8.0+ SDK. The tool downloads the platform binary on first run. + +## Install via Go + +Install Archgate using `go install`: + +```bash +go install github.com/archgate/cli/shims/go/cmd/archgate@latest +``` + +Requires Go 1.21+. The compiled Go wrapper downloads the platform binary on first run. + +## Install via RubyGems + +Install Archgate as a Ruby gem: + +```bash +gem install archgate +``` + +Requires Ruby 2.7+. The gem downloads the platform binary on first run. + +## Install via Maven / jbang (Java) + +Install Archgate using jbang: + +```bash +jbang app install archgate@dev.archgate +``` + +Or download the executable JAR from Maven Central (`dev.archgate:archgate-cli`) and run directly: + +```bash +java -jar archgate-cli-0.39.0.jar check +``` + +Requires Java 11+. The shim downloads the platform binary on first run. + ## Platform support Archgate ships pre-built binaries for the following platforms: diff --git a/package.json b/package.json index 8cc6b78c..8ad72a99 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "url": "git+https://github.com/archgate/cli.git" }, "bin": { - "archgate": "bin/archgate.cjs" + "archgate": "shims/npm/archgate.cjs" }, "files": [ - "bin/archgate.cjs" + "shims/npm/archgate.cjs" ], "os": [ "darwin", diff --git a/shims/go/cmd/archgate/main.go b/shims/go/cmd/archgate/main.go new file mode 100644 index 00000000..2824cd9b --- /dev/null +++ b/shims/go/cmd/archgate/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/archgate/cli/shims/go/internal/shim" +) + +func main() { + os.Exit(shim.Run(os.Args[1:])) +} diff --git a/shims/go/go.mod b/shims/go/go.mod new file mode 100644 index 00000000..16d2b436 --- /dev/null +++ b/shims/go/go.mod @@ -0,0 +1,3 @@ +module github.com/archgate/cli/shims/go + +go 1.21 diff --git a/shims/go/internal/shim/exec_unix.go b/shims/go/internal/shim/exec_unix.go new file mode 100644 index 00000000..99cd4ad5 --- /dev/null +++ b/shims/go/internal/shim/exec_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package shim + +import ( + "fmt" + "os" + "syscall" +) + +func executeOS(binaryPath string, args []string) int { + argv := append([]string{binaryPath}, args...) + err := syscall.Exec(binaryPath, argv, os.Environ()) + // syscall.Exec only returns on error + fmt.Fprintf(os.Stderr, "archgate: exec failed: %v\n", err) + return 2 +} diff --git a/shims/go/internal/shim/exec_windows.go b/shims/go/internal/shim/exec_windows.go new file mode 100644 index 00000000..00a4cf2e --- /dev/null +++ b/shims/go/internal/shim/exec_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package shim + +import ( + "os" + "os/exec" +) + +func executeOS(binaryPath string, args []string) int { + cmd := exec.Command(binaryPath, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 2 + } + return 0 +} diff --git a/shims/go/internal/shim/platform.go b/shims/go/internal/shim/platform.go new file mode 100644 index 00000000..02ca7390 --- /dev/null +++ b/shims/go/internal/shim/platform.go @@ -0,0 +1,46 @@ +package shim + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +// GetArtifactName returns the platform-specific artifact name for the current OS/arch. +func GetArtifactName() (string, error) { + key := runtime.GOOS + "/" + runtime.GOARCH + + switch key { + case "darwin/arm64": + return "archgate-darwin-arm64", nil + case "linux/amd64": + return "archgate-linux-x64", nil + case "windows/amd64": + return "archgate-win32-x64", nil + default: + return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) + } +} + +// GetBinaryName returns the binary filename, with .exe suffix on Windows. +func GetBinaryName() string { + if IsWindows() { + return "archgate.exe" + } + return "archgate" +} + +// GetCacheDir returns the path to the archgate binary cache directory. +func GetCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("unable to determine home directory: %w", err) + } + return filepath.Join(home, ".archgate", "bin"), nil +} + +// IsWindows returns true if the current OS is Windows. +func IsWindows() bool { + return runtime.GOOS == "windows" +} diff --git a/shims/go/internal/shim/shim.go b/shims/go/internal/shim/shim.go new file mode 100644 index 00000000..8ca46a91 --- /dev/null +++ b/shims/go/internal/shim/shim.go @@ -0,0 +1,214 @@ +package shim + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// Version is the archgate CLI version this shim downloads. +const Version = "0.39.0" + +const ( + releaseBaseURL = "https://github.com/archgate/cli/releases/download" + installHelpURL = "https://cli.archgate.dev/getting-started/installation/" +) + +// Run is the public entry point. It returns the process exit code. +func Run(args []string) int { + artifact, err := GetArtifactName() + if err != nil { + fmt.Fprintf(os.Stderr, "archgate: %v\n", err) + return 2 + } + + cacheDir, err := GetCacheDir() + if err != nil { + fmt.Fprintf(os.Stderr, "archgate: %v\n", err) + return 2 + } + + binaryPath := filepath.Join(cacheDir, GetBinaryName()) + + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + if code := download(artifact, cacheDir, binaryPath); code != 0 { + return code + } + } + + return execute(binaryPath, args) +} + +func download(artifact, cacheDir, binaryPath string) int { + fmt.Fprintf(os.Stderr, "archgate: binary not found, downloading v%s...\n", Version) + + // Determine archive extension + ext := "tar.gz" + if IsWindows() { + ext = "zip" + } + + archiveURL := fmt.Sprintf("%s/v%s/%s.%s", releaseBaseURL, Version, artifact, ext) + + // Download archive + archiveBytes, err := httpGet(archiveURL) + if err != nil { + fmt.Fprintf(os.Stderr, "archgate: failed to download binary: %v\n", err) + fmt.Fprintf(os.Stderr, "Visit %s for alternative install methods.\n", installHelpURL) + return 2 + } + + // Verify SHA256 checksum + checksumURL := fmt.Sprintf("%s/v%s/%s.%s.sha256", releaseBaseURL, Version, artifact, ext) + checksumBytes, checksumErr := httpGet(checksumURL) + if checksumErr != nil { + fmt.Fprintf(os.Stderr, "archgate: checksum file unavailable, skipping verification.\n") + } else { + expected := strings.TrimSpace(string(checksumBytes)) + if len(expected) >= 64 { + expected = expected[:64] + } + + hash := sha256.Sum256(archiveBytes) + actual := hex.EncodeToString(hash[:]) + + if !strings.EqualFold(expected, actual) { + fmt.Fprintf(os.Stderr, "archgate: checksum verification failed for v%s (expected %s, got %s)\n", Version, expected, actual) + return 2 + } + } + + // Ensure cache directory exists + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "archgate: failed to create cache directory: %v\n", err) + return 2 + } + + // Extract archive + if ext == "zip" { + err = extractZip(archiveBytes, cacheDir) + } else { + err = extractTarGz(archiveBytes, cacheDir) + } + if err != nil { + fmt.Fprintf(os.Stderr, "archgate: failed to extract archive: %v\n", err) + fmt.Fprintf(os.Stderr, "Visit %s for alternative install methods.\n", installHelpURL) + return 2 + } + + // Set permissions on Unix + if !IsWindows() { + if err := os.Chmod(binaryPath, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "archgate: failed to set binary permissions: %v\n", err) + return 2 + } + } + + fmt.Fprintf(os.Stderr, "archgate: binary downloaded successfully.\n") + return 0 +} + +func httpGet(url string) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + + return io.ReadAll(resp.Body) +} + +func extractTarGz(data []byte, destDir string) error { + gr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("gzip open: %w", err) + } + defer gr.Close() + + tr := tar.NewReader(gr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar read: %w", err) + } + + // Only extract regular files + if header.Typeflag != tar.TypeReg { + continue + } + + // Use only the base name to avoid path traversal + name := filepath.Base(header.Name) + outPath := filepath.Join(destDir, name) + + f, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("create file %s: %w", name, err) + } + + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return fmt.Errorf("write file %s: %w", name, err) + } + f.Close() + } + return nil +} + +func extractZip(data []byte, destDir string) error { + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return fmt.Errorf("zip open: %w", err) + } + + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + + // Use only the base name to avoid path traversal + name := filepath.Base(f.Name) + outPath := filepath.Join(destDir, name) + + rc, err := f.Open() + if err != nil { + return fmt.Errorf("open zip entry %s: %w", name, err) + } + + outFile, err := os.Create(outPath) + if err != nil { + rc.Close() + return fmt.Errorf("create file %s: %w", name, err) + } + + if _, err := io.Copy(outFile, rc); err != nil { + outFile.Close() + rc.Close() + return fmt.Errorf("write file %s: %w", name, err) + } + + outFile.Close() + rc.Close() + } + return nil +} + +func execute(binaryPath string, args []string) int { + return executeOS(binaryPath, args) +} diff --git a/shims/go/internal/shim/shim_test.go b/shims/go/internal/shim/shim_test.go new file mode 100644 index 00000000..ecf3a764 --- /dev/null +++ b/shims/go/internal/shim/shim_test.go @@ -0,0 +1,123 @@ +package shim + +import ( + "crypto/sha256" + "encoding/hex" + "runtime" + "testing" +) + +func TestGetArtifactName(t *testing.T) { + name, err := GetArtifactName() + if err != nil { + t.Fatalf("GetArtifactName() returned unexpected error: %v", err) + } + + expected := "" + switch runtime.GOOS + "/" + runtime.GOARCH { + case "darwin/arm64": + expected = "archgate-darwin-arm64" + case "linux/amd64": + expected = "archgate-linux-x64" + case "windows/amd64": + expected = "archgate-win32-x64" + default: + t.Skipf("skipping: unsupported platform %s/%s", runtime.GOOS, runtime.GOARCH) + } + + if name != expected { + t.Errorf("GetArtifactName() = %q, want %q", name, expected) + } +} + +func TestGetArtifactNameMapping(t *testing.T) { + // Verify the function exists and returns a non-empty string on supported platforms + name, err := GetArtifactName() + if err != nil { + t.Skipf("unsupported platform: %v", err) + } + if name == "" { + t.Error("GetArtifactName() returned empty string") + } +} + +func TestGetBinaryName(t *testing.T) { + name := GetBinaryName() + + if runtime.GOOS == "windows" { + if name != "archgate.exe" { + t.Errorf("GetBinaryName() = %q on Windows, want %q", name, "archgate.exe") + } + } else { + if name != "archgate" { + t.Errorf("GetBinaryName() = %q on Unix, want %q", name, "archgate") + } + } +} + +func TestGetCacheDir(t *testing.T) { + dir, err := GetCacheDir() + if err != nil { + t.Fatalf("GetCacheDir() returned unexpected error: %v", err) + } + if dir == "" { + t.Error("GetCacheDir() returned empty string") + } +} + +func TestIsWindows(t *testing.T) { + got := IsWindows() + want := runtime.GOOS == "windows" + if got != want { + t.Errorf("IsWindows() = %v, want %v", got, want) + } +} + +func TestSha256Verification(t *testing.T) { + tests := []struct { + name string + data []byte + expected string + match bool + }{ + { + name: "matching hash", + data: []byte("hello world"), + expected: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + match: true, + }, + { + name: "mismatching hash", + data: []byte("hello world"), + expected: "0000000000000000000000000000000000000000000000000000000000000000", + match: false, + }, + { + name: "empty data", + data: []byte(""), + expected: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + match: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hash := sha256.Sum256(tt.data) + actual := hex.EncodeToString(hash[:]) + + if (actual == tt.expected) != tt.match { + if tt.match { + t.Errorf("expected hash to match: got %s, want %s", actual, tt.expected) + } else { + t.Errorf("expected hash to NOT match but both were %s", actual) + } + } + }) + } +} + +func TestVersionIsSet(t *testing.T) { + if Version == "" { + t.Error("Version constant is empty") + } +} diff --git a/shims/maven/jbang-catalog.json b/shims/maven/jbang-catalog.json new file mode 100644 index 00000000..ac364f1c --- /dev/null +++ b/shims/maven/jbang-catalog.json @@ -0,0 +1,9 @@ +{ + "catalogs": {}, + "aliases": { + "archgate": { + "script-ref": "dev.archgate:archgate-cli:RELEASE", + "description": "Enforce Architecture Decision Records as executable rules" + } + } +} diff --git a/shims/maven/pom.xml b/shims/maven/pom.xml new file mode 100644 index 00000000..7a8422f7 --- /dev/null +++ b/shims/maven/pom.xml @@ -0,0 +1,136 @@ + + + 4.0.0 + + dev.archgate + archgate-cli + 0.39.0 + jar + + archgate-cli + Thin Java shim that downloads the archgate platform binary from GitHub Releases on first invocation and then executes it. + https://cli.archgate.dev + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + Archgate + hello@archgate.dev + Archgate + https://archgate.dev + + + + + scm:git:https://github.com/archgate/cli.git + scm:git:git@github.com:archgate/cli.git + https://github.com/archgate/cli + + + + UTF-8 + 11 + 11 + + + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + dev.archgate.cli.Shim + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + + + + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.7 + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.11.2 + + + attach-javadocs + + jar + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.7.0 + true + + ossrh + https://s01.oss.sonatype.org/ + true + + + + + + + diff --git a/shims/maven/src/main/java/dev/archgate/cli/Platform.java b/shims/maven/src/main/java/dev/archgate/cli/Platform.java new file mode 100644 index 00000000..e5507fc3 --- /dev/null +++ b/shims/maven/src/main/java/dev/archgate/cli/Platform.java @@ -0,0 +1,62 @@ +package dev.archgate.cli; + +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Platform detection for downloading the correct archgate binary. + */ +public final class Platform { + + private Platform() {} + + /** + * Returns the platform-specific artifact name used in GitHub Release URLs. + * + * @return artifact name such as {@code archgate-darwin-arm64} + * @throws UnsupportedOperationException if the current platform is not supported + */ + public static String getArtifactName() { + String osName = System.getProperty("os.name", ""); + String osArch = System.getProperty("os.arch", ""); + + if (osName.startsWith("Mac") && "aarch64".equals(osArch)) { + return "archgate-darwin-arm64"; + } + if (osName.startsWith("Linux") && "amd64".equals(osArch)) { + return "archgate-linux-x64"; + } + if (osName.startsWith("Windows") && "amd64".equals(osArch)) { + return "archgate-win32-x64"; + } + + throw new UnsupportedOperationException( + "Unsupported platform: " + osName + "/" + osArch + + "\narchgate supports darwin/arm64, linux/x64, and win32/x64."); + } + + /** + * Returns the binary file name for the current platform. + * + * @return {@code archgate.exe} on Windows, {@code archgate} otherwise + */ + public static String getBinaryName() { + return isWindows() ? "archgate.exe" : "archgate"; + } + + /** + * Returns the cache directory where the binary is stored. + * + * @return path to {@code ~/.archgate/bin} + */ + public static Path getCacheDir() { + return Paths.get(System.getProperty("user.home"), ".archgate", "bin"); + } + + /** + * Returns {@code true} if the current OS is Windows. + */ + public static boolean isWindows() { + return System.getProperty("os.name", "").startsWith("Windows"); + } +} diff --git a/shims/maven/src/main/java/dev/archgate/cli/Shim.java b/shims/maven/src/main/java/dev/archgate/cli/Shim.java new file mode 100644 index 00000000..de51e371 --- /dev/null +++ b/shims/maven/src/main/java/dev/archgate/cli/Shim.java @@ -0,0 +1,289 @@ +package dev.archgate.cli; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Thin shim that downloads the archgate platform binary from GitHub Releases + * on first invocation, caches it locally, and then executes it. + * + *

Zero external dependencies -- Java 11 standard library only.

+ */ +public final class Shim { + + private static final String VERSION = "0.39.0"; + private static final String BASE_URL = "https://github.com/archgate/cli/releases/download/v" + VERSION + "/"; + + private Shim() {} + + public static void main(String[] args) { + try { + Path binary = resolveBinary(); + execute(binary, args); + } catch (ShimException e) { + System.err.println(e.getMessage()); + System.exit(2); + } catch (Exception e) { + System.err.println("archgate: failed to download binary: " + e.getMessage() + + "\nVisit https://cli.archgate.dev/getting-started/installation/ for alternative install methods."); + System.exit(2); + } + } + + // ------------------------------------------------------------------ + // Binary resolution + // ------------------------------------------------------------------ + + static Path resolveBinary() throws Exception { + Path cacheDir = Platform.getCacheDir(); + String binaryName = Platform.getBinaryName(); + Path binaryPath = cacheDir.resolve(binaryName); + + if (Files.isRegularFile(binaryPath)) { + return binaryPath; + } + + System.err.println("archgate: binary not found, downloading v" + VERSION + "..."); + + Files.createDirectories(cacheDir); + + String artifactName = Platform.getArtifactName(); + boolean isWindows = Platform.isWindows(); + String ext = isWindows ? "zip" : "tar.gz"; + String archiveUrl = BASE_URL + artifactName + "." + ext; + String checksumUrl = archiveUrl + ".sha256"; + + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + byte[] archiveBytes = download(client, archiveUrl); + + verifySha256(client, checksumUrl, archiveBytes); + + byte[] binaryBytes; + if (isWindows) { + binaryBytes = extractFromZip(archiveBytes, binaryName); + } else { + binaryBytes = extractFromTarGz(archiveBytes, binaryName); + } + + Files.write(binaryPath, binaryBytes); + + if (!isWindows) { + setPosixExecutable(binaryPath); + } + + System.err.println("archgate: binary downloaded successfully."); + return binaryPath; + } + + // ------------------------------------------------------------------ + // HTTP download + // ------------------------------------------------------------------ + + static byte[] download(HttpClient client, String url) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("User-Agent", "archgate-cli-java") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() != 200) { + throw new IOException("GET " + url + " returned status " + response.statusCode()); + } + return response.body(); + } + + // ------------------------------------------------------------------ + // SHA-256 verification + // ------------------------------------------------------------------ + + static void verifySha256(HttpClient client, String checksumUrl, byte[] data) throws Exception { + byte[] checksumBytes; + try { + checksumBytes = download(client, checksumUrl); + } catch (Exception e) { + System.err.println("archgate: warning: checksum file not available, skipping verification"); + return; + } + + String checksumContent = new String(checksumBytes, StandardCharsets.UTF_8).trim(); + String expectedHash = checksumContent.split("\\s+")[0]; + String actualHash = sha256Hex(data); + + if (!expectedHash.equals(actualHash)) { + throw new ShimException( + "archgate: checksum verification failed for v" + VERSION + + " (expected " + expectedHash + ", got " + actualHash + ")"); + } + } + + static String sha256Hex(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } + + // ------------------------------------------------------------------ + // tar.gz extraction (inline tar parser) + // ------------------------------------------------------------------ + + static byte[] extractFromTarGz(byte[] archiveBytes, String binaryName) throws IOException { + byte[] tarBytes; + try (InputStream bais = new java.io.ByteArrayInputStream(archiveBytes); + GZIPInputStream gzis = new GZIPInputStream(bais); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int n; + while ((n = gzis.read(buf)) != -1) { + baos.write(buf, 0, n); + } + tarBytes = baos.toByteArray(); + } + + int offset = 0; + while (offset + 512 <= tarBytes.length) { + byte[] header = Arrays.copyOfRange(tarBytes, offset, offset + 512); + offset += 512; + + // All-zero header signals end of archive + boolean allZero = true; + for (byte b : header) { + if (b != 0) { + allZero = false; + break; + } + } + if (allZero) break; + + // Parse name (bytes 0-100) and prefix (bytes 345-500) + String name = stripNulls(new String(header, 0, 100, StandardCharsets.UTF_8)); + String prefix = stripNulls(new String(header, 345, 155, StandardCharsets.UTF_8)); + if (!prefix.isEmpty()) { + name = prefix + "/" + name; + } + + // Parse size (bytes 124-136, octal) + String sizeStr = stripNulls(new String(header, 124, 12, StandardCharsets.UTF_8)).trim(); + long size = sizeStr.isEmpty() ? 0 : Long.parseLong(sizeStr, 8); + + // File data follows, padded to 512-byte boundary + int blocks = (int) ((size + 511) / 512); + int dataEnd = offset + (int) size; + + if (name.equals(binaryName) || name.endsWith("/" + binaryName)) { + return Arrays.copyOfRange(tarBytes, offset, dataEnd); + } + + offset += blocks * 512; + } + + throw new IOException("Could not find " + binaryName + " in tar.gz archive"); + } + + // ------------------------------------------------------------------ + // zip extraction + // ------------------------------------------------------------------ + + static byte[] extractFromZip(byte[] archiveBytes, String binaryName) throws IOException { + try (ZipInputStream zis = new ZipInputStream(new java.io.ByteArrayInputStream(archiveBytes))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String entryName = entry.getName(); + if (entryName.equals(binaryName) || entryName.endsWith("/" + binaryName)) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = zis.read(buf)) != -1) { + baos.write(buf, 0, n); + } + return baos.toByteArray(); + } + zis.closeEntry(); + } + } + + throw new IOException("Could not find " + binaryName + " in zip archive"); + } + + // ------------------------------------------------------------------ + // Execution + // ------------------------------------------------------------------ + + private static void execute(Path binary, String[] args) throws Exception { + List command = new ArrayList<>(); + command.add(binary.toAbsolutePath().toString()); + command.addAll(Arrays.asList(args)); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.inheritIO(); + Process process = pb.start(); + int exitCode = process.waitFor(); + System.exit(exitCode); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static void setPosixExecutable(Path path) { + try { + Set perms = EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_EXECUTE + ); + Files.setPosixFilePermissions(path, perms); + } catch (UnsupportedOperationException | IOException e) { + // Not a POSIX filesystem -- skip + } + } + + private static String stripNulls(String s) { + int idx = s.indexOf('\0'); + return idx == -1 ? s : s.substring(0, idx); + } + + // ------------------------------------------------------------------ + // Exception type for controlled exits + // ------------------------------------------------------------------ + + static class ShimException extends RuntimeException { + ShimException(String message) { + super(message); + } + } +} diff --git a/shims/maven/src/test/java/dev/archgate/cli/ShimTest.java b/shims/maven/src/test/java/dev/archgate/cli/ShimTest.java new file mode 100644 index 00000000..1a3c8e8d --- /dev/null +++ b/shims/maven/src/test/java/dev/archgate/cli/ShimTest.java @@ -0,0 +1,125 @@ +package dev.archgate.cli; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +import static org.junit.jupiter.api.Assertions.*; + +class ShimTest { + + // ------------------------------------------------------------------ + // Platform detection + // ------------------------------------------------------------------ + + @Test + void darwinArm64ArtifactName() { + String result = withSystemProperties("Mac OS X", "aarch64", Platform::getArtifactName); + assertEquals("archgate-darwin-arm64", result); + } + + @Test + void linuxX64ArtifactName() { + String result = withSystemProperties("Linux", "amd64", Platform::getArtifactName); + assertEquals("archgate-linux-x64", result); + } + + @Test + void windowsX64ArtifactName() { + String result = withSystemProperties("Windows 10", "amd64", Platform::getArtifactName); + assertEquals("archgate-win32-x64", result); + } + + @Test + void unsupportedPlatformThrows() { + assertThrows(UnsupportedOperationException.class, () -> + withSystemProperties("FreeBSD", "amd64", Platform::getArtifactName)); + } + + @Test + void unsupportedArchThrows() { + assertThrows(UnsupportedOperationException.class, () -> + withSystemProperties("Linux", "aarch64", Platform::getArtifactName)); + } + + // ------------------------------------------------------------------ + // Binary name resolution + // ------------------------------------------------------------------ + + @Test + void binaryNameOnWindows() { + String result = withSystemProperties("Windows 11", "amd64", Platform::getBinaryName); + assertEquals("archgate.exe", result); + } + + @Test + void binaryNameOnUnix() { + String result = withSystemProperties("Linux", "amd64", Platform::getBinaryName); + assertEquals("archgate", result); + } + + @Test + void binaryNameOnMac() { + String result = withSystemProperties("Mac OS X", "aarch64", Platform::getBinaryName); + assertEquals("archgate", result); + } + + // ------------------------------------------------------------------ + // SHA-256 verification + // ------------------------------------------------------------------ + + @Test + void sha256HexProducesCorrectHash() throws Exception { + byte[] data = "hello world".getBytes(StandardCharsets.UTF_8); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data); + StringBuilder expected = new StringBuilder(); + for (byte b : hash) { + expected.append(String.format("%02x", b & 0xff)); + } + + String actual = Shim.sha256Hex(data); + assertEquals(expected.toString(), actual); + } + + @Test + void sha256HexMatchesKnownValue() { + // SHA-256 of empty byte array + String actual = Shim.sha256Hex(new byte[0]); + assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", actual); + } + + @Test + void sha256MismatchThrowsShimException() { + // Simulate a checksum mismatch by testing the sha256Hex output + byte[] data = "some binary content".getBytes(StandardCharsets.UTF_8); + String correctHash = Shim.sha256Hex(data); + String wrongHash = "0000000000000000000000000000000000000000000000000000000000000000"; + + assertNotEquals(wrongHash, correctHash); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** + * Temporarily overrides os.name and os.arch system properties, + * runs the given supplier, and restores the original values. + */ + private static T withSystemProperties(String osName, String osArch, java.util.function.Supplier fn) { + String origName = System.getProperty("os.name"); + String origArch = System.getProperty("os.arch"); + try { + System.setProperty("os.name", osName); + System.setProperty("os.arch", osArch); + return fn.get(); + } finally { + if (origName != null) System.setProperty("os.name", origName); + else System.clearProperty("os.name"); + if (origArch != null) System.setProperty("os.arch", origArch); + else System.clearProperty("os.arch"); + } + } +} diff --git a/bin/archgate.cjs b/shims/npm/archgate.cjs old mode 100755 new mode 100644 similarity index 72% rename from bin/archgate.cjs rename to shims/npm/archgate.cjs index a1c3f494..261801c6 --- a/bin/archgate.cjs +++ b/shims/npm/archgate.cjs @@ -2,6 +2,7 @@ "use strict"; const { execFileSync, execSync } = require("child_process"); +const crypto = require("crypto"); const https = require("https"); const zlib = require("zlib"); const path = require("path"); @@ -28,7 +29,7 @@ function getCacheDir() { function getPackageVersion() { const pkg = JSON.parse( - fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8") + fs.readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8") ); return pkg.version; } @@ -72,6 +73,47 @@ function stripNulls(str) { return idx === -1 ? str : str.slice(0, idx); } +/** + * Download a URL into a Buffer, following redirects. + */ +async function downloadToBuffer(url) { + const res = await fetchWithRedirects(url); + const chunks = []; + await new Promise((resolve, reject) => { + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", resolve); + res.on("error", reject); + }); + return Buffer.concat(chunks); +} + +/** + * Verify SHA256 checksum of a buffer against the companion .sha256 file. + * The .sha256 file format is: " \n" + */ +async function verifySha256(archiveBuffer, checksumUrl, version) { + let checksumData; + try { + checksumData = await downloadToBuffer(checksumUrl); + } catch { + // If checksum file is unavailable, skip verification with a warning + console.error( + "archgate: warning: checksum file not available, skipping verification" + ); + return; + } + const expectedHash = checksumData.toString("utf8").trim().split(/\s+/u)[0]; + const actualHash = crypto + .createHash("sha256") + .update(archiveBuffer) + .digest("hex"); + if (actualHash !== expectedHash) { + throw new Error( + `checksum verification failed for v${version} (expected ${expectedHash}, got ${actualHash})` + ); + } +} + /** * Download the platform binary from GitHub Releases and cache it. * Returns the path to the downloaded binary. @@ -83,25 +125,24 @@ async function downloadBinary() { const isWin = process.platform === "win32"; const ext = isWin ? "zip" : "tar.gz"; - const url = `https://github.com/archgate/cli/releases/download/v${version}/${artifactName}.${ext}`; + const baseUrl = `https://github.com/archgate/cli/releases/download/v${version}/${artifactName}`; + const url = `${baseUrl}.${ext}`; + const checksumUrl = `${baseUrl}.${ext}.sha256`; console.error(`archgate: binary not found, downloading v${version}...`); const cacheDir = getCacheDir(); fs.mkdirSync(cacheDir, { recursive: true }); const destPath = path.join(cacheDir, binaryName); + // Download archive and verify checksum (shared across platforms) + const archiveBuffer = await downloadToBuffer(url); + await verifySha256(archiveBuffer, checksumUrl, version); + if (isWin) { - // Download zip to temp file, extract with PowerShell - const res = await fetchWithRedirects(url); - const chunks = []; - await new Promise((resolve, reject) => { - res.on("data", (chunk) => chunks.push(chunk)); - res.on("end", resolve); - res.on("error", reject); - }); + // Extract zip with PowerShell const tmpZip = path.join(cacheDir, "archgate-download.zip"); const tmpExtract = path.join(cacheDir, "archgate-extract"); - fs.writeFileSync(tmpZip, Buffer.concat(chunks)); + fs.writeFileSync(tmpZip, archiveBuffer); try { fs.mkdirSync(tmpExtract, { recursive: true }); execSync( @@ -114,17 +155,24 @@ async function downloadBinary() { } fs.copyFileSync(extractedBinary, destPath); } finally { - try { fs.unlinkSync(tmpZip); } catch { /* cleanup */ } - try { fs.rmSync(tmpExtract, { recursive: true, force: true }); } catch { /* cleanup */ } + try { + fs.unlinkSync(tmpZip); + } catch { + /* cleanup */ + } + try { + fs.rmSync(tmpExtract, { recursive: true, force: true }); + } catch { + /* cleanup */ + } } } else { - // Download tar.gz, extract binary using inline tar parser - const res = await fetchWithRedirects(url); + // Extract binary from tar.gz using inline tar parser await new Promise((resolve, reject) => { const gunzip = zlib.createGunzip(); const chunks = []; - res.pipe(gunzip); + gunzip.end(archiveBuffer); gunzip.on("data", (chunk) => chunks.push(chunk)); gunzip.on("end", () => { const data = Buffer.concat(chunks); @@ -138,9 +186,7 @@ async function downloadBinary() { if (header.every((b) => b === 0)) break; let name = stripNulls(header.subarray(0, 100).toString("utf8")); - const prefix = stripNulls( - header.subarray(345, 500).toString("utf8") - ); + const prefix = stripNulls(header.subarray(345, 500).toString("utf8")); if (prefix) name = `${prefix}/${name}`; const sizeStr = stripNulls( diff --git a/shims/nuget/Archgate.Tool/Archgate.Tool.csproj b/shims/nuget/Archgate.Tool/Archgate.Tool.csproj new file mode 100644 index 00000000..389979bc --- /dev/null +++ b/shims/nuget/Archgate.Tool/Archgate.Tool.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0 + Archgate.Tool + true + archgate + archgate + 0.39.0 + Enforce Architecture Decision Records as executable rules -- for both humans and AI agents + Archgate + Apache-2.0 + https://cli.archgate.dev + https://github.com/archgate/cli + adr;archgate;architecture-decision-records;cli;governance;linter + enable + enable + + diff --git a/shims/nuget/Archgate.Tool/Program.cs b/shims/nuget/Archgate.Tool/Program.cs new file mode 100644 index 00000000..a68e985f --- /dev/null +++ b/shims/nuget/Archgate.Tool/Program.cs @@ -0,0 +1,250 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace Archgate.Tool; + +internal static class Program +{ + private const string Version = "0.39.0"; + + private static readonly string CacheDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".archgate", + "bin" + ); + + internal static int Main(string[] args) + { + string binaryPath = GetBinaryPath(); + + if (!File.Exists(binaryPath)) + { + try + { + DownloadBinary(binaryPath).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"archgate: failed to download binary: {ex.Message}\n" + + "Visit https://cli.archgate.dev/getting-started/installation/ for alternative install methods." + ); + return 2; + } + } + + return Execute(binaryPath, args); + } + + // ------------------------------------------------------------------------- + // Platform detection + // ------------------------------------------------------------------------- + + internal static string GetArtifactName() + { + bool isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var arch = RuntimeInformation.ProcessArchitecture; + + if (isMacOS && arch == Architecture.Arm64) + return "archgate-darwin-arm64"; + if (isLinux && arch == Architecture.X64) + return "archgate-linux-x64"; + if (isWindows && arch == Architecture.X64) + return "archgate-win32-x64"; + + string os = isMacOS ? "darwin" : isLinux ? "linux" : isWindows ? "win32" : "unknown"; + string archName = arch.ToString().ToLowerInvariant(); + throw new PlatformNotSupportedException( + $"Unsupported platform: {os}/{archName}\n" + + "archgate supports darwin/arm64, linux/x64, and win32/x64." + ); + } + + internal static string GetBinaryName() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "archgate.exe" + : "archgate"; + } + + private static string GetBinaryPath() + { + return Path.Combine(CacheDir, GetBinaryName()); + } + + // ------------------------------------------------------------------------- + // Download + verify + extract + // ------------------------------------------------------------------------- + + private static async Task DownloadBinary(string destPath) + { + string artifactName = GetArtifactName(); + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + string ext = isWindows ? "zip" : "tar.gz"; + + string baseUrl = $"https://github.com/archgate/cli/releases/download/v{Version}/{artifactName}"; + string archiveUrl = $"{baseUrl}.{ext}"; + string checksumUrl = $"{baseUrl}.{ext}.sha256"; + + Console.Error.WriteLine($"archgate: binary not found, downloading v{Version}..."); + + Directory.CreateDirectory(CacheDir); + + using var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("archgate-cli"); + + // Download archive + byte[] archiveBytes = await http.GetByteArrayAsync(archiveUrl); + + // Verify checksum + await VerifyChecksum(http, archiveBytes, checksumUrl); + + // Extract + string binaryName = GetBinaryName(); + + if (isWindows) + { + ExtractFromZip(archiveBytes, binaryName, destPath); + } + else + { + ExtractFromTarGz(archiveBytes, binaryName, destPath); + } + + // Set executable permissions on Unix + if (!isWindows) + { + File.SetUnixFileMode( + destPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute + ); + } + + Console.Error.WriteLine("archgate: binary downloaded successfully."); + } + + private static async Task VerifyChecksum(HttpClient http, byte[] archiveBytes, string checksumUrl) + { + string? expectedHash; + try + { + string checksumContent = await http.GetStringAsync(checksumUrl); + expectedHash = checksumContent.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]; + } + catch + { + Console.Error.WriteLine("archgate: warning: checksum file not available, skipping verification"); + return; + } + + byte[] hashBytes = SHA256.HashData(archiveBytes); + string actualHash = Convert.ToHexStringLower(hashBytes); + + if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"checksum verification failed for v{Version} (expected {expectedHash}, got {actualHash})" + ); + } + } + + private static void ExtractFromZip(byte[] archiveBytes, string binaryName, string destPath) + { + using var stream = new MemoryStream(archiveBytes); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + + ZipArchiveEntry? entry = null; + foreach (var e in archive.Entries) + { + // Match exact name or nested path ending with the binary name + if (string.Equals(e.Name, binaryName, StringComparison.OrdinalIgnoreCase)) + { + entry = e; + break; + } + } + + if (entry is null) + { + throw new FileNotFoundException($"Binary {binaryName} not found in zip archive"); + } + + using var entryStream = entry.Open(); + using var fileStream = File.Create(destPath); + entryStream.CopyTo(fileStream); + } + + private static void ExtractFromTarGz(byte[] archiveBytes, string binaryName, string destPath) + { + string tempDir = Path.Combine(CacheDir, "archgate-extract"); + Directory.CreateDirectory(tempDir); + + try + { + using (var gzStream = new GZipStream(new MemoryStream(archiveBytes), CompressionMode.Decompress)) + { + System.Formats.Tar.TarFile.ExtractToDirectory(gzStream, tempDir, overwriteFiles: true); + } + + // Search for the binary in the extracted directory + string? binaryFile = FindBinary(tempDir, binaryName); + + if (binaryFile is null) + { + throw new FileNotFoundException($"Binary {binaryName} not found in tar.gz archive"); + } + + File.Copy(binaryFile, destPath, overwrite: true); + } + finally + { + try { Directory.Delete(tempDir, recursive: true); } catch { /* cleanup */ } + } + } + + private static string? FindBinary(string directory, string binaryName) + { + foreach (string file in Directory.EnumerateFiles(directory, binaryName, SearchOption.AllDirectories)) + { + return file; + } + return null; + } + + // ------------------------------------------------------------------------- + // Execution + // ------------------------------------------------------------------------- + + private static int Execute(string binaryPath, string[] args) + { + var startInfo = new ProcessStartInfo + { + FileName = binaryPath, + UseShellExecute = false, + RedirectStandardInput = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + + foreach (string arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = Process.Start(startInfo); + if (process is null) + { + Console.Error.WriteLine("archgate: failed to start process"); + return 2; + } + + process.WaitForExit(); + return process.ExitCode; + } +} diff --git a/shims/nuget/tests/Archgate.Tool.Tests/Archgate.Tool.Tests.csproj b/shims/nuget/tests/Archgate.Tool.Tests/Archgate.Tool.Tests.csproj new file mode 100644 index 00000000..57045bdf --- /dev/null +++ b/shims/nuget/tests/Archgate.Tool.Tests/Archgate.Tool.Tests.csproj @@ -0,0 +1,27 @@ + + + net8.0 + Archgate.Tool.Tests + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/shims/nuget/tests/Archgate.Tool.Tests/ShimTests.cs b/shims/nuget/tests/Archgate.Tool.Tests/ShimTests.cs new file mode 100644 index 00000000..fb48a0c5 --- /dev/null +++ b/shims/nuget/tests/Archgate.Tool.Tests/ShimTests.cs @@ -0,0 +1,85 @@ +using System.Runtime.InteropServices; + +namespace Archgate.Tool.Tests; + +public class ShimTests +{ + [Theory] + [InlineData("darwin", "Arm64", "archgate-darwin-arm64")] + [InlineData("linux", "X64", "archgate-linux-x64")] + [InlineData("win32", "X64", "archgate-win32-x64")] + public void GetArtifactName_ReturnsPlatformSpecificName(string expectedOs, string arch, string expectedArtifact) + { + // We cannot mock RuntimeInformation directly, so we test the actual + // platform we are running on. This test verifies the current platform + // produces one of the known artifact names. + string artifact = Program.GetArtifactName(); + + bool isCurrentPlatform = + (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && expectedOs == "darwin" + && RuntimeInformation.ProcessArchitecture.ToString() == arch) + || (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && expectedOs == "linux" + && RuntimeInformation.ProcessArchitecture.ToString() == arch) + || (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && expectedOs == "win32" + && RuntimeInformation.ProcessArchitecture.ToString() == arch); + + if (isCurrentPlatform) + { + Assert.Equal(expectedArtifact, artifact); + } + } + + [Fact] + public void GetArtifactName_ReturnsOneOfKnownArtifacts() + { + string artifact = Program.GetArtifactName(); + + Assert.Contains(artifact, new[] + { + "archgate-darwin-arm64", + "archgate-linux-x64", + "archgate-win32-x64", + }); + } + + [Fact] + public void GetBinaryName_ReturnsCorrectName() + { + string name = Program.GetBinaryName(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Equal("archgate.exe", name); + } + else + { + Assert.Equal("archgate", name); + } + } + + [Fact] + public void ArtifactNameContainsPlatformIdentifier() + { + string artifact = Program.GetArtifactName(); + + Assert.StartsWith("archgate-", artifact); + Assert.Contains("-", artifact[9..]); // has os-arch separator after "archgate-" + } + + [Fact] + public void ArtifactAndBinaryNamesAreConsistent() + { + string artifact = Program.GetArtifactName(); + string binary = Program.GetBinaryName(); + + // Windows artifacts produce .exe binaries + if (artifact.Contains("win32")) + { + Assert.Equal("archgate.exe", binary); + } + else + { + Assert.Equal("archgate", binary); + } + } +} diff --git a/shims/pypi/archgate/__init__.py b/shims/pypi/archgate/__init__.py new file mode 100644 index 00000000..781934c1 --- /dev/null +++ b/shims/pypi/archgate/__init__.py @@ -0,0 +1 @@ +from archgate._version import __version__ diff --git a/shims/pypi/archgate/__main__.py b/shims/pypi/archgate/__main__.py new file mode 100644 index 00000000..0ab8905c --- /dev/null +++ b/shims/pypi/archgate/__main__.py @@ -0,0 +1,3 @@ +from archgate._shim import main + +main() diff --git a/shims/pypi/archgate/_shim.py b/shims/pypi/archgate/_shim.py new file mode 100644 index 00000000..f471daad --- /dev/null +++ b/shims/pypi/archgate/_shim.py @@ -0,0 +1,194 @@ +"""Thin shim that downloads the archgate binary from GitHub Releases on first +invocation and then executes it. Zero runtime dependencies — stdlib only.""" + +from __future__ import annotations + +import hashlib +import os +import platform +import stat +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from io import BytesIO +from pathlib import Path +from urllib.error import URLError +from urllib.request import urlopen + +from archgate._version import __version__ + +# --------------------------------------------------------------------------- +# Platform helpers +# --------------------------------------------------------------------------- + +_PLATFORM_MAP = { + ("Darwin", "arm64"): "archgate-darwin-arm64", + ("Darwin", "aarch64"): "archgate-darwin-arm64", + ("Linux", "x86_64"): "archgate-linux-x64", + ("Linux", "AMD64"): "archgate-linux-x64", + ("Windows", "AMD64"): "archgate-win32-x64", + ("Windows", "x86_64"): "archgate-win32-x64", +} + + +def _detect_artifact(): # type: () -> str + os_name = platform.system() + arch = platform.machine() + key = (os_name, arch) + artifact = _PLATFORM_MAP.get(key) + if artifact is None: + print( + "archgate: Unsupported platform: {os}/{arch}\n" + "archgate supports darwin/arm64, linux/x64, and win32/x64.".format( + os=os_name, arch=arch + ), + file=sys.stderr, + ) + sys.exit(2) + return artifact + + +def _binary_name(): # type: () -> str + return "archgate.exe" if platform.system() == "Windows" else "archgate" + + +def _archive_ext(): # type: () -> str + return "zip" if platform.system() == "Windows" else "tar.gz" + + +# --------------------------------------------------------------------------- +# Download / verification +# --------------------------------------------------------------------------- + +_BASE_URL = "https://github.com/archgate/cli/releases/download" + + +def _download_url(artifact, ext): # type: (str, str) -> str + return "{base}/v{ver}/{artifact}.{ext}".format( + base=_BASE_URL, ver=__version__, artifact=artifact, ext=ext + ) + + +def _checksum_url(artifact, ext): # type: (str, str) -> str + return _download_url(artifact, ext) + ".sha256" + + +def _fetch(url): # type: (str) -> bytes + resp = urlopen(url) # noqa: S310 — follows redirects automatically + return resp.read() + + +def _verify_checksum(archive_bytes, artifact, ext): + # type: (bytes, str, str) -> None + """Download the .sha256 companion file and verify the archive.""" + try: + checksum_bytes = _fetch(_checksum_url(artifact, ext)) + except (URLError, OSError): + print( + "archgate: checksum file unavailable, skipping verification.", + file=sys.stderr, + ) + return + + expected = checksum_bytes.decode("utf-8").strip()[:64] + actual = hashlib.sha256(archive_bytes).hexdigest() + if expected != actual: + print( + "archgate: checksum verification failed for v{ver} " + "(expected {exp}, got {act})".format( + ver=__version__, exp=expected, act=actual + ), + file=sys.stderr, + ) + sys.exit(2) + + +# --------------------------------------------------------------------------- +# Extraction +# --------------------------------------------------------------------------- + + +def _extract(archive_bytes, ext, dest_dir): + # type: (bytes, str, Path) -> None + """Extract archive into *dest_dir*.""" + if ext == "tar.gz": + with tarfile.open(fileobj=BytesIO(archive_bytes), mode="r:gz") as tar: + tar.extractall(path=str(dest_dir)) + else: + with zipfile.ZipFile(BytesIO(archive_bytes)) as zf: + zf.extractall(path=str(dest_dir)) + + +# --------------------------------------------------------------------------- +# Main entry-point +# --------------------------------------------------------------------------- + + +def main(): # type: () -> None + cache_dir = Path.home() / ".archgate" / "bin" + binary = cache_dir / _binary_name() + + if not binary.exists(): + artifact = _detect_artifact() + ext = _archive_ext() + url = _download_url(artifact, ext) + + print( + "archgate: binary not found, downloading v{ver}...".format( + ver=__version__ + ), + file=sys.stderr, + ) + + try: + archive_bytes = _fetch(url) + except (URLError, OSError) as exc: + print( + "archgate: failed to download binary: {detail}\n" + "Visit https://cli.archgate.dev/getting-started/installation/ " + "for alternative install methods.".format(detail=exc), + file=sys.stderr, + ) + sys.exit(2) + + _verify_checksum(archive_bytes, artifact, ext) + + # Extract into a temporary directory, then move the binary into the + # cache so we never leave a half-written file in place. + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + _extract(archive_bytes, ext, tmp_path) + + # The binary may sit at the root of the archive or inside a + # single top-level directory — search for it. + bin_name = _binary_name() + candidates = list(tmp_path.rglob(bin_name)) + if not candidates: + print( + "archgate: failed to locate binary inside the archive.", + file=sys.stderr, + ) + sys.exit(2) + + cache_dir.mkdir(parents=True, exist_ok=True) + + src = candidates[0] + # On Windows, rename requires the destination not to exist. + if binary.exists(): + binary.unlink() + + # Copy instead of rename to handle cross-device moves. + import shutil + + shutil.copy2(str(src), str(binary)) + + # Set executable permission on Unix. + if platform.system() != "Windows": + binary.chmod(binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + print("archgate: binary downloaded successfully.", file=sys.stderr) + + result = subprocess.run([str(binary)] + sys.argv[1:]) + sys.exit(result.returncode) diff --git a/shims/pypi/archgate/_version.py b/shims/pypi/archgate/_version.py new file mode 100644 index 00000000..e72781a7 --- /dev/null +++ b/shims/pypi/archgate/_version.py @@ -0,0 +1 @@ +__version__ = "0.39.0" diff --git a/shims/pypi/pyproject.toml b/shims/pypi/pyproject.toml new file mode 100644 index 00000000..d5289f75 --- /dev/null +++ b/shims/pypi/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "archgate" +version = "0.39.0" +description = "Enforce Architecture Decision Records as executable rules — for both humans and AI agents" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "Apache-2.0" } +keywords = [ + "adr", + "archgate", + "architecture-decision-records", + "cli", + "governance", + "linter", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Quality Assurance", +] +dependencies = [] + +[project.scripts] +archgate = "archgate._shim:main" + +[project.urls] +Homepage = "https://cli.archgate.dev" +Repository = "https://github.com/archgate/cli" +Issues = "https://github.com/archgate/cli/issues" diff --git a/shims/pypi/tests/test_shim.py b/shims/pypi/tests/test_shim.py new file mode 100644 index 00000000..79e46edd --- /dev/null +++ b/shims/pypi/tests/test_shim.py @@ -0,0 +1,144 @@ +"""Unit tests for the archgate PyPI shim. Uses only the stdlib unittest module.""" + +from __future__ import annotations + +import hashlib +import sys +import unittest +from unittest import mock + +# --------------------------------------------------------------------------- +# Ensure the package is importable regardless of working directory. +# --------------------------------------------------------------------------- +from pathlib import Path + +_PKG_ROOT = str(Path(__file__).resolve().parent.parent) +if _PKG_ROOT not in sys.path: + sys.path.insert(0, _PKG_ROOT) + +from archgate._shim import ( # noqa: E402 + _PLATFORM_MAP, + _archive_ext, + _binary_name, + _detect_artifact, + _verify_checksum, +) + + +class TestPlatformDetection(unittest.TestCase): + """Verify the platform → artifact mapping.""" + + def test_darwin_arm64(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "Darwin" + mp.machine.return_value = "arm64" + self.assertEqual(_detect_artifact(), "archgate-darwin-arm64") + + def test_darwin_aarch64(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "Darwin" + mp.machine.return_value = "aarch64" + self.assertEqual(_detect_artifact(), "archgate-darwin-arm64") + + def test_linux_x86_64(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "Linux" + mp.machine.return_value = "x86_64" + self.assertEqual(_detect_artifact(), "archgate-linux-x64") + + def test_linux_amd64(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "Linux" + mp.machine.return_value = "AMD64" + self.assertEqual(_detect_artifact(), "archgate-linux-x64") + + def test_windows_amd64(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "Windows" + mp.machine.return_value = "AMD64" + self.assertEqual(_detect_artifact(), "archgate-win32-x64") + + def test_windows_x86_64(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "Windows" + mp.machine.return_value = "x86_64" + self.assertEqual(_detect_artifact(), "archgate-win32-x64") + + def test_unsupported_platform_exits(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "FreeBSD" + mp.machine.return_value = "i386" + with self.assertRaises(SystemExit) as ctx: + _detect_artifact() + self.assertEqual(ctx.exception.code, 2) + + +class TestArtifactNaming(unittest.TestCase): + """Verify artifact name, binary name, and archive extension.""" + + def test_artifact_names_in_platform_map(self): + expected_artifacts = { + "archgate-darwin-arm64", + "archgate-linux-x64", + "archgate-win32-x64", + } + self.assertEqual(set(_PLATFORM_MAP.values()), expected_artifacts) + + def test_binary_name_windows(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "Windows" + self.assertEqual(_binary_name(), "archgate.exe") + + def test_binary_name_unix(self): + for os_name in ("Darwin", "Linux"): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = os_name + self.assertEqual(_binary_name(), "archgate") + + def test_archive_ext_windows(self): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = "Windows" + self.assertEqual(_archive_ext(), "zip") + + def test_archive_ext_unix(self): + for os_name in ("Darwin", "Linux"): + with mock.patch("archgate._shim.platform") as mp: + mp.system.return_value = os_name + self.assertEqual(_archive_ext(), "tar.gz") + + +class TestChecksumVerification(unittest.TestCase): + """Verify SHA256 checksum logic.""" + + def test_checksum_pass(self): + data = b"hello archgate" + expected_hash = hashlib.sha256(data).hexdigest() + checksum_content = (expected_hash + " archgate-linux-x64.tar.gz\n").encode() + + with mock.patch("archgate._shim._fetch", return_value=checksum_content): + # Should not raise or exit. + _verify_checksum(data, "archgate-linux-x64", "tar.gz") + + def test_checksum_mismatch_exits(self): + data = b"hello archgate" + wrong_hash = hashlib.sha256(b"wrong data").hexdigest() + checksum_content = (wrong_hash + " archgate-linux-x64.tar.gz\n").encode() + + with mock.patch("archgate._shim._fetch", return_value=checksum_content): + with self.assertRaises(SystemExit) as ctx: + _verify_checksum(data, "archgate-linux-x64", "tar.gz") + self.assertEqual(ctx.exception.code, 2) + + def test_checksum_unavailable_warns(self): + """When the checksum file can't be fetched, warn but don't exit.""" + from urllib.error import URLError + + data = b"hello archgate" + + with mock.patch("archgate._shim._fetch", side_effect=URLError("404")): + # Should not raise or exit. + _verify_checksum(data, "archgate-linux-x64", "tar.gz") + + +if __name__ == "__main__": + unittest.main() diff --git a/shims/rubygem/Gemfile b/shims/rubygem/Gemfile new file mode 100644 index 00000000..d5c0f4f9 --- /dev/null +++ b/shims/rubygem/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +group :test do + gem "minitest", "~> 5.0" +end diff --git a/shims/rubygem/archgate.gemspec b/shims/rubygem/archgate.gemspec new file mode 100644 index 00000000..2574a001 --- /dev/null +++ b/shims/rubygem/archgate.gemspec @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "lib/archgate/version" + +Gem::Specification.new do |spec| + spec.name = "archgate" + spec.version = Archgate::VERSION + spec.authors = ["Archgate"] + spec.email = ["hello@archgate.dev"] + + spec.summary = "Enforce Architecture Decision Records as executable rules" + spec.description = "Enforce Architecture Decision Records as executable rules -- for both humans and AI agents" + spec.homepage = "https://cli.archgate.dev" + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 2.7.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/archgate/cli" + spec.metadata["bug_tracker_uri"] = "https://github.com/archgate/cli/issues" + + spec.files = Dir["lib/**/*.rb", "exe/*"] + spec.bindir = "exe" + spec.executables = ["archgate"] + spec.require_paths = ["lib"] +end diff --git a/shims/rubygem/exe/archgate b/shims/rubygem/exe/archgate new file mode 100644 index 00000000..2a9d6ed9 --- /dev/null +++ b/shims/rubygem/exe/archgate @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "archgate" + +Archgate::Shim.run(ARGV) diff --git a/shims/rubygem/lib/archgate.rb b/shims/rubygem/lib/archgate.rb new file mode 100644 index 00000000..c986613d --- /dev/null +++ b/shims/rubygem/lib/archgate.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative "archgate/version" +require_relative "archgate/shim" diff --git a/shims/rubygem/lib/archgate/shim.rb b/shims/rubygem/lib/archgate/shim.rb new file mode 100644 index 00000000..fdcd099b --- /dev/null +++ b/shims/rubygem/lib/archgate/shim.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require "digest/sha2" +require "fileutils" +require "net/http" +require "rbconfig" +require "rubygems/package" +require "stringio" +require "tmpdir" +require "uri" +require "zlib" + +require_relative "version" + +module Archgate + # Thin shim that downloads the archgate binary from GitHub Releases on first + # invocation and then executes it. Zero runtime dependencies -- stdlib only. + module Shim + BASE_URL = "https://github.com/archgate/cli/releases/download" + + # Platform mapping: [os_pattern, arch_pattern] => artifact name + PLATFORM_MAP = [ + [/darwin|mac/i, /arm64|aarch64/i, "archgate-darwin-arm64"], + [/linux/i, /x86_64|x64|amd64/i, "archgate-linux-x64"], + [/mswin|mingw|cygwin/i, /x86_64|x64|amd64/i, "archgate-win32-x64"] + ].freeze + + class << self + def run(args) + binary = binary_path + + unless File.exist?(binary) + begin + download_binary + rescue StandardError => e + $stderr.puts "archgate: failed to download binary: #{e.message}" + $stderr.puts "Visit https://cli.archgate.dev/getting-started/installation/ for alternative install methods." + exit 2 + end + end + + if windows? + system(binary, *args) + exit($?.exitstatus || 1) + else + Kernel.exec(binary, *args) + end + end + + # -- Platform detection -------------------------------------------------- + + def host_os + RbConfig::CONFIG["host_os"] + end + + def host_cpu + RbConfig::CONFIG["host_cpu"] + end + + def detect_artifact + PLATFORM_MAP.each do |os_pat, arch_pat, artifact| + return artifact if host_os.match?(os_pat) && host_cpu.match?(arch_pat) + end + + $stderr.puts "archgate: Unsupported platform: #{host_os}/#{host_cpu}" + $stderr.puts "archgate supports darwin/arm64, linux/x64, and win32/x64." + exit 2 + end + + def windows? + host_os.match?(/mswin|mingw|cygwin/i) + end + + def binary_name + windows? ? "archgate.exe" : "archgate" + end + + def archive_ext + windows? ? "zip" : "tar.gz" + end + + def cache_dir + File.join(Dir.home, ".archgate", "bin") + end + + def binary_path + File.join(cache_dir, binary_name) + end + + # -- Download / verification --------------------------------------------- + + def download_url(artifact, ext) + "#{BASE_URL}/v#{VERSION}/#{artifact}.#{ext}" + end + + def checksum_url(artifact, ext) + "#{download_url(artifact, ext)}.sha256" + end + + def fetch(url, limit: 10) + raise "too many HTTP redirects" if limit <= 0 + + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + + request = Net::HTTP::Get.new(uri) + request["User-Agent"] = "archgate-cli-ruby" + + response = http.request(request) + + case response + when Net::HTTPSuccess + response.body + when Net::HTTPRedirection + fetch(response["location"], limit: limit - 1) + else + raise "GET #{url} returned status #{response.code}" + end + end + + def verify_checksum(archive_data, artifact, ext) + checksum_data = begin + fetch(checksum_url(artifact, ext)) + rescue StandardError + $stderr.puts "archgate: warning: checksum file not available, skipping verification" + return + end + + expected = checksum_data.strip.split(/\s+/).first + actual = Digest::SHA256.hexdigest(archive_data) + + return if expected == actual + + raise "checksum verification failed for v#{VERSION} (expected #{expected}, got #{actual})" + end + + private + + def download_binary + artifact = detect_artifact + ext = archive_ext + url = download_url(artifact, ext) + + $stderr.puts "archgate: binary not found, downloading v#{VERSION}..." + + archive_data = fetch(url) + verify_checksum(archive_data, artifact, ext) + + FileUtils.mkdir_p(cache_dir) + dest = binary_path + + if ext == "zip" + extract_zip(archive_data, dest) + else + extract_tar_gz(archive_data, dest) + end + + File.chmod(0o755, dest) unless windows? + + $stderr.puts "archgate: binary downloaded successfully." + end + + def extract_tar_gz(archive_data, dest) + bin_name = binary_name + found = false + + io = StringIO.new(archive_data) + Zlib::GzipReader.wrap(io) do |gz| + Gem::Package::TarReader.new(gz) do |tar| + tar.each do |entry| + name = entry.full_name + if name == bin_name || name.end_with?("/#{bin_name}") + File.binwrite(dest, entry.read) + found = true + break + end + end + end + end + + raise "binary #{bin_name} not found in archive" unless found + end + + def extract_zip(archive_data, dest) + Dir.mktmpdir("archgate-") do |tmp| + zip_path = File.join(tmp, "archgate-download.zip") + extract_dir = File.join(tmp, "archgate-extract") + + File.binwrite(zip_path, archive_data) + FileUtils.mkdir_p(extract_dir) + + system( + "powershell", "-NoProfile", "-Command", + "Expand-Archive -Path '#{zip_path}' -DestinationPath '#{extract_dir}' -Force" + ) + + bin = binary_name + extracted = Dir.glob(File.join(extract_dir, "**", bin)).first + raise "binary #{bin} not found in zip archive" unless extracted + + FileUtils.cp(extracted, dest) + end + end + end + end +end diff --git a/shims/rubygem/lib/archgate/version.rb b/shims/rubygem/lib/archgate/version.rb new file mode 100644 index 00000000..0aa9dcff --- /dev/null +++ b/shims/rubygem/lib/archgate/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Archgate + VERSION = "0.39.0" +end diff --git a/shims/rubygem/test/test_shim.rb b/shims/rubygem/test/test_shim.rb new file mode 100644 index 00000000..78fd7558 --- /dev/null +++ b/shims/rubygem/test/test_shim.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "digest/sha2" + +# Ensure the gem's lib/ is on the load path regardless of working directory. +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) + +require "archgate/shim" + +class TestPlatformDetection < Minitest::Test + # Helper: stub host_os and host_cpu on the Shim module, call detect_artifact. + def detect_with(os, cpu) + Archgate::Shim.stub(:host_os, os) do + Archgate::Shim.stub(:host_cpu, cpu) do + Archgate::Shim.detect_artifact + end + end + end + + def test_darwin_arm64 + assert_equal "archgate-darwin-arm64", detect_with("darwin23", "arm64") + end + + def test_darwin_aarch64 + assert_equal "archgate-darwin-arm64", detect_with("darwin23", "aarch64") + end + + def test_linux_x86_64 + assert_equal "archgate-linux-x64", detect_with("linux-gnu", "x86_64") + end + + def test_linux_amd64 + assert_equal "archgate-linux-x64", detect_with("linux-gnu", "AMD64") + end + + def test_windows_x86_64 + assert_equal "archgate-win32-x64", detect_with("mingw32", "x86_64") + end + + def test_windows_amd64 + assert_equal "archgate-win32-x64", detect_with("mswin64", "AMD64") + end + + def test_windows_cygwin_x64 + assert_equal "archgate-win32-x64", detect_with("cygwin", "x86_64") + end + + def test_unsupported_platform_exits + err = assert_raises(SystemExit) do + detect_with("freebsd13", "i386") + end + assert_equal 2, err.status + end +end + +class TestArtifactNaming < Minitest::Test + def test_artifact_name_construction + artifact = "archgate-linux-x64" + ext = "tar.gz" + url = Archgate::Shim.download_url(artifact, ext) + expected = "https://github.com/archgate/cli/releases/download/v#{Archgate::VERSION}/archgate-linux-x64.tar.gz" + assert_equal expected, url + end + + def test_checksum_url_construction + artifact = "archgate-darwin-arm64" + ext = "tar.gz" + url = Archgate::Shim.checksum_url(artifact, ext) + assert url.end_with?(".tar.gz.sha256") + end +end + +class TestBinaryName < Minitest::Test + def test_binary_name_windows + Archgate::Shim.stub(:windows?, true) do + assert_equal "archgate.exe", Archgate::Shim.binary_name + end + end + + def test_binary_name_unix + Archgate::Shim.stub(:windows?, false) do + assert_equal "archgate", Archgate::Shim.binary_name + end + end +end + +class TestArchiveExt < Minitest::Test + def test_archive_ext_windows + Archgate::Shim.stub(:windows?, true) do + assert_equal "zip", Archgate::Shim.archive_ext + end + end + + def test_archive_ext_unix + Archgate::Shim.stub(:windows?, false) do + assert_equal "tar.gz", Archgate::Shim.archive_ext + end + end +end + +class TestChecksumVerification < Minitest::Test + def test_checksum_pass + data = "hello archgate" + expected_hash = Digest::SHA256.hexdigest(data) + checksum_content = "#{expected_hash} archgate-linux-x64.tar.gz\n" + + Archgate::Shim.stub(:fetch, ->(_url) { checksum_content }) do + # Should not raise + Archgate::Shim.verify_checksum(data, "archgate-linux-x64", "tar.gz") + end + end + + def test_checksum_mismatch_raises + data = "hello archgate" + wrong_hash = Digest::SHA256.hexdigest("wrong data") + checksum_content = "#{wrong_hash} archgate-linux-x64.tar.gz\n" + + Archgate::Shim.stub(:fetch, ->(_url) { checksum_content }) do + err = assert_raises(RuntimeError) do + Archgate::Shim.verify_checksum(data, "archgate-linux-x64", "tar.gz") + end + assert_match(/checksum verification failed/, err.message) + end + end + + def test_checksum_unavailable_warns_and_continues + data = "hello archgate" + + Archgate::Shim.stub(:fetch, ->(_url) { raise StandardError, "404" }) do + # Should not raise -- just warns and returns + Archgate::Shim.verify_checksum(data, "archgate-linux-x64", "tar.gz") + end + end +end From ff31931dc5e480cd88bfc477affdac3679eee760 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Thu, 28 May 2026 14:19:52 +0000 Subject: [PATCH 02/19] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index ebc575fa..09c990d7 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -204,6 +204,66 @@ yarn check:adrs pnpm check:adrs ``` +## Install via pip (Python) + +Install Archgate globally using pip or pipx: + +```bash +# pip +pip install archgate + +# pipx (recommended for CLI tools) +pipx install archgate +``` + +This installs a lightweight Python wrapper that delegates to a platform-specific binary. Python 3.8+ is required. + +## Install via dotnet + +Install Archgate as a .NET global tool: + +```bash +dotnet tool install -g archgate +``` + +Requires .NET 8.0+ SDK. The tool downloads the platform binary on first run. + +## Install via Go + +Install Archgate using `go install`: + +```bash +go install github.com/archgate/cli/shims/go/cmd/archgate@latest +``` + +Requires Go 1.21+. The compiled Go wrapper downloads the platform binary on first run. + +## Install via RubyGems + +Install Archgate as a Ruby gem: + +```bash +gem install archgate +``` + +Requires Ruby 2.7+. The gem downloads the platform binary on first run. + +## Install via Maven / jbang (Java) + +Install Archgate using jbang: + +```bash +jbang app install archgate@dev.archgate +``` + +Or download the executable JAR from Maven Central (`dev.archgate:archgate-cli`) and run directly: + +```bash +java -jar archgate-cli-0.39.0.jar check +``` + +Requires Java 11+. The shim downloads the platform binary on first run. + ## Platform support Archgate ships pre-built binaries for the following platforms: From fd3dc50002523a34ec91e3f4b56237a851066b1c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 28 May 2026 17:04:52 +0200 Subject: [PATCH 03/19] chore: update agent memory index Signed-off-by: Rhuan Barreto --- .claude/agent-memory/archgate-developer/MEMORY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index c444e686..b3480757 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -61,6 +61,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - **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. - **Always use `bun run test`, never bare `bun test`, in CI workflows** — The package.json `test` script includes `--timeout 60000`, but bare `bun test` uses Bun's default 5000ms timeout. Tests that perform filesystem operations or spawn subprocesses (e.g., session-context tests) can exceed 5s on slow CI runners. The `release-binaries.yml` Windows step originally used `bun test` and hit timeout failures. Same principle as "never use `bunx prettier` directly" — always prefer `bun run