From f3709e314e6ac1bbd7c2f28765b4a39f28313283 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 28 Feb 2026 02:14:20 +0100 Subject: [PATCH 1/2] feat: add Windows (x64) build and CI smoke test - Add win32/x64 platform support across all layers: - npm package wrapper (bin/archgate.cjs) resolves .exe binary - Platform guard in src/cli.ts accepts win32 - package.json os field and optionalDependencies - Git helper gives actionable install URL on Windows - Add archgate-win32-x64 platform package (packages/) - Add bun-windows-x64 target to release-binaries workflow with Windows-specific build and publish steps - Add windows-smoke-test.yml CI workflow that runs lint, typecheck, format, tests, and binary smoke test on PRs - Fix cross-platform path handling throughout the codebase: - adr-writer: use basename() instead of split("/").pop() - loader: use file:// URLs for dynamic import on Windows - runner: normalize glob/relative paths to forward slashes - git-files: normalize glob scan output to match git output - Fix tests: use forward-slash paths in import specifiers - Update docs (README, CONTRIBUTING, CLAUDE.md) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release-binaries.yml | 38 +++++++++++---- .github/workflows/windows-smoke-test.yml | 60 ++++++++++++++++++++++++ CLAUDE.md | 4 +- CONTRIBUTING.md | 2 +- README.md | 2 +- bin/archgate.cjs | 6 ++- bun.lock | 1 + package.json | 8 ++-- packages/archgate-win32-x64/package.json | 19 ++++++++ src/cli.ts | 4 +- src/engine/git-files.ts | 5 +- src/engine/loader.ts | 5 +- src/engine/runner.ts | 7 +-- src/helpers/adr-writer.ts | 4 +- src/helpers/git.ts | 4 ++ tests/commands/check.test.ts | 4 +- tests/engine/loader.test.ts | 4 +- 17 files changed, 144 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/windows-smoke-test.yml create mode 100644 packages/archgate-win32-x64/package.json diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 001ea567..c56a1048 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -23,9 +23,15 @@ jobs: - target: bun-darwin-arm64 artifact: archgate-darwin-arm64 os: macos-latest + binary_name: archgate - target: bun-linux-x64 artifact: archgate-linux-x64 os: ubuntu-latest + binary_name: archgate + - target: bun-windows-x64 + artifact: archgate-win32-x64 + os: windows-latest + binary_name: archgate.exe runs-on: ${{ matrix.os }} steps: @@ -39,26 +45,38 @@ jobs: - run: bun install --frozen-lockfile - - run: bun run validate + - name: Validate (Unix) + if: runner.os != 'Windows' + run: bun run validate + + - name: Validate (Windows) + if: runner.os == 'Windows' + run: bun run lint && bun run typecheck && bun run format:check && bun test - name: Build binary run: | - bun build src/cli.ts \ - --compile \ - --bytecode \ - --minify \ - --target ${{ matrix.target }} \ - --outfile dist/${{ matrix.artifact }} + bun build src/cli.ts --compile --bytecode --minify --target ${{ matrix.target }} --outfile dist/${{ matrix.artifact }} - - name: Copy binary into npm package + - name: Copy binary into npm package (Unix) + if: runner.os != 'Windows' run: | TAG="${{ github.event.release.tag_name || inputs.tag }}" VERSION="${TAG#v}" - cp dist/${{ matrix.artifact }} packages/${{ matrix.artifact }}/bin/archgate - chmod +x packages/${{ matrix.artifact }}/bin/archgate + cp dist/${{ matrix.artifact }} packages/${{ matrix.artifact }}/bin/${{ matrix.binary_name }} + chmod +x packages/${{ matrix.artifact }}/bin/${{ matrix.binary_name }} cd packages/${{ matrix.artifact }} npm version "$VERSION" --no-git-tag-version + - name: Copy binary into npm package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $tag = "${{ github.event.release.tag_name || inputs.tag }}" + $version = $tag -replace '^v', '' + Copy-Item "dist/${{ matrix.artifact }}.exe" "packages/${{ matrix.artifact }}/bin/${{ matrix.binary_name }}" + Set-Location "packages/${{ matrix.artifact }}" + npm version "$version" --no-git-tag-version + - uses: actions/setup-node@v4 with: registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/windows-smoke-test.yml b/.github/workflows/windows-smoke-test.yml new file mode 100644 index 00000000..ea9cecc7 --- /dev/null +++ b/.github/workflows/windows-smoke-test.yml @@ -0,0 +1,60 @@ +name: Windows Smoke Test + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + branches: + - main + +concurrency: + group: windows-smoke-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + smoke-test: + name: Windows Smoke Test + runs-on: windows-latest + timeout-minutes: 15 + if: github.event.pull_request.draft == false + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: moonrepo/setup-toolchain@v0 + with: + auto-install: true + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bun run typecheck + + - name: Format check + run: bun run format:check + + - name: Tests + run: bun test --timeout 60000 + + - name: Build Windows binary + run: bun build src/cli.ts --compile --bytecode --outfile dist/archgate-smoke-test + + - name: Smoke test binary + shell: pwsh + run: | + $binary = "dist/archgate-smoke-test.exe" + if (-not (Test-Path $binary)) { + Write-Error "Binary not found at $binary" + exit 1 + } + $output = & $binary --version 2>&1 + Write-Host "archgate version: $output" + if ($LASTEXITCODE -ne 0) { + Write-Error "Binary exited with code $LASTEXITCODE" + exit 1 + } diff --git a/CLAUDE.md b/CLAUDE.md index 6f50998e..2773c18f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ bun run format # prettier --write bun run format:check # prettier --check bun test # all tests bun run validate # MANDATORY: lint + typecheck + format + test + ADR check + build check -bun run build # binaries → dist/ (darwin-arm64, linux-x64) +bun run build # binaries → dist/ (darwin-arm64, linux-x64, win32-x64) bun run commit # conventional commit wizard ``` @@ -60,7 +60,7 @@ Zod schemas are the single source of truth. Types derived via `z.infer<>` — ne ## Conventions - Commands export `register*Command(program)`, handle I/O only — no business logic -- OS: macOS and Linux only (Windows blocked, WSL2 recommended) +- OS: macOS, Linux, and Windows - Output: `styleText()` from `node:util`; `--json` for machine-readable; no emoji - Exit codes: 0 = success, 1 = violation, 2 = internal error - Deps: minimal; prefer Bun built-ins (see ARCH-006) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72059f4c..1382759b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Thank you for your interest in contributing to Archgate CLI! We welcome all kinds of contributions. -> **Note:** Development is only supported on macOS, Linux, or Windows via WSL2. Native Windows development is not supported. +> **Note:** Development is supported on macOS, Linux, and Windows. ## 🚀 Quick Start diff --git a/README.md b/README.md index b39ad80e..0704aec2 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ When a rule is violated, `archgate check` reports the file, line, and which ADR npm install -g archgate ``` -**Requirements:** macOS (arm64) or Linux (x86_64). Node.js is only needed to run the wrapper — the CLI itself is a standalone binary. +**Requirements:** macOS (arm64), Linux (x86_64), or Windows (x86_64). Node.js is only needed to run the wrapper — the CLI itself is a standalone binary. > **Using [proto](https://moonrepo.dev/proto)?** Add the following to `~/.proto/config.toml` and your shell profile so globals persist across Node.js version switches: > diff --git a/bin/archgate.cjs b/bin/archgate.cjs index c06960cd..7aa158e2 100755 --- a/bin/archgate.cjs +++ b/bin/archgate.cjs @@ -9,16 +9,18 @@ function getPlatformPackageName() { const { platform, arch } = process; if (platform === "darwin" && arch === "arm64") return "archgate-darwin-arm64"; if (platform === "linux" && arch === "x64") return "archgate-linux-x64"; + if (platform === "win32" && arch === "x64") return "archgate-win32-x64"; throw new Error( - `Unsupported platform: ${platform}/${arch}\narchgate supports darwin/arm64 and linux/x64 only.` + `Unsupported platform: ${platform}/${arch}\narchgate supports darwin/arm64, linux/x64, and win32/x64.` ); } function getBinaryPath() { const pkgName = getPlatformPackageName(); + const binaryName = process.platform === "win32" ? "archgate.exe" : "archgate"; try { const pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`)); - const binaryPath = path.join(pkgDir, "bin", "archgate"); + const binaryPath = path.join(pkgDir, "bin", binaryName); if (fs.existsSync(binaryPath)) return binaryPath; } catch { /* platform package not installed */ diff --git a/bun.lock b/bun.lock index 981032d6..8ead9218 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "optionalDependencies": { "archgate-darwin-arm64": ">=0.1.1", "archgate-linux-x64": ">=0.1.1", + "archgate-win32-x64": ">=0.1.1", }, "peerDependencies": { "typescript": "^5", diff --git a/package.json b/package.json index ac19f0cf..f60bdde4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ }, "os": [ "darwin", - "linux" + "linux", + "win32" ], "scripts": { "cli": "bun run src/cli.ts", @@ -44,7 +45,7 @@ "format:check": "prettier --check .", "test": "bun test --timeout 60000", "test:watch": "bun test --watch --timeout 60000", - "build:check": "bun build src/cli.ts --compile --bytecode --outfile dist/.build-check && rm -f dist/.build-check", + "build:check": "bun build src/cli.ts --compile --bytecode --outfile dist/.build-check && rm -f dist/.build-check dist/.build-check.exe", "validate": "bun run lint && bun run typecheck && bun run format:check && bun test && bun run check && bun run build:check", "commit": "cz", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0" @@ -71,6 +72,7 @@ }, "optionalDependencies": { "archgate-darwin-arm64": ">=0.1.1", - "archgate-linux-x64": ">=0.1.1" + "archgate-linux-x64": ">=0.1.1", + "archgate-win32-x64": ">=0.1.1" } } diff --git a/packages/archgate-win32-x64/package.json b/packages/archgate-win32-x64/package.json new file mode 100644 index 00000000..0f2eeb21 --- /dev/null +++ b/packages/archgate-win32-x64/package.json @@ -0,0 +1,19 @@ +{ + "name": "archgate-win32-x64", + "version": "0.0.0", + "description": "windows x64 binary for archgate", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/archgate.exe" + ], + "license": "FSL-1.1-ALv2", + "repository": { + "type": "git", + "url": "git+https://github.com/archgate/cli.git" + } +} diff --git a/src/cli.ts b/src/cli.ts index 3c3e22d7..01cd1844 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,8 +21,8 @@ if (typeof Bun === "undefined") if (!semver.satisfies(Bun.version, ">=1.2.21")) throw new Error("You need to update Bun to version 1.2.21 or higher"); -if (!["darwin", "linux"].includes(process.platform)) - throw new Error("Archgate only supports macOS and Linux"); +if (!["darwin", "linux", "win32"].includes(process.platform)) + throw new Error("Archgate only supports macOS, Linux, and Windows"); createPathIfNotExists(paths.cacheFolder); diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts index 5436e112..589c00d5 100644 --- a/src/engine/git-files.ts +++ b/src/engine/git-files.ts @@ -29,8 +29,9 @@ export async function resolveScopedFiles( const glob = new Bun.Glob(pattern); // oxlint-disable-next-line no-await-in-loop -- async iterator for await (const file of glob.scan({ cwd: projectRoot, dot: false })) { - if (trackedFiles && !trackedFiles.has(file)) continue; - if (!allFiles.includes(file)) allFiles.push(file); + const normalized = file.replaceAll("\\", "/"); + if (trackedFiles && !trackedFiles.has(normalized)) continue; + if (!allFiles.includes(normalized)) allFiles.push(normalized); } } return allFiles.sort(); diff --git a/src/engine/loader.ts b/src/engine/loader.ts index a01c9c20..c266cd10 100644 --- a/src/engine/loader.ts +++ b/src/engine/loader.ts @@ -1,5 +1,6 @@ import { readdirSync } from "node:fs"; import { join, basename } from "node:path"; +import { pathToFileURL } from "node:url"; import { parseAdr } from "../formats/adr"; import type { AdrDocument } from "../formats/adr"; import { type RuleSet } from "../formats/rules"; @@ -86,8 +87,10 @@ export async function loadRuleAdrs( try { // Cache-bust: Bun caches import() per-process, so append a timestamp // to force re-reading from disk on every call (critical for MCP server). + // Use file:// URL to handle Windows backslash paths in import(). + const rulesUrl = `${pathToFileURL(rulesFile).href}?t=${Date.now()}`; // oxlint-disable-next-line no-await-in-loop -- dynamic import must be sequential - const mod = await import(`${rulesFile}?t=${Date.now()}`); + const mod = await import(rulesUrl); const parsed = RuleSetSchema.safeParse(mod.default); if (!parsed.success) { diff --git a/src/engine/runner.ts b/src/engine/runner.ts index c86bea76..2b963431 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -58,7 +58,7 @@ function createRuleContext( const g = new Bun.Glob(pattern); const results: string[] = []; for await (const file of g.scan({ cwd: projectRoot, dot: false })) { - results.push(file); + results.push(file.replaceAll("\\", "/")); } return results.sort(); }, @@ -73,7 +73,7 @@ function createRuleContext( const match = lines[i].match(pattern); if (match) { matches.push({ - file: relative(projectRoot, absPath), + file: relative(projectRoot, absPath).replaceAll("\\", "/"), line: i + 1, column: (match.index ?? 0) + 1, content: lines[i], @@ -89,6 +89,7 @@ function createRuleContext( const allMatches: GrepMatch[] = []; for await (const file of g.scan({ cwd: projectRoot, dot: false })) { + const normalized = file.replaceAll("\\", "/"); const absPath = join(projectRoot, file); try { const content = await Bun.file(absPath).text(); @@ -98,7 +99,7 @@ function createRuleContext( const match = lines[i].match(pattern); if (match) { allMatches.push({ - file, + file: normalized, line: i + 1, column: (match.index ?? 0) + 1, content: lines[i], diff --git a/src/helpers/adr-writer.ts b/src/helpers/adr-writer.ts index fb9a25a1..61cd7f46 100644 --- a/src/helpers/adr-writer.ts +++ b/src/helpers/adr-writer.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { join, basename } from "node:path"; import { DOMAIN_PREFIXES, type AdrDomain, @@ -163,7 +163,7 @@ export async function updateAdrFile( rules, }); - const fileName = existing.filePath.split("/").pop()!; + const fileName = basename(existing.filePath); await Bun.write(existing.filePath, content); return { id: opts.id, fileName, filePath: existing.filePath }; diff --git a/src/helpers/git.ts b/src/helpers/git.ts index 0c9e08c4..4c600fd4 100644 --- a/src/helpers/git.ts +++ b/src/helpers/git.ts @@ -9,6 +9,10 @@ export function installGit() { console.log("Git is not installed. Installing..."); if (process.platform === "darwin") return $`brew install git`; if (process.platform === "linux") return $`sudo apt-get install -y git`; + if (process.platform === "win32") + throw new Error( + "Git is not installed. Install it from https://git-scm.com/download/win and make sure it is on your PATH." + ); throw new Error("Unsupported platform"); } diff --git a/tests/commands/check.test.ts b/tests/commands/check.test.ts index 7742e239..ea7c6890 100644 --- a/tests/commands/check.test.ts +++ b/tests/commands/check.test.ts @@ -6,7 +6,7 @@ import { loadRuleAdrs } from "../../src/engine/loader"; import { runChecks } from "../../src/engine/runner"; import { getExitCode } from "../../src/engine/reporter"; -// Absolute path to the real defineRules module +// Absolute path to the real defineRules module (forward slashes for import specifiers) const RULES_MODULE_PATH = join( import.meta.dir, "..", @@ -14,7 +14,7 @@ const RULES_MODULE_PATH = join( "src", "formats", "rules.ts" -); +).replaceAll("\\", "/"); describe("check command integration", () => { let tempDir: string; diff --git a/tests/engine/loader.test.ts b/tests/engine/loader.test.ts index 8e17cdcd..30349ce6 100644 --- a/tests/engine/loader.test.ts +++ b/tests/engine/loader.test.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { loadRuleAdrs } from "../../src/engine/loader"; -// Absolute path to the real defineRules module +// Absolute path to the real defineRules module (forward slashes for import specifiers) const RULES_MODULE_PATH = join( import.meta.dir, "..", @@ -18,7 +18,7 @@ const RULES_MODULE_PATH = join( "src", "formats", "rules.ts" -); +).replaceAll("\\", "/"); describe("loadRuleAdrs", () => { let tempDir: string; From bb0e27f3127e2d238322a7a5791be90f6e2f55df Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 28 Feb 2026 02:25:08 +0100 Subject: [PATCH 2/2] fix: add .gitattributes to enforce LF line endings Windows git auto-converts LF to CRLF on checkout, which causes Prettier format checks to fail in CI. Force LF for all text files via .gitattributes. Co-Authored-By: Claude Opus 4.6 --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..51d44e31 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Enforce LF line endings for all text files to avoid CRLF issues on Windows. +* text=auto eol=lf