diff --git a/.archgate/adrs/ARCH-009-platform-detection-helper.md b/.archgate/adrs/ARCH-009-platform-detection-helper.md new file mode 100644 index 00000000..839b0fa2 --- /dev/null +++ b/.archgate/adrs/ARCH-009-platform-detection-helper.md @@ -0,0 +1,81 @@ +--- +id: ARCH-009 +title: Centralized Platform Detection +domain: architecture +rules: true +files: + - "src/**/*.ts" +--- + +## Context + +The Archgate CLI runs on macOS, Linux, and Windows (including WSL). Platform-specific behavior appears throughout the codebase: shell syntax in user-facing messages, path separators, subprocess resolution, and feature availability checks. + +The `src/helpers/platform.ts` module already provides a centralized, cached API for platform detection (`isWindows()`, `isMacOS()`, `isLinux()`, `isWSL()`, `getPlatformInfo()`). It also exposes a `_resetPlatformCache()` function that allows tests to simulate different platforms without mocking `process.platform` directly. + +The problem is that nothing prevents code from bypassing this module and reading `process.platform` directly. Direct reads: + +- **Scatter platform logic** — Platform checks end up duplicated across modules with inconsistent patterns (`process.platform === "win32"` vs `process.platform !== "linux"`). +- **Cannot be tested** — `process.platform` is read-only in Bun. Code that reads it directly cannot be tested under a different platform without modifying global state. The platform helper's `_resetPlatformCache()` makes cross-platform testing straightforward. +- **Miss WSL** — `process.platform` returns `"linux"` inside WSL. Code that checks for `"win32"` to decide Windows-specific behavior will miss WSL scenarios where Windows paths or tools are relevant. The platform helper accounts for WSL. + +## Decision + +All platform detection in `src/` MUST go through `src/helpers/platform.ts`. Direct access to `process.platform` outside of `platform.ts` is **forbidden**. + +This covers: + +- OS-conditional logic (Windows vs macOS vs Linux) +- WSL detection +- Platform-specific user-facing messages (shell syntax, paths) +- Feature availability checks that depend on the OS + +This does NOT cover: + +- Test files (`tests/**/*.ts`) — tests may inspect `process.platform` for conditional assertions +- Build scripts and configuration files outside `src/` + +## Do's and Don'ts + +### Do + +- **DO** import from `src/helpers/platform.ts` for any platform check: `isWindows()`, `isMacOS()`, `isLinux()`, `isWSL()`, `getPlatformInfo()` +- **DO** use `_resetPlatformCache()` in tests to simulate different platforms +- **DO** consider WSL when implementing Windows-specific behavior — `isWSL()` returns true when running Linux inside WSL, where Windows tools may still be relevant + +### Don't + +- **DON'T** read `process.platform` directly in source files — use the platform helper instead +- **DON'T** duplicate platform detection logic that already exists in `platform.ts` +- **DON'T** assume `"linux"` means a native Linux environment — it could be WSL + +## Consequences + +### Positive + +- **Single source of truth** — All platform detection flows through one module with consistent caching and WSL awareness. +- **Testable** — Cross-platform behavior can be tested on any OS via `_resetPlatformCache()`. +- **WSL-safe** — The helper correctly distinguishes native Linux from WSL, preventing subtle bugs. + +### Negative + +- **Import overhead** — Modules that need a one-off platform check must import from `platform.ts` instead of reading `process.platform` directly. + +## Compliance and Enforcement + +### Automated Enforcement + +- **Archgate rule** `ARCH-009/no-direct-process-platform`: Scans all TypeScript source files (excluding `platform.ts` itself and tests) for direct `process.platform` access. Severity: `error`. + +### Manual Enforcement + +Code reviewers MUST verify: + +1. No `process.platform` reads appear outside `src/helpers/platform.ts` +2. Platform-conditional logic uses the helper functions, not inline checks +3. WSL is considered when the behavior differs between Linux and Windows + +## References + +- [ARCH-007 — Cross-Platform Subprocess Execution](./ARCH-007-cross-platform-subprocess-execution.md) — Related cross-platform concern for subprocess APIs +- `src/helpers/platform.ts` — The canonical platform detection module diff --git a/.archgate/adrs/ARCH-009-platform-detection-helper.rules.ts b/.archgate/adrs/ARCH-009-platform-detection-helper.rules.ts new file mode 100644 index 00000000..c6bfb863 --- /dev/null +++ b/.archgate/adrs/ARCH-009-platform-detection-helper.rules.ts @@ -0,0 +1,31 @@ +import { defineRules } from "../../src/formats/rules"; + +export default defineRules({ + "no-direct-process-platform": { + description: + "Platform detection must use src/helpers/platform.ts, not process.platform directly", + async check(ctx) { + const files = ctx.scopedFiles.filter( + (f) => + !f.includes("tests/") && + !f.includes(".archgate/") && + !f.endsWith("src/helpers/platform.ts") + ); + + const matches = await Promise.all( + files.map((file) => ctx.grep(file, /process\.platform/)) + ); + for (const fileMatches of matches) { + for (const m of fileMatches) { + ctx.report.violation({ + message: + "Do not access process.platform directly — use isWindows(), isMacOS(), isLinux(), or getPlatformInfo() from src/helpers/platform.ts instead.", + file: m.file, + line: m.line, + fix: 'Import { isWindows } from "../helpers/platform" (or the appropriate helper) and use it instead of process.platform', + }); + } + } + }, + }, +}); diff --git a/.archgate/adrs/GEN-002-docs-i18n.rules.ts b/.archgate/adrs/GEN-002-docs-i18n.rules.ts index 16e2f759..742b3ab5 100644 --- a/.archgate/adrs/GEN-002-docs-i18n.rules.ts +++ b/.archgate/adrs/GEN-002-docs-i18n.rules.ts @@ -9,7 +9,42 @@ const LOCALES = ["pt-br"]; const CONTENT_ROOT = "docs/src/content/docs"; +/** Patterns that match locale-prefixed internal links in MDX files. */ +const LOCALE_LINK_PATTERNS = LOCALES.map( + (locale) => new RegExp(`(?:href="|\\]\\()/${locale}/`, "g") +); + export default defineRules({ + "no-locale-prefix-in-links": { + description: + "Locale pages must not use locale-prefixed internal links — Starlight resolves them automatically", + severity: "error", + async check(ctx) { + /* oxlint-disable no-await-in-loop -- sequential per-locale is fine for a small list */ + for (const locale of LOCALES) { + const localePrefix = `${CONTENT_ROOT}/${locale}/`; + const localeFiles = (await ctx.glob(`${localePrefix}**/*.mdx`)).filter( + (f) => f.startsWith(localePrefix) + ); + const pattern = LOCALE_LINK_PATTERNS[LOCALES.indexOf(locale)]; + + const matches = await Promise.all( + localeFiles.map((file) => ctx.grep(file, pattern)) + ); + for (const fileMatches of matches) { + for (const m of fileMatches) { + ctx.report.violation({ + message: `Internal link contains locale prefix "/${locale}/". Remove the prefix — Starlight resolves locale routes automatically.`, + file: m.file, + line: m.line, + fix: `Replace "/${locale}/..." with "/..." in the link`, + }); + } + } + } + /* oxlint-enable no-await-in-loop */ + }, + }, "i18n-page-parity": { description: "Every root MDX file must have a corresponding translation in each locale, and vice versa", diff --git a/.github/workflows/code-pull-request.yml b/.github/workflows/code-pull-request.yml index 592b24be..ce7dcc72 100644 --- a/.github/workflows/code-pull-request.yml +++ b/.github/workflows/code-pull-request.yml @@ -44,6 +44,13 @@ jobs: env: PR_TITLE: ${{ github.event.pull_request.title }} run: echo "$PR_TITLE" | bun run commitlint + - name: Check llms-full.txt is up to date + run: | + bun run docs/scripts/generate-llms-full.ts + git diff --exit-code docs/public/llms-full.txt || { + echo "::error::docs/public/llms-full.txt is out of date. Run 'bun run docs/scripts/generate-llms-full.ts' and commit the result." + exit 1 + } - name: Validate id: validate run: bun run validate diff --git a/.github/workflows/update-llms.yaml b/.github/workflows/update-llms.yaml new file mode 100644 index 00000000..da2373e5 --- /dev/null +++ b/.github/workflows/update-llms.yaml @@ -0,0 +1,47 @@ +name: Update llms-full.txt +on: + pull_request: + branches: + - main + paths: + - "docs/src/content/docs/**" + - "docs/scripts/generate-llms-full.ts" + - ".github/workflows/update-llms.yaml" +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + update-llms: + name: Update llms-full.txt + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.GH_APP_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - uses: actions/checkout@v4 + with: + token: ${{ steps.generate_token.outputs.token }} + fetch-depth: 1 + ref: ${{ github.head_ref }} + - uses: moonrepo/setup-toolchain@v0 + with: + auto-install: true + cache: true + cache-base: main + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Regenerate llms-full.txt + run: bun run docs/scripts/generate-llms-full.ts + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v6 + with: + commit_message: "docs: regenerate llms-full.txt" + file_pattern: docs/public/llms-full.txt diff --git a/.simple-release.js b/.simple-release.js index 2aae4012..c1bb8884 100644 --- a/.simple-release.js +++ b/.simple-release.js @@ -23,6 +23,7 @@ class ArchgateProject extends NpmProject { if (changed) { writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); execSync("bun install", { stdio: "inherit" }); + this.changedFiles.push("bun.lock"); } } diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index f5dad02c..3464f72b 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -105,7 +105,7 @@ The Archgate CLI works standalone, but **editor plugins** unlock a full AI gover Cursor's AI agent the same governance workflow as Claude Code. -Editor plugins are currently in beta. Sign up at [plugins.archgate.dev](https://plugins.archgate.dev) to get access, then install with `archgate login` and `archgate init --install-plugin`. +Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate, then `archgate init --install-plugin` to set up the plugin. ## Learn more @@ -130,10 +130,13 @@ irm https://raw.githubusercontent.com/archgate/cli/main/install.ps1 | iex This downloads a pre-built binary for your platform and installs it to `~/.archgate/bin/`. The installer detects your shell profiles and offers to add the directory to your PATH. You can customize the install with environment variables: -- `ARCHGATE_VERSION` — Install a specific version (e.g. `v0.11.2`). Default: latest release. -- `ARCHGATE_INSTALL_DIR` — Custom install directory. Default: `~/.archgate/bin`. -You can also download binaries directly from GitHub Releases: https://github.com/archgate/cli/releases +| Variable | Description | Default | +| ---------------------- | ------------------------------------------- | ----------------- | +| `ARCHGATE_VERSION` | Install a specific version (e.g. `v0.11.2`) | Latest release | +| `ARCHGATE_INSTALL_DIR` | Custom install directory | `~/.archgate/bin` | + +You can also download binaries directly from [GitHub Releases](https://github.com/archgate/cli/releases). ## Install via npm @@ -186,11 +189,7 @@ bun run archgate check Or add a script to your `package.json`: ```json -{ - "scripts": { - "check:adrs": "archgate check" - } -} +{ "scripts": { "check:adrs": "archgate check" } } ``` ```bash @@ -245,7 +244,7 @@ Restart your shell, then run `npm install -g archgate` again. The `archgate` com Once installed, run `archgate init` in your project to set up governance. See the [Quick Start](/getting-started/quick-start/) guide for a walkthrough. -Want your AI agent to read ADRs before coding and validate after? The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) add a full governance workflow on top of the CLI. [Sign up for beta access](https://plugins.archgate.dev). +Want your AI agent to read ADRs before coding and validate after? The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) add a full governance workflow on top of the CLI. Run `archgate login` to sign up and get started. --- @@ -388,7 +387,7 @@ Now that you have a working setup, dive deeper: - [Claude Code Plugin](/guides/claude-code-plugin/) — Give AI agents a full governance workflow with role-based skills. - [Cursor Integration](/guides/cursor-integration/) — Use Archgate with Cursor IDE for AI-assisted development. -Want AI agents that automatically read your ADRs before coding? Sign up for the editor plugin beta at [plugins.archgate.dev](https://plugins.archgate.dev), then run `archgate login` and `archgate init --install-plugin`. +Want AI agents that automatically read your ADRs before coding? Run `archgate login` to sign up and authenticate, then run `archgate init --install-plugin` to set up the plugin. --- @@ -994,6 +993,26 @@ adr-compliance: - archgate check ``` +## Standalone installer (no Node.js) + +If your CI environment does not have Node.js, use the standalone installer to download a pre-built binary directly from GitHub Releases: + +```yaml +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: curl -fsSL https://raw.githubusercontent.com/archgate/cli/main/install.sh | sh + - run: ~/.archgate/bin/archgate check --ci +``` + +This works in any environment with `curl` and `tar` — no runtime dependencies needed. You can pin a version with the `ARCHGATE_VERSION` environment variable: + +```yaml +- run: curl -fsSL https://raw.githubusercontent.com/archgate/cli/main/install.sh | ARCHGATE_VERSION=v0.11.2 sh +``` + ## Bun-based CI If your CI already uses Bun, install Archgate with `bun` instead of `npm`: @@ -1013,7 +1032,7 @@ jobs: Archgate works with any CI system that can run shell commands. The pattern is always the same: -1. Install: `npm install -g archgate` (or `bun install -g archgate`) +1. Install: `npm install -g archgate`, `bun install -g archgate`, or use the [standalone installer](/getting-started/installation/#install-standalone-recommended) 2. Run: `archgate check` 3. Check the exit code (0 = pass, 1 = violations, 2 = error) @@ -1071,7 +1090,7 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings ## Installation -The Claude Code plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access before following the steps below. +The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. ### 1. Log in with GitHub @@ -1207,7 +1226,7 @@ Copilot CLI supports plugin installation from git repositories using `copilot pl ## Installation -The Copilot CLI plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access before following the steps below. +The Copilot CLI plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. ### 1. Log in with GitHub @@ -1340,7 +1359,7 @@ archgate init --editor cursor ### With plugin (beta) -The Cursor plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access. +The Cursor plugin is currently in beta. Run `archgate login` to sign up and authenticate. If you have logged in via `archgate login`, the init command also downloads and installs the Archgate plugin for Cursor. The plugin provides pre-built agent rules and skills that give Cursor's AI agent a full governance workflow. @@ -1589,7 +1608,7 @@ The plugin is served in VS Code Copilot's native `.github/plugin/` manifest form Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlier versions do not support git-based agent plugin marketplaces. Check your version with `code --version`. -The VS Code plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access before following the steps below. +The VS Code plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. ### 1. Log in with GitHub @@ -2781,7 +2800,7 @@ archgate check --help ## archgate login -Authenticate with GitHub to access Archgate editor plugins. +Authenticate with GitHub to access Archgate editor plugins. If you are not registered yet, the CLI handles signup automatically -- it prompts for your email, editor preference (Claude Code, VS Code, Copilot CLI, or Cursor), and use case, then registers you before completing the login. ```bash archgate login @@ -2789,9 +2808,11 @@ archgate login Starts a GitHub Device Flow (OAuth). The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and authorize the Archgate GitHub OAuth App. Once authorized, the CLI exchanges your GitHub identity for an Archgate plugin token and stores both in `~/.archgate/credentials`. +If your GitHub account is not yet registered, the CLI prompts for your email, preferred editor, and use case, then signs you up automatically. + Credentials are required to install editor plugins via `archgate init --install-plugin`. The CLI itself (check, init, etc.) works without login. -Editor plugins are currently in beta. Sign up at [plugins.archgate.dev](https://plugins.archgate.dev) to get access. +Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate. ### Subcommands @@ -2818,12 +2839,61 @@ and enter the code: ABCD-1234 Waiting for authorization... GitHub user: yourname + +No account found. Let's get you signed up. +Email: you@example.com +Editor: Claude Code +Use case: Enforcing ADRs in our monorepo + +Registering... Claiming archgate plugin token... Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` +If the project already has `.archgate/adrs/`, the final line reads: + +``` +Run `archgate check` to validate your project against its ADRs. +``` + +### Troubleshooting + +#### TLS/corporate proxy errors + +If `archgate login` fails with a TLS certificate error (common behind corporate proxies), point your runtime at your organization's CA bundle using the `NODE_EXTRA_CA_CERTS` environment variable. + +On macOS/Linux: + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/your-corporate-ca.pem +archgate login +``` + +On Windows (PowerShell): + +```powershell +$env:NODE_EXTRA_CA_CERTS = "C:\path\to\your-corporate-ca.pem" +archgate login +``` + +On Windows (cmd): + +```cmd +set NODE_EXTRA_CA_CERTS=C:\path\to\your-corporate-ca.pem +archgate login +``` + +On Windows (Git Bash): + +```bash +export NODE_EXTRA_CA_CERTS=/c/path/to/your-corporate-ca.pem +archgate login +``` + +Ask your IT team for the correct certificate path if you are unsure. + Check login status: ```bash @@ -3561,9 +3631,7 @@ interface ViolationDetail { The return type of `defineRules`. You do not construct this directly. ```typescript -type RuleSet = { - rules: Record; -}; +type RuleSet = { rules: Record }; ``` --- diff --git a/docs/src/content/docs/getting-started/installation.mdx b/docs/src/content/docs/getting-started/installation.mdx index 729da993..e19f086d 100644 --- a/docs/src/content/docs/getting-started/installation.mdx +++ b/docs/src/content/docs/getting-started/installation.mdx @@ -133,5 +133,5 @@ Restart your shell, then run `npm install -g archgate` again. The `archgate` com Once installed, run `archgate init` in your project to set up governance. See the [Quick Start](/getting-started/quick-start/) guide for a walkthrough. :::tip[Editor plugins (beta)] -Want your AI agent to read ADRs before coding and validate after? The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) add a full governance workflow on top of the CLI. [Sign up for beta access](https://plugins.archgate.dev). +Want your AI agent to read ADRs before coding and validate after? The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) add a full governance workflow on top of the CLI. Run `archgate login` to sign up and get started. ::: diff --git a/docs/src/content/docs/getting-started/quick-start.mdx b/docs/src/content/docs/getting-started/quick-start.mdx index 1059e2d3..6cabb145 100644 --- a/docs/src/content/docs/getting-started/quick-start.mdx +++ b/docs/src/content/docs/getting-started/quick-start.mdx @@ -140,5 +140,5 @@ Now that you have a working setup, dive deeper: - [Cursor Integration](/guides/cursor-integration/) — Use Archgate with Cursor IDE for AI-assisted development. :::tip[Editor plugins (beta)] -Want AI agents that automatically read your ADRs before coding? Sign up for the editor plugin beta at [plugins.archgate.dev](https://plugins.archgate.dev), then run `archgate login` and `archgate init --install-plugin`. +Want AI agents that automatically read your ADRs before coding? Run `archgate login` to sign up and authenticate, then run `archgate init --install-plugin` to set up the plugin. ::: diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index df6aa57f..02b03641 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -29,7 +29,7 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings ## Installation :::note[Beta access required] -The Claude Code plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access before following the steps below. +The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. ::: ### 1. Log in with GitHub diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx index 71532266..31d6f207 100644 --- a/docs/src/content/docs/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -12,7 +12,7 @@ Copilot CLI supports plugin installation from git repositories using `copilot pl ## Installation :::note[Beta access required] -The Copilot CLI plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access before following the steps below. +The Copilot CLI plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. ::: ### 1. Log in with GitHub diff --git a/docs/src/content/docs/guides/cursor-integration.mdx b/docs/src/content/docs/guides/cursor-integration.mdx index f817341f..8c116808 100644 --- a/docs/src/content/docs/guides/cursor-integration.mdx +++ b/docs/src/content/docs/guides/cursor-integration.mdx @@ -16,7 +16,7 @@ archgate init --editor cursor ### With plugin (beta) :::note[Beta access required] -The Cursor plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access. +The Cursor plugin is currently in beta. Run `archgate login` to sign up and authenticate. ::: If you have logged in via `archgate login`, the init command also downloads and installs the Archgate plugin for Cursor. The plugin provides pre-built agent rules and skills that give Cursor's AI agent a full governance workflow. diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index 23ff5072..77ce062a 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -18,7 +18,7 @@ Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlie ::: :::note[Beta access required] -The VS Code plugin is currently in beta. [Sign up at plugins.archgate.dev](https://plugins.archgate.dev) to get access before following the steps below. +The VS Code plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. ::: ### 1. Log in with GitHub diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index e578b631..307b2912 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -75,7 +75,7 @@ The Archgate CLI works standalone, but **editor plugins** unlock a full AI gover :::tip[Beta access] -Editor plugins are currently in beta. Sign up at [plugins.archgate.dev](https://plugins.archgate.dev) to get access, then install with `archgate login` and `archgate init --install-plugin`. +Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate, then `archgate init --install-plugin` to set up the plugin. ::: @@ -84,6 +84,16 @@ Editor plugins are currently in beta. Sign up at [plugins.archgate.dev](https:// href="/guides/claude-code-plugin/" description="Full setup and usage guide for the Claude Code plugin." /> + + :::tip[Acesso beta] -Os plugins para editores estão atualmente em beta. Cadastre-se em [plugins.archgate.dev](https://plugins.archgate.dev) para obter acesso, depois instale com `archgate login` e `archgate init --install-plugin`. +Os plugins para editores estão atualmente em beta. Execute `archgate login` para se cadastrar e autenticar, depois `archgate init --install-plugin` para configurar o plugin. ::: @@ -85,6 +85,16 @@ Os plugins para editores estão atualmente em beta. Cadastre-se em [plugins.arch href="/guides/claude-code-plugin/" description="Guia completo de configuração e uso do plugin Claude Code." /> + + = { claude: "Claude Code", @@ -22,6 +25,14 @@ const EDITOR_DIRS: Record = { copilot: ".github/copilot/", }; +/** Map init editor flags to signup editor identifiers. */ +const SIGNUP_EDITORS: Record = { + claude: "claude-code", + cursor: "cursor", + vscode: "vscode", + copilot: "copilot-cli", +}; + const editorOption = new Option("--editor ", "editor integration") .choices(["claude", "cursor", "vscode", "copilot"] as const) .default("claude" as const); @@ -37,9 +48,34 @@ export function registerInitCommand(program: Command) { ) .action(async (opts) => { try { - // Auto-detect: install plugin if credentials exist (unless explicitly off) - const installPlugin = - opts.installPlugin ?? (await loadCredentials()) !== null; + let hasCredentials = (await loadCredentials()) !== null; + + // If no credentials and --install-plugin not explicitly set, offer to log in + // Skip interactive prompts in non-TTY environments (agent-driven runs) + if ( + !hasCredentials && + opts.installPlugin === undefined && + process.stdin.isTTY + ) { + const { wantPlugin } = await inquirer.prompt([ + { + type: "confirm", + name: "wantPlugin", + message: + "Would you like to install the Archgate editor plugin? (requires GitHub login)", + default: true, + }, + ]); + + if (wantPlugin) { + const result = await runLoginFlow({ + editor: SIGNUP_EDITORS[opts.editor], + }); + hasCredentials = result.ok; + } + } + + const installPlugin = opts.installPlugin ?? hasCredentials; const result = await initProject(process.cwd(), { editor: opts.editor, @@ -74,6 +110,10 @@ export function registerInitCommand(program: Command) { ); } } catch (err) { + if (isTlsError(err)) { + logError(tlsHintMessage()); + process.exit(1); + } logError(err instanceof Error ? err.message : String(err)); process.exit(1); } @@ -99,7 +139,7 @@ function printManualInstructions(editor: EditorTarget, detail?: string): void { console.log(` ${styleText("bold", "copilot plugin install")} ${detail}`); break; default: - // cursor auto-installs always — should not reach here + // cursor/vscode auto-install — should not reach here break; } } diff --git a/src/commands/login.ts b/src/commands/login.ts index 506a2b37..91a19ca2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -2,16 +2,10 @@ import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; -import { - requestDeviceCode, - pollForAccessToken, - getGitHubUser, - claimArchgateToken, - saveCredentials, - loadCredentials, - clearCredentials, -} from "../helpers/auth"; +import { loadCredentials, clearCredentials } from "../helpers/auth"; import { logError, logInfo } from "../helpers/log"; +import { runLoginFlow } from "../helpers/login-flow"; +import { findProjectRoot } from "../helpers/paths"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; export function registerLoginCommand(program: Command) { @@ -31,7 +25,12 @@ export function registerLoginCommand(program: Command) { return; } - await runDeviceFlow(); + const result = await runLoginFlow(); + if (result.ok) { + printNextStep(); + } else { + process.exit(1); + } } catch (err) { if (isTlsError(err)) { logError(tlsHintMessage()); @@ -70,7 +69,12 @@ export function registerLoginCommand(program: Command) { .action(async () => { try { await clearCredentials(); - await runDeviceFlow(); + const result = await runLoginFlow(); + if (result.ok) { + printNextStep(); + } else { + process.exit(1); + } } catch (err) { if (isTlsError(err)) { logError(tlsHintMessage()); @@ -82,46 +86,14 @@ export function registerLoginCommand(program: Command) { }); } -async function runDeviceFlow(): Promise { - console.log("Authenticating with GitHub...\n"); - - // Step 1: Request device code - const deviceCode = await requestDeviceCode(); - - console.log( - `Open ${styleText("bold", deviceCode.verification_uri)} in your browser` - ); - console.log( - `and enter the code: ${styleText(["bold", "green"], deviceCode.user_code)}\n` - ); - console.log("Waiting for authorization..."); - - // Step 2: Poll for access token - const githubToken = await pollForAccessToken( - deviceCode.device_code, - deviceCode.interval, - deviceCode.expires_in - ); - - // Step 3: Get GitHub username - const githubUser = await getGitHubUser(githubToken); - logInfo(`GitHub user: ${styleText("bold", githubUser)}`); - - // Step 4: Exchange GitHub token for archgate plugin token - console.log("Claiming archgate plugin token..."); - const archgateToken = await claimArchgateToken(githubToken); - - // Step 5: Store credentials - await saveCredentials({ - token: archgateToken, - github_user: githubUser, - created_at: new Date().toISOString().split("T")[0], - }); - - console.log( - `\nAuthenticated as ${styleText("bold", githubUser)}. Plugin access is now available.` - ); - console.log( - "Run `archgate init` to set up a project with the archgate plugin." - ); +function printNextStep(): void { + if (findProjectRoot()) { + console.log( + "Run `archgate check` to validate your project against its ADRs." + ); + } else { + console.log( + "Run `archgate init` to set up a project with the archgate plugin." + ); + } } diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index bca5a0ad..bda75eee 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -9,6 +9,7 @@ import { unlinkSync } from "node:fs"; import { logDebug } from "./log"; import { internalPath, createPathIfNotExists } from "./paths"; +import { SignupRequiredError, isSignupRequiredError } from "./signup"; // --------------------------------------------------------------------------- // Constants @@ -148,10 +149,17 @@ export async function pollForAccessToken( throw new Error("Device code expired. Please try again."); } +export interface GitHubUserInfo { + login: string; + email: string | null; +} + /** - * Step 3: Get the authenticated GitHub username. + * Step 3: Get the authenticated GitHub user info. */ -export async function getGitHubUser(accessToken: string): Promise { +export async function getGitHubUser( + accessToken: string +): Promise { const response = await fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${accessToken}`, @@ -164,11 +172,14 @@ export async function getGitHubUser(accessToken: string): Promise { throw new Error(`Failed to fetch GitHub user (HTTP ${response.status})`); } - const data = (await response.json()) as { login?: string }; + const data = (await response.json()) as { + login?: string; + email?: string | null; + }; if (!data.login) { throw new Error("GitHub API did not return a username"); } - return data.login; + return { login: data.login, email: data.email ?? null }; } // --------------------------------------------------------------------------- @@ -194,6 +205,11 @@ export async function claimArchgateToken(githubToken: string): Promise { const body = (await response.json().catch(() => ({}))) as { error?: string; }; + + if (isSignupRequiredError(body.error)) { + throw new SignupRequiredError(); + } + const message = body.error ?? `Token claim failed (HTTP ${response.status})`; throw new Error(message); @@ -206,6 +222,9 @@ export async function claimArchgateToken(githubToken: string): Promise { return data.token; } +// Re-export for consumers that import from auth.ts +export { SignupRequiredError } from "./signup"; + // --------------------------------------------------------------------------- // Credential Storage // --------------------------------------------------------------------------- diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts new file mode 100644 index 00000000..6da3bdeb --- /dev/null +++ b/src/helpers/login-flow.ts @@ -0,0 +1,157 @@ +/** + * login-flow.ts — Shared GitHub device flow + signup logic + * used by both `login` and `init` commands. + */ + +import { styleText } from "node:util"; + +import inquirer from "inquirer"; + +import { + requestDeviceCode, + pollForAccessToken, + getGitHubUser, + claimArchgateToken, + saveCredentials, +} from "./auth"; +import { logError, logInfo } from "./log"; +import { SignupRequiredError, requestSignup } from "./signup"; + +export interface LoginFlowOptions { + /** + * Pre-selected editor for signup (skip the editor prompt). + * When omitted, the user is prompted to choose. + */ + editor?: string; +} + +export interface LoginFlowResult { + /** Whether credentials were obtained. */ + ok: boolean; + /** GitHub username, if login succeeded. */ + githubUser?: string; +} + +/** + * Run the full GitHub device flow: authenticate, claim token (or sign up + * if the user is unregistered), and store credentials. + * + * Returns `{ ok: true }` when credentials are stored, `{ ok: false }` on + * failure (error is already printed). + */ +export async function runLoginFlow( + options?: LoginFlowOptions +): Promise { + console.log("Authenticating with GitHub...\n"); + + const deviceCode = await requestDeviceCode(); + console.log( + `Open ${styleText("bold", deviceCode.verification_uri)} in your browser` + ); + console.log( + `and enter the code: ${styleText(["bold", "green"], deviceCode.user_code)}\n` + ); + console.log("Waiting for authorization..."); + + const githubToken = await pollForAccessToken( + deviceCode.device_code, + deviceCode.interval, + deviceCode.expires_in + ); + + const { login: githubUser, email: githubEmail } = + await getGitHubUser(githubToken); + logInfo(`GitHub user: ${styleText("bold", githubUser)}`); + + console.log("Claiming archgate plugin token..."); + let archgateToken: string; + try { + archgateToken = await claimArchgateToken(githubToken); + } catch (err) { + if (!(err instanceof SignupRequiredError)) throw err; + + console.log( + `\nYour GitHub account ${styleText("bold", githubUser)} is not yet registered.` + ); + console.log("Let's sign you up now.\n"); + + const result = await runSignupPrompt( + githubUser, + githubToken, + githubEmail, + options?.editor + ); + if (!result) return { ok: false }; + archgateToken = result; + } + + await saveCredentials({ + token: archgateToken, + github_user: githubUser, + created_at: new Date().toISOString().split("T")[0], + }); + + logInfo( + `Authenticated as ${styleText("bold", githubUser)}. Plugin access is now available.` + ); + return { ok: true, githubUser }; +} + +/** + * Prompt for signup details, submit the request, and return the token. + * Returns null on failure (error is already printed). + */ +async function runSignupPrompt( + githubUser: string, + githubToken: string, + githubEmail: string | null, + preselectedEditor?: string +): Promise { + const { email } = await inquirer.prompt({ + type: "input", + name: "email", + message: "Email address:", + default: githubEmail ?? undefined, + validate: (v: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || "Enter a valid email address", + }); + + let editor = preselectedEditor; + if (!editor) { + const ans = await inquirer.prompt({ + type: "list", + name: "editor", + message: "Which editor will you use with archgate?", + choices: [ + { name: "Claude Code", value: "claude-code" }, + { name: "VS Code", value: "vscode" }, + { name: "Copilot CLI", value: "copilot-cli" }, + { name: "Cursor", value: "cursor" }, + ], + }); + editor = ans.editor; + } + + const { useCase } = await inquirer.prompt({ + type: "input", + name: "useCase", + message: "How do you plan to use archgate?", + validate: (v: string) => + v.trim().length > 0 || "Please describe your use case", + }); + + console.log("\nSubmitting signup request..."); + const result = await requestSignup(githubUser, email, useCase, editor!); + + if (!result.ok) { + logError( + "Signup request failed. Please try again or sign up at https://plugins.archgate.dev" + ); + return null; + } + + if (result.token) return result.token; + + console.log("Claiming archgate plugin token..."); + return claimArchgateToken(githubToken); +} diff --git a/src/helpers/signup.ts b/src/helpers/signup.ts new file mode 100644 index 00000000..3c123b87 --- /dev/null +++ b/src/helpers/signup.ts @@ -0,0 +1,63 @@ +/** + * signup.ts — Archgate plugins platform signup for unregistered users. + */ + +const PLUGINS_API = "https://plugins.archgate.dev"; + +/** + * Sentinel error thrown when the token claim endpoint reports that the + * GitHub account has no approved signup. + */ +export class SignupRequiredError extends Error { + constructor() { + super("No approved signup found for this GitHub account"); + this.name = "SignupRequiredError"; + } +} + +/** + * Returns true if the error message indicates the user needs to sign up. + */ +export function isSignupRequiredError(message?: string): boolean { + if (!message) return false; + const lower = message.toLowerCase(); + return ( + lower.includes("no approved signup") || lower.includes("not registered") + ); +} + +export interface SignupResult { + ok: boolean; + /** Token returned by the API when signup is auto-approved. */ + token: string | null; +} + +/** + * Submit a signup request to the archgate plugins platform. + * On auto-approved signups the API returns the token directly, + * avoiding a separate claim round-trip. + */ +export async function requestSignup( + github: string, + email: string, + useCase: string, + editor: string = "claude-code" +): Promise { + const response = await fetch(`${PLUGINS_API}/api/signup`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "archgate-cli", + Origin: PLUGINS_API, + }, + body: JSON.stringify({ github, email, useCase, editor }), + signal: AbortSignal.timeout(15_000), + }); + + if (response.status !== 201) { + return { ok: false, token: null }; + } + + const data = (await response.json().catch(() => ({}))) as { token?: string }; + return { ok: true, token: data.token ?? null }; +} diff --git a/src/helpers/tls.ts b/src/helpers/tls.ts index 7ea9db5c..230870f1 100644 --- a/src/helpers/tls.ts +++ b/src/helpers/tls.ts @@ -3,6 +3,8 @@ * and provide actionable guidance to the user. */ +import { isWindows } from "./platform"; + const TLS_ERROR_PATTERNS = [ "self signed certificate", "unable to get local issuer certificate", @@ -25,15 +27,24 @@ export function isTlsError(error: unknown): boolean { /** * Human-readable hint explaining the TLS failure and how to fix it. + * Shows the correct shell syntax for the current platform. */ export function tlsHintMessage(): string { + const examples = isWindows() + ? [ + ' PowerShell: $env:NODE_EXTRA_CA_CERTS="C:\\path\\to\\corporate-ca.pem"', + " cmd: set NODE_EXTRA_CA_CERTS=C:\\path\\to\\corporate-ca.pem", + ' Git Bash: export NODE_EXTRA_CA_CERTS="/c/path/to/corporate-ca.pem"', + ] + : [' export NODE_EXTRA_CA_CERTS="/path/to/corporate-ca.pem"']; + return [ "TLS certificate verification failed.", "This typically happens behind a corporate proxy that performs SSL inspection.", "", "To fix this, set the NODE_EXTRA_CA_CERTS environment variable to your corporate CA certificate:", "", - ' export NODE_EXTRA_CA_CERTS="/path/to/corporate-ca.pem"', + ...examples, "", "Then retry the command.", ].join("\n"); diff --git a/tests/engine/git-files.test.ts b/tests/engine/git-files.test.ts index 49f6370a..a22e2af9 100644 --- a/tests/engine/git-files.test.ts +++ b/tests/engine/git-files.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -9,6 +9,7 @@ import { getChangedFiles, resolveScopedFiles, } from "../../src/engine/git-files"; +import { git, safeRmSync } from "../test-utils"; describe("git-files", () => { let tempDir: string; @@ -18,7 +19,7 @@ describe("git-files", () => { }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + safeRmSync(tempDir); }); describe("getGitTrackedFiles", () => { @@ -28,9 +29,9 @@ describe("git-files", () => { }); test("returns tracked files in a git repo", async () => { - await Bun.$`git init`.cwd(tempDir).quiet(); + await git(["init"], tempDir); writeFileSync(join(tempDir, "file.ts"), "export const x = 1;"); - await Bun.$`git add file.ts`.cwd(tempDir).quiet(); + await git(["add", "file.ts"], tempDir); const result = await getGitTrackedFiles(tempDir); expect(result).not.toBeNull(); expect(result!.has("file.ts")).toBe(true); @@ -44,9 +45,9 @@ describe("git-files", () => { }); test("returns staged files", async () => { - await Bun.$`git init`.cwd(tempDir).quiet(); + await git(["init"], tempDir); writeFileSync(join(tempDir, "staged.ts"), "export const x = 1;"); - await Bun.$`git add staged.ts`.cwd(tempDir).quiet(); + await git(["add", "staged.ts"], tempDir); const files = await getStagedFiles(tempDir); expect(files).toContain("staged.ts"); }); @@ -59,21 +60,21 @@ describe("git-files", () => { }); test("returns both staged and unstaged changes", async () => { - await Bun.$`git init`.cwd(tempDir).quiet(); - await Bun.$`git config user.email "test@test.com"`.cwd(tempDir).quiet(); - await Bun.$`git config user.name "Test"`.cwd(tempDir).quiet(); + await git(["init"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); writeFileSync(join(tempDir, "a.ts"), "export const a = 1;"); - await Bun.$`git add a.ts`.cwd(tempDir).quiet(); - await Bun.$`git commit -m "init"`.cwd(tempDir).quiet(); + await git(["add", "a.ts"], tempDir); + await git(["commit", "-m", "init"], tempDir); // Stage a new file (staged change) writeFileSync(join(tempDir, "b.ts"), "export const b = 1;"); - await Bun.$`git add b.ts`.cwd(tempDir).quiet(); + await git(["add", "b.ts"], tempDir); // Modify a committed file without staging (unstaged change) writeFileSync(join(tempDir, "a.ts"), "export const a = 2;"); const files = await getChangedFiles(tempDir); expect(files).toContain("a.ts"); expect(files).toContain("b.ts"); - }); + }, 15_000); }); describe("resolveScopedFiles", () => { @@ -83,11 +84,11 @@ describe("git-files", () => { }); test("resolves files matching glob pattern", async () => { - await Bun.$`git init`.cwd(tempDir).quiet(); + await git(["init"], tempDir); mkdirSync(join(tempDir, "src"), { recursive: true }); writeFileSync(join(tempDir, "src", "foo.ts"), "export const x = 1;"); writeFileSync(join(tempDir, "src", "bar.md"), "# Doc"); - await Bun.$`git add .`.cwd(tempDir).quiet(); + await git(["add", "."], tempDir); const files = await resolveScopedFiles(tempDir, ["src/**/*.ts"]); expect(files).toContain("src/foo.ts"); expect(files).not.toContain("src/bar.md"); diff --git a/tests/helpers/auth.test.ts b/tests/helpers/auth.test.ts index 6757e4e7..6f918dc8 100644 --- a/tests/helpers/auth.test.ts +++ b/tests/helpers/auth.test.ts @@ -146,11 +146,16 @@ describe("auth", () => { const { getGitHubUser } = await import("../../src/helpers/auth"); const originalFetch = globalThis.fetch; - mockFetch(() => Promise.resolve(Response.json({ login: "octocat" }))); + mockFetch(() => + Promise.resolve( + Response.json({ login: "octocat", email: "octo@cat.com" }) + ) + ); try { const user = await getGitHubUser("gho_test_token"); - expect(user).toBe("octocat"); + expect(user.login).toBe("octocat"); + expect(user.email).toBe("octo@cat.com"); } finally { globalThis.fetch = originalFetch; } @@ -189,8 +194,9 @@ describe("auth", () => { } }); - test("throws with error message from service on 403", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); + test("throws SignupRequiredError on 403 with no approved signup", async () => { + const { claimArchgateToken, SignupRequiredError } = + await import("../../src/helpers/auth"); const originalFetch = globalThis.fetch; mockFetch(() => @@ -203,8 +209,8 @@ describe("auth", () => { ); try { - await expect(claimArchgateToken("gho_token")).rejects.toThrow( - "No approved signup" + await expect(claimArchgateToken("gho_token")).rejects.toBeInstanceOf( + SignupRequiredError ); } finally { globalThis.fetch = originalFetch; diff --git a/tests/helpers/git.test.ts b/tests/helpers/git.test.ts index 214983e4..f5acf777 100644 --- a/tests/helpers/git.test.ts +++ b/tests/helpers/git.test.ts @@ -1,27 +1,36 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + describe, + expect, + test, + beforeEach, + afterEach, + setDefaultTimeout, +} from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { $ } from "bun"; - import { getChangedFiles } from "../../src/helpers/git"; +import { git, safeRmSync } from "../test-utils"; + +setDefaultTimeout(15_000); describe("getChangedFiles", () => { let tempDir: string; beforeEach(async () => { tempDir = mkdtempSync(join(tmpdir(), "archgate-git-test-")); - await $`git init`.cwd(tempDir).quiet(); - await $`git config user.email "test@test.com"`.cwd(tempDir).quiet(); - await $`git config user.name "Test"`.cwd(tempDir).quiet(); + await git(["init"], tempDir); + await git(["config", "user.email", "test@test.com"], tempDir); + await git(["config", "user.name", "Test"], tempDir); // Create an initial commit so HEAD exists writeFileSync(join(tempDir, "README.md"), "init"); - await $`git add . && git commit -m "init"`.cwd(tempDir).quiet(); + await git(["add", "."], tempDir); + await git(["commit", "-m", "init"], tempDir); }); afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); + safeRmSync(tempDir); }); test("returns empty array when no changes", async () => { @@ -37,14 +46,14 @@ describe("getChangedFiles", () => { test("returns staged files", async () => { writeFileSync(join(tempDir, "new.ts"), "export const x = 1;"); - await $`git add new.ts`.cwd(tempDir).quiet(); + await git(["add", "new.ts"], tempDir); const files = await getChangedFiles(tempDir); expect(files).toContain("new.ts"); }); test("deduplicates files that are both staged and modified", async () => { writeFileSync(join(tempDir, "file.ts"), "v1"); - await $`git add file.ts`.cwd(tempDir).quiet(); + await git(["add", "file.ts"], tempDir); writeFileSync(join(tempDir, "file.ts"), "v2"); const files = await getChangedFiles(tempDir); const count = files.filter((f) => f === "file.ts").length; diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts new file mode 100644 index 00000000..67bd2ba3 --- /dev/null +++ b/tests/helpers/login-flow.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; + +import { runLoginFlow } from "../../src/helpers/login-flow"; +import type { + LoginFlowOptions, + LoginFlowResult, +} from "../../src/helpers/login-flow"; + +describe("login-flow", () => { + test("runLoginFlow is exported as a function", () => { + expect(typeof runLoginFlow).toBe("function"); + }); + + test("LoginFlowOptions accepts editor field", () => { + const opts: LoginFlowOptions = { editor: "claude-code" }; + expect(opts.editor).toBe("claude-code"); + }); + + test("LoginFlowResult shape", () => { + const success: LoginFlowResult = { ok: true, githubUser: "octocat" }; + expect(success.ok).toBe(true); + expect(success.githubUser).toBe("octocat"); + + const failure: LoginFlowResult = { ok: false }; + expect(failure.ok).toBe(false); + expect(failure.githubUser).toBeUndefined(); + }); +}); diff --git a/tests/helpers/signup.test.ts b/tests/helpers/signup.test.ts new file mode 100644 index 00000000..1e6b832e --- /dev/null +++ b/tests/helpers/signup.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; + +import { + SignupRequiredError, + isSignupRequiredError, +} from "../../src/helpers/signup"; + +describe("SignupRequiredError", () => { + test("is an instance of Error", () => { + const err = new SignupRequiredError(); + expect(err).toBeInstanceOf(Error); + }); + + test("has the correct name", () => { + expect(new SignupRequiredError().name).toBe("SignupRequiredError"); + }); + + test("has a descriptive message", () => { + expect(new SignupRequiredError().message).toContain("No approved signup"); + }); +}); + +describe("isSignupRequiredError", () => { + test("matches 'No approved signup found'", () => { + expect( + isSignupRequiredError("No approved signup found for this GitHub account") + ).toBe(true); + }); + + test("matches 'not registered'", () => { + expect(isSignupRequiredError("User is not registered")).toBe(true); + }); + + test("is case-insensitive", () => { + expect(isSignupRequiredError("NO APPROVED SIGNUP")).toBe(true); + }); + + test("returns false for unrelated messages", () => { + expect(isSignupRequiredError("Token expired")).toBe(false); + }); + + test("returns false for no argument", () => { + expect(isSignupRequiredError()).toBe(false); + }); +}); diff --git a/tests/helpers/tls.test.ts b/tests/helpers/tls.test.ts index 9ebdf326..9f0c097d 100644 --- a/tests/helpers/tls.test.ts +++ b/tests/helpers/tls.test.ts @@ -47,4 +47,15 @@ describe("tlsHintMessage", () => { test("mentions corporate proxy", () => { expect(tlsHintMessage()).toContain("corporate proxy"); }); + + test("shows shell-appropriate syntax per platform", () => { + const msg = tlsHintMessage(); + if (process.platform === "win32") { + expect(msg).toContain("PowerShell:"); + expect(msg).toContain("cmd:"); + expect(msg).toContain("Git Bash:"); + } else { + expect(msg).toContain("export NODE_EXTRA_CA_CERTS="); + } + }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts new file mode 100644 index 00000000..7a4b46c8 --- /dev/null +++ b/tests/test-utils.ts @@ -0,0 +1,42 @@ +import { rmSync } from "node:fs"; + +/** + * Run a git command in the given directory via Bun.spawn (ARCH-007 compliant). + * Returns stdout as a trimmed string. + */ +export async function git(args: string[], cwd: string): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error( + `git ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim()}` + ); + } + return stdout.trim(); +} + +/** + * Remove a temp directory with retries to handle Windows EBUSY errors + * caused by git processes that haven't fully released file locks yet. + */ +export function safeRmSync(dir: string, retries = 5): void { + for (let i = 0; i <= retries; i++) { + try { + rmSync(dir, { recursive: true, force: true }); + return; + } catch (err: unknown) { + const code = + err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined; + const isRetryable = + code === "EBUSY" || code === "EPERM" || code === "ENOTEMPTY"; + if (!isRetryable || i === retries) throw err; + Bun.sleepSync(200 * (i + 1)); + } + } +}