diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 09d9ceeb..ad0f9b26 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -255,6 +255,10 @@ export default defineConfig({ items: [ { label: "Overview", slug: "reference/cli" }, { label: "archgate login", slug: "reference/cli/login" }, + { + label: "archgate credential", + slug: "reference/cli/credential", + }, { label: "archgate init", slug: "reference/cli/init" }, { label: "archgate plugin", slug: "reference/cli/plugin" }, { label: "archgate check", slug: "reference/cli/check" }, diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 19182afb..2be2c100 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -109,7 +109,7 @@ The Archgate CLI works standalone, but **editor plugins** unlock a full AI guard Cursor's AI agent the same guardrails workflow as Claude Code. -Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate, then `archgate init --install-plugin` to set up the plugin. +Editor plugins are currently in beta. Run `archgate login` to sign in, then `archgate init --install-plugin` to set up the plugin. ## Learn more @@ -344,7 +344,7 @@ If you prefer `npm install -g archgate` instead of the proto plugin, you need to Once installed, run `archgate init` in your project to set up linting and guardrails. 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 guardrails workflow on top of the CLI. Run `archgate login` to sign up and get started. +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 guardrails workflow on top of the CLI. Run `archgate login` to sign in and get started. --- @@ -489,7 +489,7 @@ Now that you have a working setup, dive deeper: - [Claude Code Plugin](/guides/claude-code-plugin/): Give AI agents architecture-aware guardrails 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? Run `archgate login` to sign up and authenticate, then run `archgate init --install-plugin` to set up the plugin. +Want AI agents that automatically read your ADRs before coding? Run `archgate login` to sign in, then run `archgate init --install-plugin` to set up the plugin. --- @@ -1328,17 +1328,17 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings ## Installation -The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. +The Claude Code plugin is currently in beta. Run `archgate login` to sign in. -### 1. Log in with GitHub +### 1. Log in to the Archgate platform -Authenticate with your GitHub account to obtain a plugin token: +Log in to obtain plugin access: ```bash archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -1363,7 +1363,7 @@ If the `claude` CLI is on your PATH, the plugin is installed automatically via: 1. `claude plugin marketplace add https://plugins.archgate.dev/archgate.git` (registers the Archgate marketplace) 2. `claude plugin install archgate@archgate` (installs the plugin) -The marketplace URL carries no credentials -- git authenticates against `plugins.archgate.dev` using the token `archgate login` stored in your OS credential manager. +The marketplace URL carries no credentials -- git authenticates against `plugins.archgate.dev` using the token `archgate login` handed to your git credential helper. If the `claude` CLI is not found, the command prints the manual commands for you to run. @@ -1475,17 +1475,17 @@ Installation is declarative: the CLI writes the marketplace and plugin declarati ## Installation -The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. +The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign in before following the steps below. -### 1. Log in with GitHub +### 1. Log in to the Archgate platform -Authenticate with your GitHub account to obtain a plugin token: +Log in to obtain plugin access: ```bash archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -1607,7 +1607,7 @@ Run `archgate init` with the `--editor cursor` flag to configure Cursor integrat archgate init --editor cursor ``` -The Cursor plugin is currently in beta. Run `archgate login` to sign up and authenticate. +The Cursor plugin is currently in beta. Run `archgate login` to sign in. If you have logged in via `archgate login`, the init command also installs the Archgate plugin for Cursor. The plugin provides pre-built agents, skills, and hooks that give Cursor's AI agent a full guardrails workflow. @@ -1910,7 +1910,7 @@ Run `archgate init` with the `--editor opencode` flag to configure opencode inte archgate init --editor opencode ``` -The opencode agent bundle is currently in beta. Run `archgate login` to sign up and authenticate. +The opencode agent bundle is currently in beta. Run `archgate login` to sign in. The opencode agents and skills are **not written to your project tree** — unlike Cursor, which also installs its agents and skills at the user scope but still writes its `afterFileEdit` hook into the project's `.cursor/hooks.json`. For opencode nothing editor-specific lands in the repository: everything lives on your machine and is available across every project you open with opencode. @@ -2277,9 +2277,9 @@ When cloning or forking a repository that uses Archgate, the security scanner au ### Credentials -The `archgate login` command stores your authentication token in the operating system's credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. The token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. +The `archgate login` command hands your tokens to the git credential helper you have configured, via `git credential approve`. A helper backed by the operating system's credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text, so check yours with `git config --global credential.helper`. The token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. -- Plugin installation commands never embed the token in a URL or pass it as a command-line argument: git-based installs authenticate through the OS credential manager, and API downloads send the token in an `Authorization` header. Neither is visible in process listings. +- Plugin installation commands never embed the token in a URL or pass it as a command-line argument: git-based installs authenticate through your git credential helper, and API downloads send the token in an `Authorization` header. Neither is visible in process listings. - To revoke access, run `archgate login logout`. ### Self-update integrity @@ -2308,17 +2308,17 @@ 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. Run `archgate login` to sign up and authenticate before following the steps below. +The VS Code plugin is currently in beta. Run `archgate login` to sign in before following the steps below. -### 1. Log in with GitHub +### 1. Log in to the Archgate platform -Authenticate with your GitHub account to obtain a plugin token: +Log in to obtain plugin access: ```bash archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -2377,7 +2377,7 @@ The user-level marketplace setting (added to your `settings.json`): } ``` -The URL carries no credentials. Git authenticates against `plugins.archgate.dev` using the token `archgate login` stored in your OS credential manager. +The URL carries no credentials. `archgate credential` is the credential helper; `archgate login` registers it for `plugins.archgate.dev`. Git asks it on each request and receives a valid access token, renewed only when the cached one has lapsed. ### Manual setup @@ -2388,12 +2388,17 @@ If you prefer not to let the CLI modify your user settings, you can set things u **Extension:** Download and install the `.vsix` file directly: ```bash -curl -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix -code --install-extension archgate.vsix -rm archgate.vsix +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | + archgate credential get | + sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode && + code --install-extension archgate.vsix +rm -f archgate.vsix ``` -Replace `` with the plugin token `archgate login` stored for `plugins.archgate.dev` in your OS credential manager. You can read it back with `git credential fill` (`printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | git credential fill`). +`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. `--fail` makes curl exit non-zero on a 401 rather than writing the error body over `archgate.vsix`, and `&&` keeps `code` from installing anything curl did not finish downloading; the final `rm -f` cleans up either way. + +Prefer the marketplace setup above where you can: it lets git ask the helper on every request, so no token is handled by hand at all. ## What the plugin provides @@ -4459,6 +4464,88 @@ When the running binary lives under `~/.archgate/bin/`, the message notes that ` --- +## Reference: archgate credential + +Source: https://cli.archgate.dev/reference/cli/credential/ + +Supplies git with a valid access token for Archgate plugin repositories. You do not normally run this command yourself -- git invokes it, and [`archgate login`](/reference/cli/login/) registers it for you. + +```bash +archgate credential get +``` + +Plugin repositories are cloned over HTTPS from `plugins.archgate.dev`, and git authenticates those requests itself rather than going through the CLI. Access tokens are short-lived, so a token written once into your credential manager would stop working within the hour. This helper closes that gap: git asks it for credentials on each request, and it answers with a cached access token, renewing it from the stored refresh token only once it has lapsed. + +The command speaks git's [credential helper protocol](https://git-scm.com/docs/git-credential) on standard input and output. It answers only for `plugins.archgate.dev` and stays silent for every other host, so credentials for the rest of your remotes keep using whatever helper you already configured. + +## Subcommands + +| Subcommand | Description | +| --------------------------- | --------------------------------------------------------- | +| `archgate credential get` | Print credentials for a plugin repository | +| `archgate credential store` | Accept a credential from git (no-op) | +| `archgate credential erase` | Drop the cached access token when git reports it rejected | + +`store` is deliberately a no-op: tokens come from `archgate login`, so there is nothing for git to teach the CLI. + +`erase` drops only the cached access token and keeps your session. Git erases whenever the credentials it was given are rejected, and a single 401 — an access token that slipped past the renewal window, or a transient error from the plugins host — should not cost you a full sign-in. The next request renews from the refresh token. + +## Configuration + +`archgate login` writes the following to your global git config: + +```ini +[credential "https://plugins.archgate.dev"] + helper = + helper = !/home/you/.archgate/bin/archgate credential +``` + +The empty first entry resets any helper inherited from a broader config scope, so `archgate` is the only helper consulted for this host. The executable is recorded by absolute path, so git started by an editor or a GUI client finds it without your shell's PATH. `archgate login logout` removes both entries. + +## Examples + +Inspect what git receives: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` +protocol=https +host=plugins.archgate.dev +username=yourname +password=eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCJ9... +``` + +When you are signed out, the command prints nothing and exits successfully, which tells git to fall through to its other helpers: + +```bash +archgate login logout +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` + +``` + +## Troubleshooting + +### `git clone` prompts for a username and password + +The helper is not registered. Run `archgate login` again, or check the configuration directly: + +```bash +git config --get-all credential.https://plugins.archgate.dev.helper +``` + +The output should list an empty line followed by `!`, the absolute path of the archgate executable, and `credential`. If it does not, the CLI could not write your global git config -- confirm `git config --global --list` works. + +### The helper is registered but credentials are still rejected + +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper returns no credentials and reports the error rather than staying silent. It reads only the token set stored by `archgate login`; a token issued before this flow existed is never served to git. Run `archgate login` again. + +--- + ## Reference: archgate doctor Source: https://cli.archgate.dev/reference/cli/doctor/ @@ -4545,19 +4632,20 @@ archgate --log-level debug check ## Commands -| Command | Description | -| ------------------------------------------------ | ----------------------------------------------------- | -| [`archgate login`](./login/) | Authenticate with GitHub | -| [`archgate init`](./init/) | Set up linting and rules enforcement | -| [`archgate plugin`](./plugin/) | Manage editor plugins | -| [`archgate check`](./check/) | Run ADR compliance checks | -| [`archgate adr`](./adr/) | Create, list, show, and update ADRs | -| [`archgate review-context`](./review-context/) | Pre-compute review context for CI/editor integrations | -| [`archgate session-context`](./session-context/) | Read AI editor session transcripts | -| [`archgate upgrade`](./upgrade/) | Upgrade Archgate to the latest version | -| [`archgate doctor`](./doctor/) | Check system environment and editor integrations | -| [`archgate clean`](./clean/) | Remove the CLI cache directory | -| [`archgate telemetry`](./telemetry/) | Manage anonymous usage data collection | +| Command | Description | +| ------------------------------------------------ | ------------------------------------------------------ | +| [`archgate login`](./login/) | Log in to the Archgate platform | +| [`archgate credential`](./credential/) | Git credential helper for archgate plugin repositories | +| [`archgate init`](./init/) | Set up linting and rules enforcement | +| [`archgate plugin`](./plugin/) | Manage editor plugins | +| [`archgate check`](./check/) | Run ADR compliance checks | +| [`archgate adr`](./adr/) | Create, list, show, and update ADRs | +| [`archgate review-context`](./review-context/) | Pre-compute review context for CI/editor integrations | +| [`archgate session-context`](./session-context/) | Read AI editor session transcripts | +| [`archgate upgrade`](./upgrade/) | Upgrade Archgate to the latest version | +| [`archgate doctor`](./doctor/) | Check system environment and editor integrations | +| [`archgate clean`](./clean/) | Remove the CLI cache directory | +| [`archgate telemetry`](./telemetry/) | Manage anonymous usage data collection | --- @@ -4627,19 +4715,23 @@ Re-running `archgate init` does **not** overwrite a manually configured `baseBra Source: https://cli.archgate.dev/reference/cli/login/ -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, Cursor, VS Code, GitHub Copilot, or opencode), and use case, then registers you before completing the login. +Log in to the Archgate platform. Logging in is what grants access to the editor plugins. ```bash 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 it securely in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI opens your browser at a URL that already carries the one-time code, and copies that code to your clipboard in case the page asks for it. Both the URL and the code are printed as well, so you can finish on another machine or paste them yourself. -If your GitHub account is not yet registered, the CLI prompts for your email, preferred editor, and use case, then signs you up automatically. +Neither step is required. In CI, over SSH, or on a native Linux host with no display server (WSL reaches the Windows desktop) the CLI skips both and just prints them. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. + +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI reports that the session has expired, and `archgate login` re-establishes it. A token issued before this flow existed keeps working only until you sign in to the platform; from then on the platform session alone decides. + +Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). 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. Run `archgate login` to sign up and authenticate. +Editor plugins are currently in beta. Run `archgate login` to sign in. ## Subcommands @@ -4648,7 +4740,7 @@ Editor plugins are currently in beta. Run `archgate login` to sign up and authen | `archgate login` | Authenticate (skips if already logged in) | | `archgate login status` | Show current authentication status | | `archgate login logout` | Remove stored credentials | -| `archgate login refresh` | Re-authenticate and claim a new token | +| `archgate login refresh` | Sign in again, replacing stored tokens | ## Examples @@ -4659,29 +4751,13 @@ archgate login ``` ``` -By signing up, you agree to the Archgate Terms of Service: +By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -info: Authenticating with GitHub... - -Open https://github.com/login/device in your browser -and enter the code: ABCD-1234 +Opened https://auth.archgate.dev/device?user_code=HZML-HXLB in your browser. +If it asks for a code, enter: HZML-HXLB (copied to your clipboard) Waiting for authorization... -info: GitHub user: yourname -info: Claiming archgate plugin token... - -Your GitHub account yourname is not yet registered. -Let's sign you up now. - -? Email address: you@example.com -? Which editor will you use with archgate? Claude Code -? How do you plan to use archgate? Enforcing ADRs in our monorepo -? I agree to be contacted by the Archgate team to provide feedback during the beta period. Yes - -info: Submitting signup request... -info: Claiming archgate plugin token... - info: Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` @@ -5342,7 +5418,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign up via `archgate login`, the **Plugins Service** (`plugins.archgate.dev`) collects personal information: your **email address**, **GitHub username**, **editor choice**, and a **use case description**. This data is used to provision your account and send a welcome email. Authentication tokens are stored as SHA-256 hashes on our servers; on your machine, credentials are kept in your OS credential manager (never as plain-text files). +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate platform** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing, and reads only your username, name or email from the sign-in result to label the session. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/docs/src/content/docs/getting-started/installation.mdx b/docs/src/content/docs/getting-started/installation.mdx index 76fca77c..c9302608 100644 --- a/docs/src/content/docs/getting-started/installation.mdx +++ b/docs/src/content/docs/getting-started/installation.mdx @@ -233,5 +233,5 @@ If you prefer `npm install -g archgate` instead of the proto plugin, you need to Once installed, run `archgate init` in your project to set up linting and guardrails. 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 guardrails workflow on top of the CLI. Run `archgate login` to sign up and get started. +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 guardrails workflow on top of the CLI. Run `archgate login` to sign in 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 fde73777..634111f9 100644 --- a/docs/src/content/docs/getting-started/quick-start.mdx +++ b/docs/src/content/docs/getting-started/quick-start.mdx @@ -141,5 +141,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? Run `archgate login` to sign up and authenticate, then run `archgate init --install-plugin` to set up the plugin. +Want AI agents that automatically read your ADRs before coding? Run `archgate login` to sign in, 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 91b83aa6..18e9d087 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -31,18 +31,18 @@ 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. Run `archgate login` to sign up and authenticate. +The Claude Code plugin is currently in beta. Run `archgate login` to sign in. ::: -### 1. Log in with GitHub +### 1. Log in to the Archgate platform -Authenticate with your GitHub account to obtain a plugin token: +Log in to obtain plugin access: ```bash archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -67,7 +67,7 @@ If the `claude` CLI is on your PATH, the plugin is installed automatically via: 1. `claude plugin marketplace add https://plugins.archgate.dev/archgate.git` (registers the Archgate marketplace) 2. `claude plugin install archgate@archgate` (installs the plugin) -The marketplace URL carries no credentials -- git authenticates against `plugins.archgate.dev` using the token `archgate login` stored in your OS credential manager. +The marketplace URL carries no credentials -- git authenticates against `plugins.archgate.dev` using the token `archgate login` handed to your git credential helper. If the `claude` CLI is not found, the command prints the manual commands for you to run. diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx index fc749ed9..d29abc6d 100644 --- a/docs/src/content/docs/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -16,18 +16,18 @@ Installation is declarative: the CLI writes the marketplace and plugin declarati ## Installation :::note[Beta access required] -The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. +The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign in before following the steps below. ::: -### 1. Log in with GitHub +### 1. Log in to the Archgate platform -Authenticate with your GitHub account to obtain a plugin token: +Log in to obtain plugin access: ```bash archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/cursor-integration.mdx b/docs/src/content/docs/guides/cursor-integration.mdx index a1dba435..89793b17 100644 --- a/docs/src/content/docs/guides/cursor-integration.mdx +++ b/docs/src/content/docs/guides/cursor-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor cursor ``` :::note[Beta access required] -The Cursor plugin is currently in beta. Run `archgate login` to sign up and authenticate. +The Cursor plugin is currently in beta. Run `archgate login` to sign in. ::: If you have logged in via `archgate login`, the init command also installs the Archgate plugin for Cursor. The plugin provides pre-built agents, skills, and hooks that give Cursor's AI agent a full guardrails workflow. diff --git a/docs/src/content/docs/guides/opencode-integration.mdx b/docs/src/content/docs/guides/opencode-integration.mdx index 38a93cba..5fe2f874 100644 --- a/docs/src/content/docs/guides/opencode-integration.mdx +++ b/docs/src/content/docs/guides/opencode-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor opencode ``` :::note[Beta access required] -The opencode agent bundle is currently in beta. Run `archgate login` to sign up and authenticate. +The opencode agent bundle is currently in beta. Run `archgate login` to sign in. ::: The opencode agents and skills are **not written to your project tree** — unlike Cursor, which also installs its agents and skills at the user scope but still writes its `afterFileEdit` hook into the project's `.cursor/hooks.json`. For opencode nothing editor-specific lands in the repository: everything lives on your machine and is available across every project you open with opencode. diff --git a/docs/src/content/docs/guides/security.mdx b/docs/src/content/docs/guides/security.mdx index 55ede7e4..15e339c7 100644 --- a/docs/src/content/docs/guides/security.mdx +++ b/docs/src/content/docs/guides/security.mdx @@ -138,9 +138,9 @@ When cloning or forking a repository that uses Archgate, the security scanner au ### Credentials -The `archgate login` command stores your authentication token in the operating system's credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. The token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. +The `archgate login` command hands your tokens to the git credential helper you have configured, via `git credential approve`. A helper backed by the operating system's credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text, so check yours with `git config --global credential.helper`. The token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. -- Plugin installation commands never embed the token in a URL or pass it as a command-line argument: git-based installs authenticate through the OS credential manager, and API downloads send the token in an `Authorization` header. Neither is visible in process listings. +- Plugin installation commands never embed the token in a URL or pass it as a command-line argument: git-based installs authenticate through your git credential helper, and API downloads send the token in an `Authorization` header. Neither is visible in process listings. - To revoke access, run `archgate login logout`. ### Self-update integrity diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index f2432329..5bb3a384 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -18,18 +18,18 @@ 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. Run `archgate login` to sign up and authenticate before following the steps below. +The VS Code plugin is currently in beta. Run `archgate login` to sign in before following the steps below. ::: -### 1. Log in with GitHub +### 1. Log in to the Archgate platform -Authenticate with your GitHub account to obtain a plugin token: +Log in to obtain plugin access: ```bash archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -88,7 +88,7 @@ The user-level marketplace setting (added to your `settings.json`): } ``` -The URL carries no credentials. Git authenticates against `plugins.archgate.dev` using the token `archgate login` stored in your OS credential manager. +The URL carries no credentials. `archgate credential` is the credential helper; `archgate login` registers it for `plugins.archgate.dev`. Git asks it on each request and receives a valid access token, renewed only when the cached one has lapsed. ### Manual setup @@ -99,12 +99,17 @@ If you prefer not to let the CLI modify your user settings, you can set things u **Extension:** Download and install the `.vsix` file directly: ```bash -curl -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix -code --install-extension archgate.vsix -rm archgate.vsix +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | + archgate credential get | + sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode && + code --install-extension archgate.vsix +rm -f archgate.vsix ``` -Replace `` with the plugin token `archgate login` stored for `plugins.archgate.dev` in your OS credential manager. You can read it back with `git credential fill` (`printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | git credential fill`). +`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. `--fail` makes curl exit non-zero on a 401 rather than writing the error body over `archgate.vsix`, and `&&` keeps `code` from installing anything curl did not finish downloading; the final `rm -f` cleans up either way. + +Prefer the marketplace setup above where you can: it lets git ask the helper on every request, so no token is handled by hand at all. ## What the plugin provides diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 925e9cd9..52f68091 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -81,7 +81,7 @@ The Archgate CLI works standalone, but **editor plugins** unlock a full AI guard :::tip[Beta access] -Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate, then `archgate init --install-plugin` to set up the plugin. +Editor plugins are currently in beta. Run `archgate login` to sign in, then `archgate init --install-plugin` to set up the plugin. ::: diff --git a/docs/src/content/docs/nb/getting-started/installation.mdx b/docs/src/content/docs/nb/getting-started/installation.mdx index 0674cf14..769d4a32 100644 --- a/docs/src/content/docs/nb/getting-started/installation.mdx +++ b/docs/src/content/docs/nb/getting-started/installation.mdx @@ -233,5 +233,5 @@ Hvis du foretrekker `npm install -g archgate` i stedet for proto-pluginen, må d Etter installasjonen, kjør `archgate init` i prosjektet ditt for å sette opp linting og guardrails. Se guiden [Hurtigstart](/getting-started/quick-start/) for en gjennomgang. :::tip[Editorplugins (beta)] -Vil du at AI-agenten din skal lese ADR-er før den koder og validere etterpå? Editorpluginene for [Claude Code](/guides/claude-code-plugin/) og [Cursor](/guides/cursor-integration/) legger til en komplett guardrails-arbeidsflyt på toppen av CLI-en. Kjør `archgate login` for å registrere deg og komme i gang. +Vil du at AI-agenten din skal lese ADR-er før den koder og validere etterpå? Editorpluginene for [Claude Code](/guides/claude-code-plugin/) og [Cursor](/guides/cursor-integration/) legger til en komplett guardrails-arbeidsflyt på toppen av CLI-en. Kjør `archgate login` for å logge inn og komme i gang. ::: diff --git a/docs/src/content/docs/nb/getting-started/quick-start.mdx b/docs/src/content/docs/nb/getting-started/quick-start.mdx index 4ed41daa..cf548702 100644 --- a/docs/src/content/docs/nb/getting-started/quick-start.mdx +++ b/docs/src/content/docs/nb/getting-started/quick-start.mdx @@ -141,5 +141,5 @@ Nå som du har et fungerende oppsett, kan du dykke dypere: - [Cursor-integrasjon](/guides/cursor-integration/) -- Bruk Archgate med Cursor IDE for AI-assistert utvikling. :::tip[Editorplugins (beta)] -Vil du ha AI-agenter som automatisk leser ADR-ene dine før de koder? Kjør `archgate login` for å registrere deg og autentisere, deretter kjør `archgate init --install-plugin` for å sette opp pluginen. +Vil du ha AI-agenter som automatisk leser ADR-ene dine før de koder? Kjør `archgate login` for å logge inn, deretter kjør `archgate init --install-plugin` for å sette opp pluginen. ::: diff --git a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx index 048da7a3..fb658dc4 100644 --- a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx @@ -31,18 +31,18 @@ Pluginen legger til agenter og rollebaserte ferdigheter i Claude Code. Utviklera ## Installasjon :::note[Beta-tilgang kreves] -Claude Code-pluginen er for tiden i beta. Kjør `archgate login` for å registrere deg og autentisere. +Claude Code-pluginen er for tiden i beta. Kjør `archgate login` for å logge inn. ::: -### 1. Logg inn med GitHub +### 1. Logg inn på Archgate-plattformen -Autentiser med GitHub-kontoen din for å få et plugin-token: +Logg inn for å få tilgang til pluginene: ```bash archgate login ``` -Dette starter en GitHub Device Flow. CLI-et viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden og autoriser. Når det er ferdig, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-et viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden og autoriser. Når det er ferdig, gis tokenene til den git credential-helperen du har konfigurert via `git credential approve`; en helper som bruker operativsystemet holder dem krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. ### 2. Initialiser prosjektet ditt med pluginen @@ -67,7 +67,7 @@ Hvis `claude`-CLI-et er på PATH, installeres pluginen automatisk via: 1. `claude plugin marketplace add https://plugins.archgate.dev/archgate.git` (registrerer Archgate-markedsplassen) 2. `claude plugin install archgate@archgate` (installerer pluginen) -Markedsplass-URL-en inneholder ingen legitimasjon -- git autentiserer mot `plugins.archgate.dev` med tokenet `archgate login` lagret i operativsystemets legitimasjonsbehandler. +Markedsplass-URL-en inneholder ingen legitimasjon -- git autentiserer mot `plugins.archgate.dev` med tokenet `archgate login` ga til git credential-helperen din. Hvis `claude`-CLI-et ikke finnes, skriver kommandoen ut de manuelle kommandoene du må kjøre. diff --git a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx index 10b77ae5..b1fa0e19 100644 --- a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx @@ -16,18 +16,18 @@ Installasjonen er deklarativ: CLI-en skriver markedsplass- og plugin-deklarasjon ## Installasjon :::note[Betatilgang kreves] -GitHub Copilot-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere før du følger trinnene nedenfor. +GitHub Copilot-pluginet er for øyeblikket i beta. Kjør `archgate login` for å logge inn før du følger trinnene nedenfor. ::: -### 1. Logg inn med GitHub +### 1. Logg inn på Archgate-plattformen -Autentiser med GitHub-kontoen din for å få et plugin-token: +Logg inn for å få tilgang til pluginene: ```bash archgate login ``` -Dette starter en GitHub Device Flow. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, gis tokenene til den git credential-helperen du har konfigurert via `git credential approve`; en helper som bruker operativsystemet holder dem krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. ### 2. Initialiser prosjektet ditt med pluginet diff --git a/docs/src/content/docs/nb/guides/cursor-integration.mdx b/docs/src/content/docs/nb/guides/cursor-integration.mdx index accfe054..caa6c0f0 100644 --- a/docs/src/content/docs/nb/guides/cursor-integration.mdx +++ b/docs/src/content/docs/nb/guides/cursor-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor cursor ``` :::note[Betatilgang kreves] -Cursor-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere. +Cursor-pluginet er for øyeblikket i beta. Kjør `archgate login` for å logge inn. ::: Hvis du har logget inn via `archgate login`, installerer init-kommandoen også Archgate-pluginet for Cursor. Pluginet tilbyr ferdigbygde agenter, skills og hooks som gir Cursors AI-agent en komplett guardrails-arbeidsflyt. diff --git a/docs/src/content/docs/nb/guides/opencode-integration.mdx b/docs/src/content/docs/nb/guides/opencode-integration.mdx index 9a04bbd2..1ea9fc50 100644 --- a/docs/src/content/docs/nb/guides/opencode-integration.mdx +++ b/docs/src/content/docs/nb/guides/opencode-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor opencode ``` :::note[Betatilgang kreves] -opencode-agentpakken er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere. +opencode-agentpakken er for øyeblikket i beta. Kjør `archgate login` for å logge inn. ::: opencode-agentene og -ferdighetene skrives **ikke til prosjekttreet ditt** -- i motsetning til Cursor, som også installerer sine agenter og ferdigheter på brukernivå, men likevel skriver `afterFileEdit`-hooken sin i prosjektets `.cursor/hooks.json`. For opencode havner ingenting editorspesifikt i repositoriet: alt bor på maskinen din og er tilgjengelig på tvers av alle prosjekter du åpner med opencode. diff --git a/docs/src/content/docs/nb/guides/security.mdx b/docs/src/content/docs/nb/guides/security.mdx index c1799629..9f354f93 100644 --- a/docs/src/content/docs/nb/guides/security.mdx +++ b/docs/src/content/docs/nb/guides/security.mdx @@ -138,9 +138,9 @@ Når du kloner eller forker et repositorium som bruker Archgate, håndhever sikk ### Legitimasjon -Kommandoen `archgate login` lagrer autentiseringstokenet ditt i operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) via `git credential approve`. Ingen legitimasjon skrives til disk som klartekstfiler. Tokenet brukes for plugin-installasjon og sendes aldri til tredjeparter utover Archgate plugins-tjenesten. +Kommandoen `archgate login` gir tokenene dine til den git credential-helperen du har konfigurert, via `git credential approve`. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst, så sjekk din med `git config --global credential.helper`. Tokenet brukes for plugin-installasjon og sendes aldri til tredjeparter utover Archgate plugins-tjenesten. -- Plugin-installasjonskommandoer legger aldri tokenet inn i en URL eller sender det som et kommandolinjeargument: git-baserte installasjoner autentiserer gjennom OS-legitimasjonsbehandleren, og API-nedlastinger sender tokenet i en `Authorization`-header. Ingen av delene er synlige i prosesslister. +- Plugin-installasjonskommandoer legger aldri tokenet inn i en URL eller sender det som et kommandolinjeargument: git-baserte installasjoner autentiserer gjennom git credential-helperen din, og API-nedlastinger sender tokenet i en `Authorization`-header. Ingen av delene er synlige i prosesslister. - For å tilbakekalle tilgang, kjør `archgate login logout`. ### Selvoppdateringsintegritet diff --git a/docs/src/content/docs/nb/guides/vscode-plugin.mdx b/docs/src/content/docs/nb/guides/vscode-plugin.mdx index 07652a0d..e93d361e 100644 --- a/docs/src/content/docs/nb/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/nb/guides/vscode-plugin.mdx @@ -18,18 +18,18 @@ Agent-plugins krever **VS Code 1.110 (februar 2026-utgivelsen) eller nyere**. Ti ::: :::note[Betatilgang kreves] -VS Code-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere før du følger trinnene nedenfor. +VS Code-pluginet er for øyeblikket i beta. Kjør `archgate login` for å logge inn før du følger trinnene nedenfor. ::: -### 1. Logg inn med GitHub +### 1. Logg inn på Archgate-plattformen -Autentiser med GitHub-kontoen din for å få et plugin-token: +Logg inn for å få tilgang til pluginene: ```bash archgate login ``` -Dette starter en GitHub Device Flow. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, gis tokenene til den git credential-helperen du har konfigurert via `git credential approve`; en helper som bruker operativsystemet holder dem krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. ### 2. Initialiser prosjektet ditt med pluginet @@ -88,7 +88,7 @@ Markedsplassinnstillingen på brukernivå (lagt til i `settings.json`): } ``` -URL-en inneholder ingen legitimasjon. Git autentiserer mot `plugins.archgate.dev` med tokenet `archgate login` lagret i operativsystemets legitimasjonsbehandler. +URL-en inneholder ingen legitimasjon. `archgate credential` er credential-helperen; `archgate login` registrerer den for `plugins.archgate.dev`. Git spør den ved hver forespørsel og får et gyldig tilgangstoken, fornyet bare når det mellomlagrede har utløpt. ### Manuelt oppsett @@ -99,12 +99,17 @@ Hvis du foretrekker at CLI-en ikke endrer brukerinnstillingene dine, kan du sett **Utvidelse:** Last ned og installer `.vsix`-filen direkte: ```bash -curl -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix -code --install-extension archgate.vsix -rm archgate.vsix +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | + archgate credential get | + sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode && + code --install-extension archgate.vsix +rm -f archgate.vsix ``` -Erstatt `` med plugin-tokenet `archgate login` lagret for `plugins.archgate.dev` i operativsystemets legitimasjonsbehandler. Du kan lese det tilbake med `git credential fill` (`printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | git credential fill`). +`archgate credential get` skriver ut et git credential-svar, så `password=`-linjen formes om til et `curl --config`-direktiv og sendes inn via pipe. Tokenet havner aldri på kommandolinjen, der det ville vært synlig i prosesslisten og i shell-historikken. `--fail` gjør at curl avslutter med feilkode ved 401 i stedet for å skrive feilmeldingen over `archgate.vsix`, og `&&` hindrer `code` i å installere noe curl ikke lastet ferdig; den avsluttende `rm -f` rydder opp uansett. + +Bruk heller marketplace-oppsettet over når du kan: da spør git helperen ved hver forespørsel, og du håndterer ikke noe token manuelt. ## Hva pluginet tilbyr diff --git a/docs/src/content/docs/nb/index.mdx b/docs/src/content/docs/nb/index.mdx index e14914d5..c00ad7b8 100644 --- a/docs/src/content/docs/nb/index.mdx +++ b/docs/src/content/docs/nb/index.mdx @@ -82,7 +82,7 @@ Archgate CLI fungerer frittstående, men **editorplugins** låser opp en fullste :::tip[Beta-tilgang] -Editorplugins er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere, deretter `archgate init --install-plugin` for å sette opp pluginen. +Editorplugins er for øyeblikket i beta. Kjør `archgate login` for å logge inn, deretter `archgate init --install-plugin` for å sette opp pluginen. ::: diff --git a/docs/src/content/docs/nb/reference/cli/credential.mdx b/docs/src/content/docs/nb/reference/cli/credential.mdx new file mode 100644 index 00000000..77b3d862 --- /dev/null +++ b/docs/src/content/docs/nb/reference/cli/credential.mdx @@ -0,0 +1,80 @@ +--- +title: archgate credential +description: "Git credential helper som leverer tilgangstokener for Archgates plugin-repositorier." +--- + +Leverer git et gyldig tilgangstoken for Archgates plugin-repositorier. Du kjører normalt ikke denne kommandoen selv -- git kaller den, og [`archgate login`](/reference/cli/login/) registrerer den for deg. + +```bash +archgate credential get +``` + +Plugin-repositorier klones over HTTPS fra `plugins.archgate.dev`, og git autentiserer disse forespørslene selv i stedet for å gå gjennom CLI-en. Tilgangstokener er kortlivede, så et token som skrives én gang til legitimasjonsbehandleren ville slutte å virke innen en time. Denne helperen tetter det hullet: git spør den om legitimasjon ved hver forespørsel, og den svarer med et mellomlagret tilgangstoken som først fornyes fra det lagrede refresh-tokenet når det har utløpt. + +Kommandoen snakker gits [credential helper-protokoll](https://git-scm.com/docs/git-credential) over standard inn og ut. Den svarer bare for `plugins.archgate.dev` og er taus for alle andre verter, slik at legitimasjon for de øvrige remotene dine fortsetter å bruke den helperen du allerede har konfigurert. + +## Underkommandoer + +| Underkommando | Beskrivelse | +| --------------------------- | ---------------------------------------------------------------------------- | +| `archgate credential get` | Skriver ut legitimasjon for et plugin-repositorium | +| `archgate credential store` | Tar imot legitimasjon fra git (no-op) | +| `archgate credential erase` | Forkaster det mellomlagrede tilgangstokenet når git melder at det ble avvist | + +`store` er bevisst en no-op: tokener kommer fra `archgate login`, så git har ingenting å lære CLI-en. + +`erase` forkaster bare det mellomlagrede tilgangstokenet og beholder økten. Git sletter legitimasjon hver gang den blir avvist, og én enkelt 401 — et tilgangstoken som så vidt passerte fornyelsesvinduet, eller en forbigående feil fra plugins-verten — skal ikke koste deg en full innlogging. Neste forespørsel fornyer fra refresh-tokenet. + +## Konfigurasjon + +`archgate login` skriver følgende til den globale git-konfigurasjonen din: + +```ini +[credential "https://plugins.archgate.dev"] + helper = + helper = !/home/deg/.archgate/bin/archgate credential +``` + +Den tomme første oppføringen nullstiller enhver helper som er arvet fra et bredere konfigurasjonsnivå, slik at `archgate` er den eneste helperen git spør for denne verten. Den kjørbare filen registreres med absolutt sti, så git som startes fra en editor eller en GUI-klient finner den uten skallets PATH. `archgate login logout` fjerner begge oppføringene. + +## Eksempler + +Se hva git får tilbake: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` +protocol=https +host=plugins.archgate.dev +username=dittnavn +password=eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCJ9... +``` + +Når du er logget ut, skriver kommandoen ingenting og avslutter uten feil, noe som forteller git at den skal gå videre til de andre helperne sine: + +```bash +archgate login logout +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` + +``` + +## Feilsøking + +### `git clone` spør etter brukernavn og passord + +Helperen er ikke registrert. Kjør `archgate login` på nytt, eller sjekk konfigurasjonen direkte: + +```bash +git config --get-all credential.https://plugins.archgate.dev.helper +``` + +Utdataene skal vise en tom linje etterfulgt av `!`, den absolutte stien til den kjørbare archgate-filen og `credential`. Hvis ikke, klarte ikke CLI-en å skrive den globale git-konfigurasjonen din -- bekreft at `git config --global --list` virker. + +### Helperen er registrert, men legitimasjonen avvises fortsatt + +Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, returnerer helperen ingen legitimasjon og melder fra om feilen. Den leser bare tokensettet `archgate login` lagret; et token utstedt før denne flyten fantes gis aldri til git. Kjør `archgate login` på nytt. diff --git a/docs/src/content/docs/nb/reference/cli/index.mdx b/docs/src/content/docs/nb/reference/cli/index.mdx index 57740095..31141420 100644 --- a/docs/src/content/docs/nb/reference/cli/index.mdx +++ b/docs/src/content/docs/nb/reference/cli/index.mdx @@ -1,6 +1,6 @@ --- title: CLI-kommandoer -description: "Komplett referanse for alle Archgate CLI-kommandoer: init, check, adr, login, plugin, review-context, session-context, upgrade, doctor, clean og telemetry." +description: "Komplett referanse for alle Archgate CLI-kommandoer: init, check, adr, login, credential, plugin, review-context, session-context, upgrade, doctor, clean og telemetry." --- ## Globale valg @@ -23,7 +23,8 @@ archgate --log-level debug check | Kommando | Beskrivelse | | ------------------------------------------------ | --------------------------------------------------------------- | -| [`archgate login`](./login/) | Autentiser med GitHub | +| [`archgate login`](./login/) | Logg inn på Archgate-plattformen | +| [`archgate credential`](./credential/) | Git credential-helper for archgate-plugin-repositorier | | [`archgate init`](./init/) | Sett opp linting og regelhåndhevelse | | [`archgate plugin`](./plugin/) | Administrer editor-plugins | | [`archgate check`](./check/) | Kjør ADR-samsvarskontroller | diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index 03635b08..7b96d751 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -1,22 +1,26 @@ --- title: archgate login -description: "Autentiser med GitHub for å få tilgang til Archgate editor-plugins." +description: "Logg inn på Archgate-plattformen." --- -Autentiser med GitHub for å få tilgang til Archgate editor-plugins. Hvis du ikke er registrert ennå, håndterer CLI-en registrering automatisk -- den ber om e-postadressen din, foretrukket editor (Claude Code, Cursor, VS Code, GitHub Copilot eller opencode) og bruksområde, og registrerer deg før innloggingen fullføres. +Logg inn på Archgate-plattformen. Innloggingen er det som gir tilgang til editor-pluginene. ```bash archgate login ``` -Starter en GitHub Device Flow (OAuth). CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og autoriser Archgate GitHub OAuth-appen. Når autorisasjonen er fullført, veksler CLI-en GitHub-identiteten din mot et Archgate plugin-token og lagrer det sikkert i operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) via `git credential approve`. Ingen legitimasjon skrives til disk som klartekstfiler. +Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgate-plattformen. CLI-en åpner nettleseren på en URL som allerede inneholder engangskoden, og kopierer koden til utklippstavlen i tilfelle siden spør etter den. Både URL-en og koden skrives også ut, så du kan fullføre på en annen maskin eller lime dem inn selv. -Hvis GitHub-kontoen din ikke er registrert ennå, ber CLI-en om e-postadressen din, foretrukket editor og bruksområde, og registrerer deg automatisk. +Ingen av delene er påkrevd. I CI, over SSH eller på en ren Linux-maskin uten skjermserver (WSL når Windows-skrivebordet) hopper CLI-en over begge og skriver dem bare ut. Når autorisasjonen er fullført, gir CLI-en tokenene til git via `git credential approve`, så hvor de havner avhenger av hvilken credential-helper du har konfigurert. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager, Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst. Sjekk din med `git config --global credential.helper`. + +Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, melder CLI-en at økten er utløpt, og `archgate login` gjenoppretter den. Et token utstedt før denne flyten fantes virker bare til du logger inn på plattformen; fra da av er det plattformøkten alene som gjelder. + +Innlogging registrerer også `archgate credential` som en git credential helper for `plugins.archgate.dev`, som er det som lar `git clone` av et plugin-repositorium autentisere uten å spørre. Se [`archgate credential`](/reference/cli/credential/). Legitimasjon kreves for å installere editor-plugins via `archgate init --install-plugin`. Selve CLI-en (check, init osv.) fungerer uten innlogging. :::tip[Plugin-beta] -Editor-plugins er for tiden i beta. Kjør `archgate login` for å registrere deg og autentisere. +Editor-plugins er for tiden i beta. Kjør `archgate login` for å logge inn. ::: ## Underkommandoer @@ -26,7 +30,7 @@ Editor-plugins er for tiden i beta. Kjør `archgate login` for å registrere deg | `archgate login` | Autentiser (hopper over hvis allerede innlogget) | | `archgate login status` | Vis gjeldende autentiseringsstatus | | `archgate login logout` | Fjern lagret legitimasjon | -| `archgate login refresh` | Autentiser på nytt og hent et nytt token | +| `archgate login refresh` | Logg inn på nytt og erstatt lagrede token | ## Eksempler @@ -37,29 +41,13 @@ archgate login ``` ``` -By signing up, you agree to the Archgate Terms of Service: +By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -info: Authenticating with GitHub... - -Open https://github.com/login/device in your browser -and enter the code: ABCD-1234 +Opened https://auth.archgate.dev/device?user_code=HZML-HXLB in your browser. +If it asks for a code, enter: HZML-HXLB (copied to your clipboard) Waiting for authorization... -info: GitHub user: yourname -info: Claiming archgate plugin token... - -Your GitHub account yourname is not yet registered. -Let's sign you up now. - -? Email address: you@example.com -? Which editor will you use with archgate? Claude Code -? How do you plan to use archgate? Enforcing ADRs in our monorepo -? I agree to be contacted by the Archgate team to provide feedback during the beta period. Yes - -info: Submitting signup request... -info: Claiming archgate plugin token... - info: Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` diff --git a/docs/src/content/docs/nb/reference/privacy-policy.mdx b/docs/src/content/docs/nb/reference/privacy-policy.mdx index cbc5bfad..993ea38d 100644 --- a/docs/src/content/docs/nb/reference/privacy-policy.mdx +++ b/docs/src/content/docs/nb/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data overføres via reverseproksier på `n.archgate.dev` (analyse) og `s.archgat ### Archgate Plugins Service -Når du registrerer deg via `archgate login`, samler **Plugins Service** (`plugins.archgate.dev`) inn personopplysninger: din **e-postadresse**, **GitHub-brukernavn**, **editorvalg** og en **beskrivelse av bruksområde**. Disse dataene brukes til å opprette kontoen din og sende en velkomst-e-post. Autentiseringstokener lagres som SHA-256-hasher på serverne våre; på din maskin oppbevares legitimasjonen i operativsystemets legitimasjonsbehandler (aldri som ren tekstfiler). +Når du logger inn via `archgate login`, autentiserer du med GitHub gjennom **Archgate-plattformen** (`auth.archgate.dev`), som mottar profilen GitHub deler med den: ditt **navn**, din **e-postadresse**, ditt **GitHub-brukernavn** og din **avatar**. Dette brukes til å identifisere kontoen din og sende en velkomst-e-post. CLI-en spør deg selv om ingenting, og leser bare brukernavnet, navnet eller e-postadressen din fra innloggingsresultatet for å merke økten. På din maskin gis tokenene til den credential-helperen du har konfigurert; med en helper som bruker operativsystemet holdes de krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. Ved å opprette en konto godtar du [tjenestevilkårene](https://archgate.dev/terms-of-service). Se den [fullstendige personvernerklæringen](https://archgate.dev/privacy-policy) for komplette detaljer. diff --git a/docs/src/content/docs/pt-br/getting-started/installation.mdx b/docs/src/content/docs/pt-br/getting-started/installation.mdx index 6ab5b237..7f888add 100644 --- a/docs/src/content/docs/pt-br/getting-started/installation.mdx +++ b/docs/src/content/docs/pt-br/getting-started/installation.mdx @@ -233,5 +233,5 @@ Se você preferir `npm install -g archgate` ao invés do plugin proto, precisa c Após a instalação, execute `archgate init` no seu projeto para configurar linting e guardrails. Veja o guia de [Início Rápido](/getting-started/quick-start/) para um passo a passo. :::tip[Plugins para editores (beta)] -Quer que seu agente de IA leia ADRs antes de programar e valide depois? Os plugins para editores do [Claude Code](/guides/claude-code-plugin/) e [Cursor](/guides/cursor-integration/) adicionam um fluxo completo de guardrails sobre a CLI. Execute `archgate login` para se cadastrar e começar. +Quer que seu agente de IA leia ADRs antes de programar e valide depois? Os plugins para editores do [Claude Code](/guides/claude-code-plugin/) e [Cursor](/guides/cursor-integration/) adicionam um fluxo completo de guardrails sobre a CLI. Execute `archgate login` para entrar e começar. ::: diff --git a/docs/src/content/docs/pt-br/getting-started/quick-start.mdx b/docs/src/content/docs/pt-br/getting-started/quick-start.mdx index 6ab05422..72ec3cd3 100644 --- a/docs/src/content/docs/pt-br/getting-started/quick-start.mdx +++ b/docs/src/content/docs/pt-br/getting-started/quick-start.mdx @@ -141,5 +141,5 @@ Agora que você tem uma configuração funcionando, aprofunde-se: - [Integração com Cursor](/guides/cursor-integration/): use o Archgate com o Cursor IDE para desenvolvimento assistido por IA. :::tip[Plugins para editores (beta)] -Quer agentes de IA que leiam automaticamente seus ADRs antes de programar? Execute `archgate login` para se cadastrar e autenticar, depois execute `archgate init --install-plugin` para configurar o plugin. +Quer agentes de IA que leiam automaticamente seus ADRs antes de programar? Execute `archgate login` para entrar, depois execute `archgate init --install-plugin` para configurar o plugin. ::: diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index 28036eda..b8a9a95f 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -31,18 +31,18 @@ O agente `archgate:developer` é definido como agente padrão via `.claude/setti ## Instalação :::note[Acesso beta necessário] -O plugin para Claude Code está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. +O plugin para Claude Code está atualmente em beta. Execute `archgate login` para entrar. ::: -### 1. Faça login com o GitHub +### 1. Entre na plataforma Archgate -Autentique-se com sua conta do GitHub para obter um token do plugin: +Entre para obter acesso aos plugins: ```bash archgate login ``` -Isso inicia um GitHub Device Flow. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, os tokens resultantes são entregues ao credential helper do git que você configurou via `git credential approve`; um helper apoiado no sistema operacional os mantém criptografados, enquanto o helper `store` embutido do git os grava em texto puro. ### 2. Inicialize seu projeto com o plugin @@ -67,7 +67,7 @@ Se o CLI `claude` estiver no seu PATH, o plugin é instalado automaticamente via 1. `claude plugin marketplace add https://plugins.archgate.dev/archgate.git` (registra o marketplace do Archgate) 2. `claude plugin install archgate@archgate` (instala o plugin) -A URL do marketplace não contém credenciais -- o git se autentica em `plugins.archgate.dev` usando o token que o `archgate login` armazena no gerenciador de credenciais do sistema operacional. +A URL do marketplace não contém credenciais -- o git se autentica em `plugins.archgate.dev` usando o token que o `archgate login` entregou ao seu credential helper do git. Se o CLI `claude` não for encontrado, o comando exibe os comandos manuais para você executar. diff --git a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx index 2be879d2..08c5849a 100644 --- a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx @@ -16,18 +16,18 @@ A instalação é declarativa: o CLI escreve a declaração do marketplace e do ## Instalação :::note[Acesso beta necessário] -O plugin para GitHub Copilot está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar antes de seguir os passos abaixo. +O plugin para GitHub Copilot está atualmente em beta. Execute `archgate login` para entrar antes de seguir os passos abaixo. ::: -### 1. Faça login com o GitHub +### 1. Entre na plataforma Archgate -Autentique-se com sua conta do GitHub para obter um token do plugin: +Entre para obter acesso aos plugins: ```bash archgate login ``` -Isso inicia um GitHub Device Flow. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, os tokens resultantes são entregues ao credential helper do git que você configurou via `git credential approve`; um helper apoiado no sistema operacional os mantém criptografados, enquanto o helper `store` embutido do git os grava em texto puro. ### 2. Inicialize seu projeto com o plugin diff --git a/docs/src/content/docs/pt-br/guides/cursor-integration.mdx b/docs/src/content/docs/pt-br/guides/cursor-integration.mdx index 4b31bd85..57d607d9 100644 --- a/docs/src/content/docs/pt-br/guides/cursor-integration.mdx +++ b/docs/src/content/docs/pt-br/guides/cursor-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor cursor ``` :::note[Acesso beta necessário] -O plugin para Cursor está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. +O plugin para Cursor está atualmente em beta. Execute `archgate login` para entrar. ::: Se você fez login via `archgate login`, o comando init também instala o plugin Archgate para o Cursor. O plugin fornece agentes, skills e hooks pré-configurados que dão ao agente de IA do Cursor um fluxo completo de guardrails. diff --git a/docs/src/content/docs/pt-br/guides/opencode-integration.mdx b/docs/src/content/docs/pt-br/guides/opencode-integration.mdx index 6b26f79e..09d84666 100644 --- a/docs/src/content/docs/pt-br/guides/opencode-integration.mdx +++ b/docs/src/content/docs/pt-br/guides/opencode-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor opencode ``` :::note[Acesso beta necessário] -O bundle de agentes do opencode está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. +O bundle de agentes do opencode está atualmente em beta. Execute `archgate login` para entrar. ::: Os agentes e skills do opencode **não são gravados na árvore do seu projeto** -- diferente do Cursor, que também instala seus agentes e skills no escopo do usuário, mas ainda assim grava seu hook `afterFileEdit` no `.cursor/hooks.json` do projeto. No opencode nada específico do editor chega ao repositório: tudo vive na sua máquina e fica disponível em todos os projetos que você abrir com o opencode. diff --git a/docs/src/content/docs/pt-br/guides/security.mdx b/docs/src/content/docs/pt-br/guides/security.mdx index bce1a72e..6764b261 100644 --- a/docs/src/content/docs/pt-br/guides/security.mdx +++ b/docs/src/content/docs/pt-br/guides/security.mdx @@ -138,9 +138,9 @@ Ao clonar ou fazer fork de um repositório que usa Archgate, o scanner de segura ### Credenciais -O comando `archgate login` armazena seu token de autenticação no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) via `git credential approve`. Nenhuma credencial é gravada em disco como arquivo de texto. O token é usado para instalação de plugins e nunca é enviado a terceiros além do serviço de plugins do Archgate. +O comando `archgate login` entrega seus tokens ao credential helper do git que você configurou, via `git credential approve`. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro, então confira o seu com `git config --global credential.helper`. O token é usado para instalação de plugins e nunca é enviado a terceiros além do serviço de plugins do Archgate. -- Comandos de instalação de plugins nunca embutem o token em uma URL nem o passam como argumento de linha de comando: instalações baseadas em git se autenticam através do gerenciador de credenciais do SO, e downloads via API enviam o token em um cabeçalho `Authorization`. Nenhum dos dois fica visível em listagens de processos. +- Comandos de instalação de plugins nunca embutem o token em uma URL nem o passam como argumento de linha de comando: instalações baseadas em git se autenticam através do seu credential helper do git, e downloads via API enviam o token em um cabeçalho `Authorization`. Nenhum dos dois fica visível em listagens de processos. - Para revogar acesso, rode `archgate login logout`. ### Integridade da atualização diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index 0ac9f0bd..bc479896 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -18,18 +18,18 @@ Plugins de agente requerem **VS Code 1.110 (lançamento de fevereiro de 2026) ou ::: :::note[Acesso beta necessário] -O plugin para VS Code está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar antes de seguir os passos abaixo. +O plugin para VS Code está atualmente em beta. Execute `archgate login` para entrar antes de seguir os passos abaixo. ::: -### 1. Faça login com o GitHub +### 1. Entre na plataforma Archgate -Autentique-se com sua conta do GitHub para obter um token do plugin: +Entre para obter acesso aos plugins: ```bash archgate login ``` -Isso inicia um GitHub Device Flow. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, os tokens resultantes são entregues ao credential helper do git que você configurou via `git credential approve`; um helper apoiado no sistema operacional os mantém criptografados, enquanto o helper `store` embutido do git os grava em texto puro. ### 2. Inicialize seu projeto com o plugin @@ -88,7 +88,7 @@ A configuração do marketplace no nível de usuário (adicionada ao seu `settin } ``` -A URL não contém credenciais. O git se autentica em `plugins.archgate.dev` usando o token que o `archgate login` armazena no gerenciador de credenciais do sistema operacional. +A URL não contém credenciais. O `archgate credential` é o credential helper; o `archgate login` o registra para `plugins.archgate.dev`. O git o consulta a cada requisição e recebe um token de acesso válido, renovado apenas quando o token em cache expira. ### Configuração manual @@ -99,12 +99,17 @@ Se preferir não deixar o CLI modificar suas configurações, você pode configu **Extensão:** Baixe e instale o arquivo `.vsix` diretamente: ```bash -curl -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix -code --install-extension archgate.vsix -rm archgate.vsix +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | + archgate credential get | + sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode && + code --install-extension archgate.vsix +rm -f archgate.vsix ``` -Substitua `` pelo token de plugin que o `archgate login` armazena para `plugins.archgate.dev` no gerenciador de credenciais do sistema operacional. Você pode lê-lo de volta com `git credential fill` (`printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | git credential fill`). +O `archgate credential get` emite uma resposta de credencial do git, então a linha `password=` é convertida em uma diretiva `curl --config` e enviada por pipe. O token nunca chega à linha de comando, onde ficaria visível na lista de processos e no histórico do shell. O `--fail` faz o curl sair com erro em um 401, em vez de gravar o corpo do erro por cima de `archgate.vsix`, e o `&&` impede que o `code` instale algo que o curl não terminou de baixar; o `rm -f` final limpa em qualquer caso. + +Prefira a configuração de marketplace acima quando possível: assim o git consulta o helper a cada requisição e nenhum token é manipulado à mão. ## O que o plugin oferece diff --git a/docs/src/content/docs/pt-br/index.mdx b/docs/src/content/docs/pt-br/index.mdx index c943b688..8a812bd3 100644 --- a/docs/src/content/docs/pt-br/index.mdx +++ b/docs/src/content/docs/pt-br/index.mdx @@ -83,7 +83,7 @@ A CLI do Archgate funciona de forma independente, mas os **plugins para editores :::tip[Acesso beta] -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. +Os plugins para editores estão atualmente em beta. Execute `archgate login` para entrar, depois `archgate init --install-plugin` para configurar o plugin. ::: diff --git a/docs/src/content/docs/pt-br/reference/cli/credential.mdx b/docs/src/content/docs/pt-br/reference/cli/credential.mdx new file mode 100644 index 00000000..1c63e3af --- /dev/null +++ b/docs/src/content/docs/pt-br/reference/cli/credential.mdx @@ -0,0 +1,80 @@ +--- +title: archgate credential +description: "Credential helper do git que fornece tokens de acesso para os repositórios de plugins do Archgate." +--- + +Fornece ao git um token de acesso válido para os repositórios de plugins do Archgate. Normalmente você não executa este comando -- o git o invoca, e o [`archgate login`](/reference/cli/login/) o registra para você. + +```bash +archgate credential get +``` + +Os repositórios de plugins são clonados via HTTPS a partir de `plugins.archgate.dev`, e o próprio git autentica essas requisições em vez de passar pela CLI. Os tokens de acesso têm vida curta, então um token gravado uma única vez no gerenciador de credenciais deixaria de funcionar em menos de uma hora. Este helper resolve isso: o git pede credenciais a cada requisição, e ele responde com um token de acesso em cache, renovando-o a partir do refresh token armazenado somente depois que expira. + +O comando fala o [protocolo de credential helper do git](https://git-scm.com/docs/git-credential) via entrada e saída padrão. Ele responde apenas para `plugins.archgate.dev` e permanece silencioso para qualquer outro host, então as credenciais dos seus demais remotes continuam usando o helper que você já configurou. + +## Subcomandos + +| Subcomando | Descrição | +| --------------------------- | ------------------------------------------------------------------------- | +| `archgate credential get` | Exibe as credenciais de um repositório de plugin | +| `archgate credential store` | Aceita uma credencial do git (no-op) | +| `archgate credential erase` | Descarta o token de acesso em cache quando o git o reporta como rejeitado | + +`store` é intencionalmente um no-op: os tokens vêm do `archgate login`, então não há nada que o git possa ensinar à CLI. + +O `erase` descarta apenas o token de acesso em cache e mantém sua sessão. O git apaga credenciais sempre que elas são rejeitadas, e um único 401 — um token de acesso que passou da janela de renovação, ou um erro transitório do host de plugins — não deve custar um novo login completo. A próxima requisição renova a partir do refresh token. + +## Configuração + +O `archgate login` grava o seguinte na sua configuração global do git: + +```ini +[credential "https://plugins.archgate.dev"] + helper = + helper = !/home/voce/.archgate/bin/archgate credential +``` + +A primeira entrada vazia redefine qualquer helper herdado de um escopo de configuração mais amplo, de modo que o `archgate` seja o único helper consultado para este host. O executável é registrado pelo caminho absoluto, então o git iniciado por um editor ou cliente gráfico o encontra sem o PATH do seu shell. O `archgate login logout` remove as duas entradas. + +## Exemplos + +Inspecione o que o git recebe: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` +protocol=https +host=plugins.archgate.dev +username=seunome +password=eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCJ9... +``` + +Quando você não está autenticado, o comando não exibe nada e encerra com sucesso, o que faz o git seguir para os outros helpers: + +```bash +archgate login logout +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` + +``` + +## Solução de problemas + +### O `git clone` pede usuário e senha + +O helper não está registrado. Execute `archgate login` novamente ou verifique a configuração diretamente: + +```bash +git config --get-all credential.https://plugins.archgate.dev.helper +``` + +A saída deve listar uma linha vazia seguida de `!`, o caminho absoluto do executável archgate e `credential`. Se não listar, a CLI não conseguiu gravar sua configuração global do git -- confirme que `git config --global --list` funciona. + +### O helper está registrado, mas as credenciais continuam sendo rejeitadas + +Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o helper não retorna credenciais e informa o erro. Ele lê apenas o conjunto de tokens armazenado pelo `archgate login`; um token emitido antes deste fluxo existir nunca é entregue ao git. Execute `archgate login` novamente. diff --git a/docs/src/content/docs/pt-br/reference/cli/index.mdx b/docs/src/content/docs/pt-br/reference/cli/index.mdx index 98e67dac..48ed91ff 100644 --- a/docs/src/content/docs/pt-br/reference/cli/index.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/index.mdx @@ -1,6 +1,6 @@ --- title: Comandos da CLI -description: "Referência completa de todos os comandos da CLI do Archgate: init, check, adr, login, plugin, review-context, session-context, upgrade, doctor, clean e telemetria." +description: "Referência completa de todos os comandos da CLI do Archgate: init, check, adr, login, credential, plugin, review-context, session-context, upgrade, doctor, clean e telemetria." --- ## Opções globais @@ -21,16 +21,17 @@ archgate --log-level debug check ## Comandos -| Comando | Descrição | -| ------------------------------------------------ | -------------------------------------------------------- | -| [`archgate login`](./login/) | Autenticar com o GitHub | -| [`archgate init`](./init/) | Configurar linting e aplicação de regras | -| [`archgate plugin`](./plugin/) | Gerenciar plugins de editor | -| [`archgate check`](./check/) | Executar verificações de conformidade com ADRs | -| [`archgate adr`](./adr/) | Criar, listar, exibir e atualizar ADRs | -| [`archgate review-context`](./review-context/) | Pré-computar contexto de revisão para CI/integrações | -| [`archgate session-context`](./session-context/) | Ler transcrições de sessão de editores de IA | -| [`archgate upgrade`](./upgrade/) | Atualizar o Archgate para a versão mais recente | -| [`archgate doctor`](./doctor/) | Verificar ambiente do sistema e integrações com editores | -| [`archgate clean`](./clean/) | Remover o diretório de cache da CLI | -| [`archgate telemetry`](./telemetry/) | Gerenciar a coleta anônima de dados de uso | +| Comando | Descrição | +| ------------------------------------------------ | -------------------------------------------------------------- | +| [`archgate login`](./login/) | Entre na plataforma Archgate | +| [`archgate credential`](./credential/) | Credential helper do git para repositórios de plugins archgate | +| [`archgate init`](./init/) | Configurar linting e aplicação de regras | +| [`archgate plugin`](./plugin/) | Gerenciar plugins de editor | +| [`archgate check`](./check/) | Executar verificações de conformidade com ADRs | +| [`archgate adr`](./adr/) | Criar, listar, exibir e atualizar ADRs | +| [`archgate review-context`](./review-context/) | Pré-computar contexto de revisão para CI/integrações | +| [`archgate session-context`](./session-context/) | Ler transcrições de sessão de editores de IA | +| [`archgate upgrade`](./upgrade/) | Atualizar o Archgate para a versão mais recente | +| [`archgate doctor`](./doctor/) | Verificar ambiente do sistema e integrações com editores | +| [`archgate clean`](./clean/) | Remover o diretório de cache da CLI | +| [`archgate telemetry`](./telemetry/) | Gerenciar a coleta anônima de dados de uso | diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index 3a7c067e..1a76dd8d 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -1,32 +1,36 @@ --- title: archgate login -description: "Autentique-se com o GitHub para acessar os plugins de editor do Archgate." +description: "Entre na plataforma Archgate." --- -Autentique-se com o GitHub para acessar os plugins de editor do Archgate. Se você ainda não possui cadastro, a CLI cuida do registro automaticamente -- solicita seu email, preferência de editor (Claude Code, Cursor, VS Code, GitHub Copilot ou opencode) e caso de uso, e então realiza o cadastro antes de concluir o login. +Entre na plataforma Archgate. É o login que concede acesso aos plugins de editor. ```bash archgate login ``` -Inicia um GitHub Device Flow (OAuth). A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e autorize o Archgate GitHub OAuth App. Após a autorização, a CLI troca sua identidade do GitHub por um token de plugin do Archgate e armazena-o de forma segura no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) via `git credential approve`. Nenhuma credencial é gravada em disco como arquivo de texto. +Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) na plataforma Archgate. A CLI abre seu navegador em uma URL que já contém o código de uso único e copia esse código para a área de transferência, caso a página o solicite. A URL e o código também são exibidos, então você pode concluir em outra máquina ou colá-los manualmente. -Se sua conta do GitHub ainda não estiver cadastrada, a CLI solicita seu email, editor preferido e caso de uso, e realiza o cadastro automaticamente. +Nenhuma das duas etapas é obrigatória. Em CI, via SSH ou em uma máquina Linux nativa sem servidor gráfico (o WSL alcança a área de trabalho do Windows), a CLI pula ambas e apenas os exibe. Após a autorização, a CLI entrega os tokens ao git via `git credential approve`, então onde eles ficam depende do credential helper que você tem configurado. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager, libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro. Verifique o seu com `git config --global credential.helper`. + +Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI informa que a sessão expirou, e o `archgate login` a restabelece. Um token emitido antes deste fluxo existir continua funcionando apenas até você entrar na plataforma; a partir daí, somente a sessão da plataforma decide. + +Entrar também registra `archgate credential` como um credential helper do git para `plugins.archgate.dev`, que é o que permite ao `git clone` de um repositório de plugin autenticar sem solicitar credenciais. Veja [`archgate credential`](/reference/cli/credential/). As credenciais são necessárias para instalar plugins de editor via `archgate init --install-plugin`. A CLI em si (check, init, etc.) funciona sem login. :::tip[Plugin em beta] -Os plugins de editor estão atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. +Os plugins de editor estão atualmente em beta. Execute `archgate login` para entrar. ::: ## Subcomandos -| Subcomando | Descrição | -| ------------------------ | -------------------------------------- | -| `archgate login` | Autenticar (pula se já estiver logado) | -| `archgate login status` | Mostrar o status atual de autenticação | -| `archgate login logout` | Remover credenciais armazenadas | -| `archgate login refresh` | Reautenticar e solicitar um novo token | +| Subcomando | Descrição | +| ------------------------ | ---------------------------------------- | +| `archgate login` | Autenticar (pula se já estiver logado) | +| `archgate login status` | Mostrar o status atual de autenticação | +| `archgate login logout` | Remover credenciais armazenadas | +| `archgate login refresh` | Entrar novamente, substituindo os tokens | ## Exemplos @@ -37,29 +41,13 @@ archgate login ``` ``` -By signing up, you agree to the Archgate Terms of Service: +By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -info: Authenticating with GitHub... - -Open https://github.com/login/device in your browser -and enter the code: ABCD-1234 +Opened https://auth.archgate.dev/device?user_code=HZML-HXLB in your browser. +If it asks for a code, enter: HZML-HXLB (copied to your clipboard) Waiting for authorization... -info: GitHub user: yourname -info: Claiming archgate plugin token... - -Your GitHub account yourname is not yet registered. -Let's sign you up now. - -? Email address: you@example.com -? Which editor will you use with archgate? Claude Code -? How do you plan to use archgate? Enforcing ADRs in our monorepo -? I agree to be contacted by the Archgate team to provide feedback during the beta period. Yes - -info: Submitting signup request... -info: Claiming archgate plugin token... - info: Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` diff --git a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx index fdbbe1c0..78ea35c0 100644 --- a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx +++ b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx @@ -17,7 +17,7 @@ O Archgate coleta **análises de uso anônimas** (via PostHog) e **relatórios d ### Serviço de Plugins do Archgate -Quando você se cadastra via `archgate login`, o **Serviço de Plugins** (`plugins.archgate.dev`) coleta informações pessoais: seu **endereço de email**, **nome de usuário GitHub**, **escolha do editor** e uma **descrição do caso de uso**. Esses dados são usados para provisionar sua conta e enviar um email de boas-vindas. Os tokens de autenticação são armazenados como hashes SHA-256 em nossos servidores; na sua máquina, as credenciais são mantidas no gerenciador de credenciais do seu sistema operacional (nunca como arquivos de texto simples). Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. +Quando você entra via `archgate login`, você se autentica com o GitHub através da **plataforma Archgate** (`auth.archgate.dev`), que recebe o perfil que o GitHub compartilha com ele: seu **nome**, **endereço de email**, **nome de usuário GitHub** e **avatar**. Isso é usado para identificar sua conta e enviar um email de boas-vindas. A própria CLI não pede nada a você, e lê apenas seu nome de usuário, nome ou email do resultado do login para identificar a sessão. Na sua máquina, os tokens resultantes são entregues ao credential helper do git que você configurou; com um helper apoiado no sistema operacional eles ficam criptografados, enquanto o helper `store` embutido do git os grava em texto puro. Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. ### Como desativar diff --git a/docs/src/content/docs/reference/cli/credential.mdx b/docs/src/content/docs/reference/cli/credential.mdx new file mode 100644 index 00000000..78101458 --- /dev/null +++ b/docs/src/content/docs/reference/cli/credential.mdx @@ -0,0 +1,80 @@ +--- +title: archgate credential +description: "Git credential helper that supplies access tokens for Archgate plugin repositories." +--- + +Supplies git with a valid access token for Archgate plugin repositories. You do not normally run this command yourself -- git invokes it, and [`archgate login`](/reference/cli/login/) registers it for you. + +```bash +archgate credential get +``` + +Plugin repositories are cloned over HTTPS from `plugins.archgate.dev`, and git authenticates those requests itself rather than going through the CLI. Access tokens are short-lived, so a token written once into your credential manager would stop working within the hour. This helper closes that gap: git asks it for credentials on each request, and it answers with a cached access token, renewing it from the stored refresh token only once it has lapsed. + +The command speaks git's [credential helper protocol](https://git-scm.com/docs/git-credential) on standard input and output. It answers only for `plugins.archgate.dev` and stays silent for every other host, so credentials for the rest of your remotes keep using whatever helper you already configured. + +## Subcommands + +| Subcommand | Description | +| --------------------------- | --------------------------------------------------------- | +| `archgate credential get` | Print credentials for a plugin repository | +| `archgate credential store` | Accept a credential from git (no-op) | +| `archgate credential erase` | Drop the cached access token when git reports it rejected | + +`store` is deliberately a no-op: tokens come from `archgate login`, so there is nothing for git to teach the CLI. + +`erase` drops only the cached access token and keeps your session. Git erases whenever the credentials it was given are rejected, and a single 401 — an access token that slipped past the renewal window, or a transient error from the plugins host — should not cost you a full sign-in. The next request renews from the refresh token. + +## Configuration + +`archgate login` writes the following to your global git config: + +```ini +[credential "https://plugins.archgate.dev"] + helper = + helper = !/home/you/.archgate/bin/archgate credential +``` + +The empty first entry resets any helper inherited from a broader config scope, so `archgate` is the only helper consulted for this host. The executable is recorded by absolute path, so git started by an editor or a GUI client finds it without your shell's PATH. `archgate login logout` removes both entries. + +## Examples + +Inspect what git receives: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` +protocol=https +host=plugins.archgate.dev +username=yourname +password=eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCJ9... +``` + +When you are signed out, the command prints nothing and exits successfully, which tells git to fall through to its other helpers: + +```bash +archgate login logout +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` + +``` + +## Troubleshooting + +### `git clone` prompts for a username and password + +The helper is not registered. Run `archgate login` again, or check the configuration directly: + +```bash +git config --get-all credential.https://plugins.archgate.dev.helper +``` + +The output should list an empty line followed by `!`, the absolute path of the archgate executable, and `credential`. If it does not, the CLI could not write your global git config -- confirm `git config --global --list` works. + +### The helper is registered but credentials are still rejected + +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper returns no credentials and reports the error rather than staying silent. It reads only the token set stored by `archgate login`; a token issued before this flow existed is never served to git. Run `archgate login` again. diff --git a/docs/src/content/docs/reference/cli/index.mdx b/docs/src/content/docs/reference/cli/index.mdx index b6a37e88..341a08de 100644 --- a/docs/src/content/docs/reference/cli/index.mdx +++ b/docs/src/content/docs/reference/cli/index.mdx @@ -1,6 +1,6 @@ --- title: CLI Commands -description: "Complete reference for all Archgate CLI commands: init, check, adr, login, plugin, review-context, session-context, upgrade, doctor, clean, and telemetry." +description: "Complete reference for all Archgate CLI commands: init, check, adr, login, credential, plugin, review-context, session-context, upgrade, doctor, clean, and telemetry." --- ## Global options @@ -21,16 +21,17 @@ archgate --log-level debug check ## Commands -| Command | Description | -| ------------------------------------------------ | ----------------------------------------------------- | -| [`archgate login`](./login/) | Authenticate with GitHub | -| [`archgate init`](./init/) | Set up linting and rules enforcement | -| [`archgate plugin`](./plugin/) | Manage editor plugins | -| [`archgate check`](./check/) | Run ADR compliance checks | -| [`archgate adr`](./adr/) | Create, list, show, and update ADRs | -| [`archgate review-context`](./review-context/) | Pre-compute review context for CI/editor integrations | -| [`archgate session-context`](./session-context/) | Read AI editor session transcripts | -| [`archgate upgrade`](./upgrade/) | Upgrade Archgate to the latest version | -| [`archgate doctor`](./doctor/) | Check system environment and editor integrations | -| [`archgate clean`](./clean/) | Remove the CLI cache directory | -| [`archgate telemetry`](./telemetry/) | Manage anonymous usage data collection | +| Command | Description | +| ------------------------------------------------ | ------------------------------------------------------ | +| [`archgate login`](./login/) | Log in to the Archgate platform | +| [`archgate credential`](./credential/) | Git credential helper for archgate plugin repositories | +| [`archgate init`](./init/) | Set up linting and rules enforcement | +| [`archgate plugin`](./plugin/) | Manage editor plugins | +| [`archgate check`](./check/) | Run ADR compliance checks | +| [`archgate adr`](./adr/) | Create, list, show, and update ADRs | +| [`archgate review-context`](./review-context/) | Pre-compute review context for CI/editor integrations | +| [`archgate session-context`](./session-context/) | Read AI editor session transcripts | +| [`archgate upgrade`](./upgrade/) | Upgrade Archgate to the latest version | +| [`archgate doctor`](./doctor/) | Check system environment and editor integrations | +| [`archgate clean`](./clean/) | Remove the CLI cache directory | +| [`archgate telemetry`](./telemetry/) | Manage anonymous usage data collection | diff --git a/docs/src/content/docs/reference/cli/login.mdx b/docs/src/content/docs/reference/cli/login.mdx index a0ecbbfe..1ea2b62f 100644 --- a/docs/src/content/docs/reference/cli/login.mdx +++ b/docs/src/content/docs/reference/cli/login.mdx @@ -1,22 +1,26 @@ --- title: archgate login -description: "Authenticate with GitHub to access Archgate editor plugins." +description: "Log in to the Archgate platform." --- -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, Cursor, VS Code, GitHub Copilot, or opencode), and use case, then registers you before completing the login. +Log in to the Archgate platform. Logging in is what grants access to the editor plugins. ```bash 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 it securely in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI opens your browser at a URL that already carries the one-time code, and copies that code to your clipboard in case the page asks for it. Both the URL and the code are printed as well, so you can finish on another machine or paste them yourself. -If your GitHub account is not yet registered, the CLI prompts for your email, preferred editor, and use case, then signs you up automatically. +Neither step is required. In CI, over SSH, or on a native Linux host with no display server (WSL reaches the Windows desktop) the CLI skips both and just prints them. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. + +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI reports that the session has expired, and `archgate login` re-establishes it. A token issued before this flow existed keeps working only until you sign in to the platform; from then on the platform session alone decides. + +Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). Credentials are required to install editor plugins via `archgate init --install-plugin`. The CLI itself (check, init, etc.) works without login. :::tip[Plugin beta] -Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate. +Editor plugins are currently in beta. Run `archgate login` to sign in. ::: ## Subcommands @@ -26,7 +30,7 @@ Editor plugins are currently in beta. Run `archgate login` to sign up and authen | `archgate login` | Authenticate (skips if already logged in) | | `archgate login status` | Show current authentication status | | `archgate login logout` | Remove stored credentials | -| `archgate login refresh` | Re-authenticate and claim a new token | +| `archgate login refresh` | Sign in again, replacing stored tokens | ## Examples @@ -37,29 +41,13 @@ archgate login ``` ``` -By signing up, you agree to the Archgate Terms of Service: +By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -info: Authenticating with GitHub... - -Open https://github.com/login/device in your browser -and enter the code: ABCD-1234 +Opened https://auth.archgate.dev/device?user_code=HZML-HXLB in your browser. +If it asks for a code, enter: HZML-HXLB (copied to your clipboard) Waiting for authorization... -info: GitHub user: yourname -info: Claiming archgate plugin token... - -Your GitHub account yourname is not yet registered. -Let's sign you up now. - -? Email address: you@example.com -? Which editor will you use with archgate? Claude Code -? How do you plan to use archgate? Enforcing ADRs in our monorepo -? I agree to be contacted by the Archgate team to provide feedback during the beta period. Yes - -info: Submitting signup request... -info: Claiming archgate plugin token... - info: Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` diff --git a/docs/src/content/docs/reference/privacy-policy.mdx b/docs/src/content/docs/reference/privacy-policy.mdx index 2c8a1658..1ff1cc0a 100644 --- a/docs/src/content/docs/reference/privacy-policy.mdx +++ b/docs/src/content/docs/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign up via `archgate login`, the **Plugins Service** (`plugins.archgate.dev`) collects personal information: your **email address**, **GitHub username**, **editor choice**, and a **use case description**. This data is used to provision your account and send a welcome email. Authentication tokens are stored as SHA-256 hashes on our servers; on your machine, credentials are kept in your OS credential manager (never as plain-text files). +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate platform** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing, and reads only your username, name or email from the sign-in result to label the session. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/src/cli.ts b/src/cli.ts index 52af809d..78fb90a4 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,10 @@ import packageJson from "../package.json"; import { registerAdrCommand } from "./commands/adr/index"; import { registerCheckCommand } from "./commands/check"; import { registerCleanCommand } from "./commands/clean"; +import { + isCredentialHelperInvocation, + registerCredentialCommand, +} from "./commands/credential"; import { registerDoctorCommand } from "./commands/doctor"; import { registerInitCommand } from "./commands/init"; import { registerLoginCommand } from "./commands/login"; @@ -85,14 +89,21 @@ createPathIfNotExists(paths.cacheFolder); void cleanupStaleBinary(); async function main() { - await installGit(); + // Started by git as its credential helper, stdout is the protocol channel + // and git waits for this process to exit: startup skips everything that + // could write there or wait on the network — the git install check, + // telemetry, and the update notice. + const helper = isCredentialHelperInvocation(process.argv); + if (!helper) await installGit(); // Start error tracking and telemetry initialization concurrently without // awaiting: the preAction hook awaits this promise right before the first // telemetry event fires, so `repo_id` is always present on `command_executed` // events, while paths that never reach preAction (--help, --version) leave // the ~150ms of SDK parse and repo_id resolution off the critical path. - const telemetryReady = Promise.all([initSentry(), initTelemetry()]); + const telemetryReady: Promise = helper + ? Promise.resolve() + : Promise.all([initSentry(), initTelemetry()]); const logLevelOption = new Option("--log-level ", "Set log verbosity") .choices(["error", "warn", "info", "debug"] as const) @@ -156,13 +167,16 @@ async function main() { registerCleanCommand(program); registerDoctorCommand(program); registerTelemetryCommand(program); + registerCredentialCommand(program); - const updateCheckPromise = maybeCheckForUpdates(packageJson.version); + const updateCheckPromise = helper + ? Promise.resolve(null) + : maybeCheckForUpdates(packageJson.version); await program.parseAsync(process.argv); const notice = await updateCheckPromise; if (notice !== null && notice !== "") console.log(notice); - await Promise.all([flushTelemetry(), flushSentry()]); + if (!helper) await Promise.all([flushTelemetry(), flushSentry()]); // Belt-and-braces: force exit so any stray handle left by a third-party // SDK (posthog-node, @sentry/node-core, etc.) can't linger and make the diff --git a/src/commands/credential.ts b/src/commands/credential.ts new file mode 100644 index 00000000..2cd3754f --- /dev/null +++ b/src/commands/credential.ts @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import type { Command } from "@commander-js/extra-typings"; + +import { + invalidateAccessToken, + resolveAccessToken, +} from "../helpers/credential-store"; +import { handleCommandError } from "../helpers/exit"; +import { + type CredentialRequest, + formatCredentialResponse, + parseCredentialRequest, +} from "../helpers/git-credential-protocol"; +import { logDebug } from "../helpers/log"; +import { PLUGINS_HOST } from "../helpers/plugin-install"; + +/** + * Read the credential request git writes to stdin. + * + * @returns The parsed request, or an empty one when stdin is closed. + */ +async function readRequest(): Promise { + return parseCredentialRequest(await Bun.stdin.text()); +} + +/** + * True for a request this helper answers. + * + * Git uses the requested protocol for the exchange itself, so an `http://` + * request would carry the token in cleartext; only `https` to the plugins + * host qualifies, and the response cannot upgrade the protocol. + */ +function isPluginsRequest(request: CredentialRequest): boolean { + return request.protocol === "https" && request.host === PLUGINS_HOST; +} + +/** + * True when this process was started by git as its credential helper. + * + * Startup must then stay silent and quick: stdout is the protocol channel, + * and git is blocked waiting for it. Any argument list naming `credential` + * qualifies; skipping the git install check for a false positive is harmless. + * + * @param argv - `process.argv` or an equivalent. + */ +export function isCredentialHelperInvocation(argv: readonly string[]): boolean { + return argv.slice(2).includes("credential"); +} + +export function registerCredentialCommand(program: Command) { + const credential = program + .command("credential") + .description("Git credential helper for archgate plugin repositories"); + + credential + .command("get") + .description("Print credentials for a plugin repository") + .action(async () => { + try { + const request = await readRequest(); + if (!isPluginsRequest(request)) { + logDebug( + "Ignoring credential request for:", + `${request.protocol}://${request.host}` + ); + return; + } + + const credentials = await resolveAccessToken(); + if (!credentials) { + logDebug("No stored credentials; deferring to other helpers"); + return; + } + + process.stdout.write( + formatCredentialResponse({ + protocol: "https", + host: PLUGINS_HOST, + username: credentials.github_user, + password: credentials.token, + }) + ); + } catch (err) { + await handleCommandError(err); + } + }); + + credential + .command("store") + .description("Accept a credential from git (no-op)") + .action(async () => { + try { + // Tokens come from `archgate login`, so git has nothing to teach us. + await readRequest(); + } catch (err) { + await handleCommandError(err); + } + }); + + credential + .command("erase") + .description("Drop the cached access token when git reports it rejected") + .action(async () => { + try { + if (isPluginsRequest(await readRequest())) { + await invalidateAccessToken(); + } + } catch (err) { + await handleCommandError(err); + } + }); +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 5ccf9c57..c2500699 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -13,7 +13,6 @@ import { exitWith, handleCommandError } from "../helpers/exit"; import { EDITOR_LABELS, EDITOR_TARGETS, - SIGNUP_EDITORS, initProject, } from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; @@ -95,16 +94,14 @@ export function registerInitCommand(program: Command) { type: "confirm", name: "wantPlugin", message: - "Would you like to install the Archgate editor plugin? (requires GitHub login)", + "Would you like to install the Archgate editor plugin? (requires logging in to the Archgate platform)", default: true, }, ]) ); if (wantPlugin) { - const result = await runLoginFlow({ - editor: SIGNUP_EDITORS[editors[0]], - }); + const result = await runLoginFlow(); hasCredentials = result.ok; } } diff --git a/src/commands/login.ts b/src/commands/login.ts index 8ad7f381..a21f8fdb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -4,18 +4,48 @@ import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; -import { loadCredentials, clearCredentials } from "../helpers/credential-store"; +import { + clearCredentials, + loadCredentials, + loadTokenSet, +} from "../helpers/credential-store"; import { exitWith, handleCommandError } from "../helpers/exit"; -import { logError, logInfo } from "../helpers/log"; +import { + ensureGitCredentialHelper, + unregisterGitCredentialHelper, +} from "../helpers/git-credential-config"; +import { logInfo } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; import { findProjectRoot } from "../helpers/paths"; import { trackLoginResult } from "../helpers/telemetry"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; +import { UserError } from "../helpers/user-error"; + +/** Raised when a stored record survives a clear; logout and refresh share it. */ +function credentialsLeftBehind(): UserError { + return new UserError( + "Some credentials could not be removed from your git credential manager.", + "Check `git config --global credential.helper` and run `archgate login logout` again." + ); +} + +/** + * Raised when the helper entry survives an unregister; logout and refresh + * share it. + * + * @param prefix - What already happened before the helper removal failed. + */ +function helperLeftBehind(prefix: string): UserError { + return new UserError( + `${prefix}, but the git credential helper entry could not be removed.`, + "Remove it with `git config --global --unset-all credential.https://plugins.archgate.dev.helper`." + ); +} export function registerLoginCommand(program: Command) { const login = program .command("login") - .description("Authenticate with GitHub to access archgate plugins"); + .description("Log in to the Archgate platform"); login.action(async () => { try { @@ -23,32 +53,16 @@ export function registerLoginCommand(program: Command) { if (existing) { logInfo( `Already logged in as ${styleText("bold", existing.github_user)}.`, - "Run `archgate login refresh` to re-authenticate." + "Run `archgate login refresh` to sign in again." ); + // A platform session may have been stored while the helper write + // failed; a legacy token must not get the helper, which cannot serve it. + if (await loadTokenSet()) await ensureGitCredentialHelper(); return; } - - const result = await runLoginFlow(); - trackLoginResult({ subcommand: "login", success: result.ok }); - if (result.ok) { - printNextStep(); - } else { - await exitWith(1); - } + await signIn("login"); } catch (err) { - if (err instanceof Error && err.name === "ExitPromptError") throw err; - const failureReason = isTlsError(err) ? "tls" : "other"; - trackLoginResult({ - subcommand: "login", - success: false, - failure_reason: failureReason, - }); - if (isTlsError(err)) { - logError(tlsHintMessage()); - await exitWith(1); - return; - } - await handleCommandError(err); + await handleSignInError("login", err); } }); @@ -74,8 +88,16 @@ export function registerLoginCommand(program: Command) { .description("Remove stored credentials") .action(async () => { try { - await clearCredentials(); - trackLoginResult({ subcommand: "logout", success: true }); + // The helper goes first: while archgate answers for the plugins host, + // clearing cannot see a legacy token the OS store holds for it. + const unregistered = await unregisterGitCredentialHelper(); + const cleared = await clearCredentials(); + trackLoginResult({ + subcommand: "logout", + success: unregistered && cleared, + }); + if (!cleared) throw credentialsLeftBehind(); + if (!unregistered) throw helperLeftBehind("Credentials removed"); console.log("Logged out successfully."); } catch (err) { await handleCommandError(err); @@ -84,35 +106,61 @@ export function registerLoginCommand(program: Command) { login .command("refresh") - .description("Re-authenticate and claim a new token") + .description("Sign in again, replacing stored tokens") .action(async () => { try { - await clearCredentials(); - const result = await runLoginFlow(); - trackLoginResult({ subcommand: "refresh", success: result.ok }); - if (result.ok) { - printNextStep(); - } else { - await exitWith(1); + // While the helper still answers for the plugins host, clearing cannot + // see a legacy token behind it, so a failed unregister stops here. + if (!(await unregisterGitCredentialHelper())) { + throw helperLeftBehind("Nothing changed"); } + // A leftover record would keep answering for the old account, so a + // failed clear stops the refresh rather than signing in over it. + if (!(await clearCredentials())) throw credentialsLeftBehind(); + await signIn("refresh"); } catch (err) { - if (err instanceof Error && err.name === "ExitPromptError") throw err; - const failureReason = isTlsError(err) ? "tls" : "other"; - trackLoginResult({ - subcommand: "refresh", - success: false, - failure_reason: failureReason, - }); - if (isTlsError(err)) { - logError(tlsHintMessage()); - await exitWith(1); - return; - } - await handleCommandError(err); + await handleSignInError("refresh", err); } }); } +/** + * Run the sign-in flow and report its outcome. + * + * @param subcommand - Reported to telemetry with the outcome. + */ +async function signIn(subcommand: "login" | "refresh"): Promise { + const result = await runLoginFlow(); + trackLoginResult({ subcommand, success: result.ok }); + if (result.ok) { + printNextStep(); + } else { + await exitWith(1); + } +} + +/** + * Report a failed sign-in, with a dedicated hint for TLS interception. + * + * @param subcommand - Reported to telemetry with the failure. + * @param err - Whatever the action threw; prompt cancellations propagate. + */ +async function handleSignInError( + subcommand: "login" | "refresh", + err: unknown +): Promise { + if (err instanceof Error && err.name === "ExitPromptError") throw err; + const tls = isTlsError(err); + trackLoginResult({ + subcommand, + success: false, + failure_reason: tls ? "tls" : "other", + }); + // The hint replaces the raw error; as a UserError it keeps exit code 1 and + // the handler's expected-error classification. + await handleCommandError(tls ? new UserError(tlsHintMessage()) : err); +} + function printNextStep(): void { const projectRoot = findProjectRoot(); if (projectRoot !== null && projectRoot !== "") { diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts deleted file mode 100644 index 189afeff..00000000 --- a/src/helpers/auth.ts +++ /dev/null @@ -1,241 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -/** - * auth.ts — GitHub Device Flow authentication and archgate token management. - * - * Implements RFC 8628 (OAuth 2.0 Device Authorization Grant) for CLI login. - * Token storage is delegated to credential-store.ts which uses git's native - * credential helpers (macOS Keychain, Windows Credential Manager, libsecret). - */ - -import { z } from "zod"; - -import { logDebug } from "./log"; -import { SignupRequiredError, isSignupRequiredError } from "./signup"; -import { UserError } from "./user-error"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const PLUGINS_API = "https://plugins.archgate.dev"; -const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"; -const GITHUB_DEVICE_TOKEN_URL = "https://github.com/login/oauth/access_token"; - -/** - * GitHub OAuth App client ID for the archgate CLI (public client — no secret). - * Device flow apps are public clients; the client_id is not confidential. - */ -const GITHUB_CLIENT_ID = "Ov23liZUI9Aiv2ZrSAgn"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -const DeviceCodeResponseSchema = z.object({ - device_code: z.string(), - user_code: z.string(), - verification_uri: z.string(), - expires_in: z.number(), - interval: z.number(), -}); - -type DeviceCodeResponse = z.infer; - -const DeviceTokenResponseSchema = z.union([ - z.object({ - access_token: z.string(), - token_type: z.string(), - scope: z.string(), - }), - z.object({ error: z.string(), error_description: z.string().optional() }), -]); - -const GitHubUserSchema = z.object({ - login: z.string().optional(), - email: z.string().nullable().default(null), -}); - -const TokenResponseSchema = z.object({ token: z.string().optional() }); -const ErrorResponseSchema = z.object({ error: z.string().optional() }); - -// --------------------------------------------------------------------------- -// GitHub Device Flow -// --------------------------------------------------------------------------- - -/** - * Step 1: Request a device code from GitHub. - * The user will be shown the `user_code` and asked to visit `verification_uri`. - */ -export async function requestDeviceCode(): Promise { - logDebug("Requesting device code from:", GITHUB_DEVICE_CODE_URL); - const response = await fetch(GITHUB_DEVICE_CODE_URL, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: "read:user" }), - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - logDebug("Device code request failed, status:", response.status); - throw new UserError( - `GitHub device code request failed (HTTP ${response.status})` - ); - } - - const data = DeviceCodeResponseSchema.parse(await response.json()); - logDebug("Device code received, expires in:", data.expires_in, "seconds"); - return data; -} - -/** - * Step 2: Poll GitHub until the user authorizes, or the code expires. - * - * @param deviceCode - Device code from the step-1 authorization request. - * @param interval - Seconds to wait between polls, per RFC 8628. GitHub can - * widen this via a `slow_down` response. - * @param expiresIn - Lifetime of the device code in seconds, forming the - * polling deadline. - * @returns The GitHub access token. - * @throws {UserError} When GitHub rejects a poll, or the code expires or is - * denied before authorization completes. - */ -export async function pollForAccessToken( - deviceCode: string, - interval: number, - expiresIn: number -): Promise { - const deadline = Date.now() + expiresIn * 1000; - let pollInterval = interval; - logDebug( - "Starting device code polling, deadline:", - new Date(deadline).toISOString() - ); - - /* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */ - while (Date.now() < deadline) { - await Bun.sleep(pollInterval * 1000); - - const response = await fetch(GITHUB_DEVICE_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: GITHUB_CLIENT_ID, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - throw new UserError(`GitHub token poll failed (HTTP ${response.status})`); - } - - const data = DeviceTokenResponseSchema.parse(await response.json()); - - if ("access_token" in data) { - logDebug("Access token received"); - return data.access_token; - } - - if ("error" in data) { - if (data.error === "authorization_pending") { - logDebug("Authorization pending, retrying in", pollInterval, "seconds"); - continue; - } - if (data.error === "slow_down") { - pollInterval += 5; - logDebug("Slow down requested, new interval:", pollInterval, "seconds"); - continue; - } - // expired_token, access_denied, or other terminal error - throw new UserError( - data.error_description ?? `GitHub authorization failed: ${data.error}` - ); - } - } - /* oxlint-enable no-await-in-loop */ - - throw new UserError("Device code expired. Please try again."); -} - -interface GitHubUserInfo { - login: string; - email: string | null; -} - -/** - * Step 3: Get the authenticated GitHub user info. - */ -export async function getGitHubUser( - accessToken: string -): Promise { - logDebug("Fetching GitHub user info"); - const response = await fetch("https://api.github.com/user", { - headers: { - Authorization: `Bearer ${accessToken}`, - "User-Agent": "archgate-cli", - }, - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - throw new UserError( - `Failed to fetch GitHub user (HTTP ${response.status})` - ); - } - - const data = GitHubUserSchema.parse(await response.json()); - if (data.login === undefined || data.login === "") { - throw new Error("GitHub API did not return a username"); - } - logDebug("GitHub user:", data.login); - return { login: data.login, email: data.email }; -} - -// --------------------------------------------------------------------------- -// Token Claim -// --------------------------------------------------------------------------- - -/** - * Exchange a GitHub access token for an archgate plugin token - * via POST /api/token/claim on the plugins service. - */ -export async function claimArchgateToken(githubToken: string): Promise { - logDebug("Claiming archgate token from:", `${PLUGINS_API}/api/token/claim`); - const response = await fetch(`${PLUGINS_API}/api/token/claim`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "archgate-cli", - }, - body: JSON.stringify({ github_token: githubToken }), - signal: AbortSignal.timeout(15_000), - redirect: "error", - }); - - if (!response.ok) { - const errorResult = ErrorResponseSchema.safeParse( - await response.json().catch(() => ({})) - ); - const body = errorResult.success ? errorResult.data : {}; - - if (isSignupRequiredError(body.error)) { - throw new SignupRequiredError(); - } - - const message = - body.error ?? `Token claim failed (HTTP ${response.status})`; - throw new UserError(message); - } - - const data = TokenResponseSchema.parse(await response.json()); - if (data.token === undefined || data.token === "") { - throw new Error("Plugins service did not return a token"); - } - logDebug("Archgate token claimed successfully"); - return data.token; -} diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 4ec80bb1..3333e58c 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -1,22 +1,47 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate /** - * Secure credential storage in the OS credential manager (macOS Keychain, - * Windows Credential Manager, libsecret) via `git credential - * approve/fill/reject` — nothing is written to disk. In the protocol, - * `username` carries the GitHub username and `password` the plugin token. + * Credential storage through `git credential approve/fill/reject`, so the + * configured helper decides where records live. The platform session is + * filed under {@link AUTH_HOST} for {@link SESSION_ACCOUNT} as a JSON + * password; a pre-platform token sits under {@link PLUGINS_HOST} as-is. * * @see https://git-scm.com/docs/git-credential */ import { unlinkSync } from "node:fs"; +import { z } from "zod"; + import { logDebug, logWarn } from "./log"; import { internalPath } from "./paths"; +import { + AUTH_HOST, + isExpired, + platformAuth, + type Session, + SessionSchema, + type TokenSet, +} from "./platform-auth"; +import { PLUGINS_HOST } from "./plugin-install"; +import { SessionExpiredError } from "./session-expired-error"; -const CREDENTIAL_HOST = "plugins.archgate.dev"; const CREDENTIAL_TIMEOUT_MS = 3_000; +/** How often, and how far apart, a losing renewal re-reads the store. */ +const RENEWAL_REREADS = 4; +const RENEWAL_REREAD_DELAY_MS = 250; + +/** + * Account the platform session is filed under. + * + * One fixed name, so signing in as another user replaces the record instead + * of leaving the old one beside it, and every read selects this record rather + * than whichever the helper answers first. The display name lives inside the + * JSON payload. + */ +const SESSION_ACCOUNT = "archgate"; + /** * Build env for git credential commands at call time (not import time), * suppressing every interactive prompt across platforms and Git Credential @@ -43,8 +68,19 @@ export interface StoredCredentials { // Git credential protocol helpers // --------------------------------------------------------------------------- -function credentialInput(username?: string, password?: string): string { - const lines = ["protocol=https", `host=${CREDENTIAL_HOST}`]; +/** + * One record in git's credential protocol. + * + * No value here can forge a protocol line: hosts and the session account are + * constants, and the session payload is JSON, which escapes line breaks and + * NUL. Legacy records only ever carry values git itself produced. + */ +function credentialInput( + host: string, + username?: string, + password?: string +): string { + const lines = ["protocol=https", `host=${host}`]; if (username !== undefined && username !== "") lines.push(`username=${username}`); if (password !== undefined && password !== "") @@ -53,35 +89,67 @@ function credentialInput(username?: string, password?: string): string { return lines.join("\n"); } -async function gitCredentialApprove( +/** + * Run `git credential approve` or `reject` for one record. + * + * @returns `true` when git exited 0. Output is ignored: nothing reads it, and + * an unread pipe can fill and block git (ARCH-007). + */ +async function gitCredential( + action: "approve" | "reject", + host: string, username: string, password: string ): Promise { - const proc = Bun.spawn(["git", "credential", "approve"], { - stdin: new Blob([credentialInput(username, password)]), - stdout: "pipe", - stderr: "pipe", + const proc = Bun.spawn(["git", "credential", action], { + stdin: new Blob([credentialInput(host, username, password)]), + stdout: "ignore", + stderr: "ignore", env: gitCredentialEnv(), }); return (await proc.exited) === 0; } -async function gitCredentialFill(): Promise<{ - username: string; - password: string; -} | null> { +/** Outcome of one `git credential fill`, distinguishing absent from unusable. */ +const FillResultSchema = z.object({ + credentials: z + .object({ username: z.string().min(1), password: z.string().min(1) }) + .nullable(), + /** True when the helper had to be killed for exceeding the timeout. */ + timedOut: z.boolean(), +}); + +type FillResult = z.infer; + +/** Empty outcome, used for every path that yields no credentials. */ +function noCredentials(timedOut: boolean): FillResult { + return { credentials: null, timedOut }; +} + +/** + * Ask git for the record filed under a host. + * + * @param host - Host the record is filed under. + * @param account - When given, selects that account's record rather than + * whichever the helper answers first. + */ +async function gitCredentialFill( + host: string, + account?: string +): Promise { try { + // stderr is ignored rather than piped: nothing reads it, and an unread + // pipe can fill and block the helper (ARCH-007). const proc = Bun.spawn(["git", "credential", "fill"], { - stdin: new Blob([credentialInput()]), + stdin: new Blob([credentialInput(host, account)]), stdout: "pipe", - stderr: "pipe", + stderr: "ignore", env: gitCredentialEnv(), }); - // The timeout MUST be cancelled when the spawn wins the race — - // `Bun.sleep` / `setTimeout` both keep the event loop alive for their - // full duration, adding 3s of latency to every `loadCredentials()` - // caller (e.g. `archgate doctor`) if left running. + // The timer is cleared when git answers first: a pending `setTimeout` + // keeps the event loop alive for its full duration, which would add 3s of + // latency to every caller. let timer: ReturnType | undefined; const result = await Promise.race([ (async () => { @@ -99,7 +167,8 @@ async function gitCredentialFill(): Promise<{ if (timer) clearTimeout(timer); }); - if (result?.exitCode !== 0) return null; + if (result === null) return noCredentials(true); + if (result.exitCode !== 0) return noCredentials(false); let username = ""; let password = ""; @@ -107,47 +176,33 @@ async function gitCredentialFill(): Promise<{ if (line.startsWith("username=")) username = line.slice(9); if (line.startsWith("password=")) password = line.slice(9); } - return username && password ? { username, password } : null; + + // The helper is a subprocess, so its output is validated rather than trusted. + const parsed = FillResultSchema.safeParse({ + credentials: { username, password }, + timedOut: false, + }); + return parsed.success ? parsed.data : noCredentials(false); } catch { - return null; + return noCredentials(false); } } -async function gitCredentialReject( - username: string, - password: string -): Promise { - const proc = Bun.spawn(["git", "credential", "reject"], { - stdin: new Blob([credentialInput(username, password)]), - stdout: "pipe", - stderr: "pipe", - env: gitCredentialEnv(), - }); - await proc.exited; -} - // --------------------------------------------------------------------------- // Legacy metadata file cleanup // --------------------------------------------------------------------------- -/** Path to the legacy metadata file (~/.archgate/credentials). */ -function legacyMetadataPath(): string { - return internalPath("credentials"); -} - /** * Delete the legacy `~/.archgate/credentials` file if it exists. * * @returns `true` when a file was found and deleted, `false` when none existed. */ async function cleanupLegacyMetadata(): Promise { - const file = Bun.file(legacyMetadataPath()); - if (await file.exists()) { - unlinkSync(legacyMetadataPath()); - logDebug("Legacy credentials metadata file removed"); - return true; - } - return false; + const path = internalPath("credentials"); + if (!(await Bun.file(path).exists())) return false; + unlinkSync(path); + logDebug("Legacy credentials metadata file removed"); + return true; } // --------------------------------------------------------------------------- @@ -158,54 +213,166 @@ const CREDENTIAL_HELPER_HINT = "Run `git config --global credential.helper` to check your configuration."; /** - * Persist archgate credentials in the OS credential manager. + * Persist a platform session in the OS credential manager. * - * A verification round-trip (`git credential fill`) confirms the token was + * A verification round-trip (`git credential fill`) confirms the blob was * actually persisted — `git credential approve` exits 0 even without a * configured helper, silently storing nothing. + * + * @param user - Display name shown by `archgate login status`. + * @param tokens - Access token, refresh token and absolute expiry. + * @returns `true` when the session is retrievable afterwards. */ -export async function saveCredentials( - credentials: StoredCredentials -): Promise { +export async function saveTokenSet( + user: string, + tokens: TokenSet +): Promise { await cleanupLegacyMetadata(); - const stored = await gitCredentialApprove( - credentials.github_user, - credentials.token + const serialized = JSON.stringify({ user, tokens } satisfies Session); + const stored = await gitCredential( + "approve", + AUTH_HOST, + SESSION_ACCOUNT, + serialized ); - - if (stored) { - const verified = await gitCredentialFill(); - if (verified) { - logDebug("Token verified in git credential manager"); - } else { - logWarn( - "Token could not be verified in git credential manager.", - "Your credential helper may not persist credentials.", - CREDENTIAL_HELPER_HINT, - "Without a working credential helper, you will need to re-login after each session." - ); - } - } else { + if (!stored) { logWarn( "git credential approve failed.", "Your git credential helper may not be configured.", CREDENTIAL_HELPER_HINT ); + return false; + } + + // The read-back must return this very blob, not whatever else the helper + // may hold for the host. + const { credentials } = await gitCredentialFill(AUTH_HOST, SESSION_ACCOUNT); + if (credentials?.password === serialized) { + logDebug("Token set verified in git credential manager"); + return true; } + + logWarn( + "Token could not be verified in git credential manager.", + "Your credential helper may not persist credentials.", + CREDENTIAL_HELPER_HINT, + "Without a working credential helper, you will need to re-login after each session." + ); + return false; } /** - * Load stored archgate credentials from the OS credential manager. A legacy - * `~/.archgate/credentials` file is deleted on sight and the user is asked - * to re-login. + * Read the stored platform session without renewing it. + * + * @returns The session, or `null` when none is stored. + */ +export async function loadTokenSet(): Promise { + return (await readSession()).session; +} + +/** {@link loadTokenSet}, also reporting whether the helper timed out. */ +async function readSession(): Promise<{ + session: Session | null; + timedOut: boolean; +}> { + const { credentials, timedOut } = await gitCredentialFill( + AUTH_HOST, + SESSION_ACCOUNT + ); + if (!credentials) return { session: null, timedOut }; + + const parsed = SessionSchema.safeParse(safeJsonParse(credentials.password)); + if (!parsed.success) { + logDebug("Stored session is unreadable"); + return { session: null, timedOut: false }; + } + return { session: parsed.data, timedOut: false }; +} + +/** + * Return a usable access token, renewing it when it has lapsed. + * + * @returns Credentials ready to send, or `null` when the user is signed out. + */ +export async function resolveAccessToken(): Promise { + return (await resolveWithStatus()).credentials; +} + +/** {@link resolveAccessToken}, also reporting whether the helper timed out. */ +async function resolveWithStatus(): Promise<{ + credentials: StoredCredentials | null; + timedOut: boolean; +}> { + const { session, timedOut } = await readSession(); + if (!session) return { credentials: null, timedOut }; + + const credentials = isExpired(session.tokens.expiresAt) + ? await renew(session) + : { token: session.tokens.accessToken, github_user: session.user }; + return { credentials, timedOut: false }; +} + +/** + * Exchange a refresh token, tolerating a sibling process having rotated it. + * + * Git invokes the credential helper many times for one operation, so several + * processes can read the same expired session and refresh concurrently. The + * platform rotates the refresh token, which makes every exchange after the + * first fail; re-reading the store recovers the token the winner saved. + * + * @param stale - The expired session as read before renewal. + * @returns Credentials from this renewal, or from whichever process won. + * @throws {UserError} When no usable session exists afterwards. + */ +async function renew(stale: Session): Promise { + try { + const renewed = await platformAuth.refreshAccessToken( + stale.tokens.refreshToken + ); + if (!(await saveTokenSet(stale.user, renewed))) { + // The exchange rotated the refresh token, so the stored one is already + // dead: this request can finish, but the session cannot be renewed again. + logWarn( + "Renewed credentials could not be stored.", + "Run `archgate login` to sign in again." + ); + } + return { token: renewed.accessToken, github_user: stale.user }; + } catch (error) { + // The winner stores its result only after its own exchange, so a loser + // can be refused before that write lands: re-read a few times first. + /* oxlint-disable no-await-in-loop -- bounded re-reads, in order */ + for (let attempt = 0; attempt < RENEWAL_REREADS; attempt += 1) { + if (attempt > 0) await Bun.sleep(RENEWAL_REREAD_DELAY_MS); + const { session } = await readSession(); + if ( + session && + session.tokens.refreshToken !== stale.tokens.refreshToken && + !isExpired(session.tokens.expiresAt) + ) { + logDebug("token set was renewed by another process", { attempt }); + return { token: session.tokens.accessToken, github_user: session.user }; + } + } + /* oxlint-enable no-await-in-loop */ + throw error; + } +} + +/** + * Load archgate credentials, renewing a lapsed access token on the way. + * + * A token issued before platform sign-in is filed under {@link PLUGINS_HOST} + * and is returned unchanged, but only while no platform session is stored: + * once the user has signed in, that session alone decides. * * @returns The stored credentials, or `null` when none are stored. */ export async function loadCredentials(): Promise { - // Delete legacy metadata file — force re-login for a clean slate. - const hadLegacy = await cleanupLegacyMetadata(); - if (hadLegacy) { + // A legacy metadata file means a pre-platform install: remove it and + // require a fresh sign-in. + if (await cleanupLegacyMetadata()) { logWarn( "Legacy credentials file removed.", "Run `archgate login` to re-authenticate." @@ -213,22 +380,105 @@ export async function loadCredentials(): Promise { return null; } - const gitCreds = await gitCredentialFill(); - if (gitCreds) { - return { token: gitCreds.password, github_user: gitCreds.username }; + // A refused refresh token is an expected signed-out state, not a failure, + // and it ends the lookup: the legacy store is not consulted for a user who + // has signed in to the platform, and while archgate is git's helper for + // the plugins host that lookup would only reach archgate itself. Anything + // else — the service unreachable or failing — propagates. + let resolved: StoredCredentials | null = null; + let authLookupTimedOut = false; + try { + const outcome = await resolveWithStatus(); + resolved = outcome.credentials; + authLookupTimedOut = outcome.timedOut; + } catch (error) { + if (!(error instanceof SessionExpiredError)) throw error; + logDebug("stored session is signed out", { reason: error.message }); + return null; } - return null; + if (resolved) return resolved; + + // A helper that timed out on the first host will time out on the second too, + // so skip it rather than making every signed-out call wait twice over. + if (authLookupTimedOut) return null; + + const { credentials: legacy } = await gitCredentialFill(PLUGINS_HOST); + return legacy + ? { token: legacy.password, github_user: legacy.username } + : null; } /** * Remove stored credentials (logout). - * Clears the OS credential manager and any legacy metadata file. + * + * Clears both credential entries and any legacy metadata file. Callers + * unregister the git credential helper first: while archgate answers for the + * plugins host, the lookup for that host reaches archgate itself rather than + * the store holding a legacy token, which would then outlive the logout. + * + * @returns `true` when every record found was removed. */ -export async function clearCredentials(): Promise { - const gitCreds = await gitCredentialFill(); - if (gitCreds) { - await gitCredentialReject(gitCreds.username, gitCreds.password); - logDebug("Token removed from git credential manager"); +export async function clearCredentials(): Promise { + let cleared = true; + /* oxlint-disable no-await-in-loop -- two fixed hosts, cleared in order */ + for (const host of [AUTH_HOST, PLUGINS_HOST]) { + const { credentials, timedOut } = await gitCredentialFill( + host, + host === AUTH_HOST ? SESSION_ACCOUNT : undefined + ); + if (timedOut) { + // Nothing is known about this host, so nothing can be called removed. + logDebug("git credential fill timed out", { host }); + cleared = false; + continue; + } + if (!credentials) continue; + const rejected = await gitCredential( + "reject", + host, + credentials.username, + credentials.password + ); + if (rejected) { + logDebug("Credentials removed from git credential manager", { host }); + } else { + logDebug("git credential reject failed", { host }); + cleared = false; + } } + /* oxlint-enable no-await-in-loop */ await cleanupLegacyMetadata(); + return cleared; +} + +/** + * Mark the stored access token as lapsed, keeping the refresh token. + * + * Git erases credentials on any rejection, so discarding the refresh token + * here would turn a single recoverable 401 into a full device-flow login. + * The next lookup renews instead. + * + * @returns `true` when the lapsed token set was persisted; `false` when there + * was nothing stored or the store could not be updated, in which case + * {@link saveTokenSet} has already warned. + */ +export async function invalidateAccessToken(): Promise { + const session = await loadTokenSet(); + if (!session) return false; + + const saved = await saveTokenSet(session.user, { + ...session.tokens, + expiresAt: 0, + }); + if (saved) logDebug("access token marked for renewal"); + return saved; +} + +/** Parse JSON, returning `null` rather than throwing on malformed input. */ +function safeJsonParse(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } } diff --git a/src/helpers/desktop.ts b/src/helpers/desktop.ts new file mode 100644 index 00000000..6d94710f --- /dev/null +++ b/src/helpers/desktop.ts @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * desktop.ts — open a URL in the browser and copy text to the clipboard. + * + * Both are conveniences: every caller must still print what it would have + * opened or copied, because a headless host, an SSH session or a container + * has neither a browser nor a clipboard. + */ + +import { isLinux, isMacOS, isWindows, isWSL } from "./platform"; + +/** Give a launcher this long before deciding it is not going to answer. */ +const SPAWN_TIMEOUT_MS = 3_000; + +/** + * True when the process has no desktop session to open a browser into. + * + * CI and SSH are treated as headless even on a platform that would otherwise + * qualify: opening a browser on the far end of an SSH connection helps nobody. + * On Linux a session needs a display server; WSL is exempt because it reaches + * the Windows desktop through its own launcher. + */ +export function isHeadless(): boolean { + const set = (name: string): boolean => { + const value = Bun.env[name]; + return value !== undefined && value !== ""; + }; + + if (set("CI")) return true; + if (set("SSH_CONNECTION") || set("SSH_TTY")) return true; + if (isLinux() && !isWSL()) { + return !set("DISPLAY") && !set("WAYLAND_DISPLAY"); + } + return false; +} + +/** + * Run a command, resolving false rather than throwing when it is unavailable. + * + * Streams are ignored rather than piped: nothing reads them, and an unread + * pipe can fill and block the child (ARCH-007). + * + * @param command - Argv array. + * @param stdin - Optional payload written to the process's standard input. + */ +async function run(command: string[], stdin?: string): Promise { + try { + const proc = Bun.spawn(command, { + stdin: stdin === undefined ? "ignore" : new Blob([stdin]), + stdout: "ignore", + stderr: "ignore", + }); + + let timer: ReturnType | undefined; + const exited = await Promise.race([ + proc.exited, + new Promise<"timeout">((resolve) => { + timer = setTimeout(() => { + resolve("timeout"); + }, SPAWN_TIMEOUT_MS); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); + + if (exited === "timeout") { + proc.kill(); + return false; + } + return exited === 0; + } catch { + return false; + } +} + +/** Browser launchers to try, in order, for the current platform. */ +function browserCommands(url: string): string[][] { + if (isMacOS()) return [["open", url]]; + // rundll32 hands the URL to the default browser without a shell in + // between: `cmd /c start` and PowerShell both parse `&` and `?` out of it. + const windowsHandler = ["url.dll,FileProtocolHandler", url]; + // WSL reaches the Windows desktop; wslview ships with wslu, and rundll32 + // covers distributions that lack it. + if (isWSL()) { + return [ + ["wslview", url], + ["rundll32.exe", ...windowsHandler], + ]; + } + if (isWindows()) return [["rundll32", ...windowsHandler]]; + return [ + ["xdg-open", url], + ["gio", "open", url], + ]; +} + +/** Clipboard writers to try, in order, for the current platform. */ +function clipboardCommands(): string[][] { + if (isMacOS()) return [["pbcopy"]]; + if (isWSL() || isWindows()) return [["clip.exe"], ["clip"]]; + return [ + ["wl-copy"], + ["xclip", "-selection", "clipboard"], + ["xsel", "--clipboard", "--input"], + ]; +} + +/** Try each command in order, stopping at the first that succeeds. */ +async function firstThatWorks( + commands: string[][], + stdin?: string +): Promise { + if (isHeadless()) return false; + + /* oxlint-disable no-await-in-loop -- candidates are tried in order */ + for (const command of commands) { + if (await run(command, stdin)) return true; + } + /* oxlint-enable no-await-in-loop */ + return false; +} + +/** + * Open a URL in the user's browser. + * + * @param url - Absolute URL to open. + * @returns `true` when a launcher accepted it; `false` when headless or none + * of the platform's launchers are installed. + */ +export async function openBrowser(url: string): Promise { + return firstThatWorks(browserCommands(url)); +} + +/** + * Copy text to the system clipboard. + * + * @param text - Text to place on the clipboard. + * @returns `true` when a clipboard tool accepted it. + */ +export async function copyToClipboard(text: string): Promise { + return firstThatWorks(clipboardCommands(), text); +} diff --git a/src/helpers/git-credential-config.ts b/src/helpers/git-credential-config.ts new file mode 100644 index 00000000..3095b594 --- /dev/null +++ b/src/helpers/git-credential-config.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * git-credential-config.ts — register archgate as git's credential helper. + * + * Scoped to the plugins host alone, so credentials for every other host keep + * using whatever helper the user already configured. + */ + +import { selfInvokeArgv } from "./install-info"; +import { logDebug, logWarn } from "./log"; +import { PLUGINS_HOST } from "./plugin-install"; + +/** Config key holding the helper list for the plugins host. */ +const HELPER_KEY = `credential.https://${PLUGINS_HOST}.helper`; + +/** + * Quote one argument for the POSIX shell git runs `!` helpers through. + * + * Backslashes become forward slashes: Git for Windows runs helpers through + * its bundled `sh`, which treats a backslash inside quotes as an escape. + * + * @param arg - Executable path or argument. + */ +export function quoteForGitShell(arg: string): string { + const slashed = arg.replaceAll("\\", "/"); + if (/^[\w./:@%+=-]+$/u.test(slashed)) return slashed; + return `'${slashed.replaceAll("'", "'\\''")}'`; +} + +/** + * Shell form git invokes; `!` marks it as a command rather than a helper name. + * + * The executable is recorded by absolute path: git started by an editor or a + * GUI client rarely inherits the shell PATH that holds `archgate`. + */ +export function helperCommand(): string { + return `!${selfInvokeArgv(["credential"]) + .map((arg) => quoteForGitShell(arg)) + .join(" ")}`; +} + +/** + * Run a git command, resolving to its exit code; 1 when git cannot start. + * + * The environment is passed explicitly: `Bun.spawn` snapshots the environment + * at startup, so a later `Bun.env` assignment — which is how tests redirect + * `GIT_CONFIG_GLOBAL` — would otherwise not reach git. + */ +async function git(args: string[]): Promise { + try { + // Only the exit code matters; unread pipes can block git once it writes + // enough diagnostic output to fill them. + const proc = Bun.spawn(["git", ...args], { + stdout: "ignore", + stderr: "ignore", + env: { ...Bun.env }, + }); + return await proc.exited; + } catch { + return 1; + } +} + +/** + * Point git at archgate for plugin-host credentials. + * + * An empty value is written first: git accumulates helpers across config + * scopes, and the empty entry resets any inherited list so archgate is the + * only helper consulted for this host. + * + * @returns `true` when both config writes succeed. + */ +export async function registerGitCredentialHelper(): Promise { + const writes = [ + ["config", "--global", "--replace-all", HELPER_KEY, ""], + ["config", "--global", "--add", HELPER_KEY, helperCommand()], + ]; + /* oxlint-disable no-await-in-loop -- the reset must land before the add */ + for (const args of writes) { + if ((await git(args)) !== 0) return false; + } + /* oxlint-enable no-await-in-loop */ + + logDebug("Registered archgate as git credential helper for", PLUGINS_HOST); + return true; +} + +/** + * Register the helper, warning rather than failing when git refuses. + * + * Downloads use the access token directly, so a missing helper entry costs + * only non-interactive `git clone` of plugin repositories. + */ +export async function ensureGitCredentialHelper(): Promise { + if (await registerGitCredentialHelper()) return; + logWarn( + "Could not register archgate as a git credential helper.", + "Plugin downloads still work; `git clone` of a plugin repository may prompt for credentials." + ); +} + +/** + * Remove archgate from git's credential configuration for the plugins host. + * + * @returns `true` when the section was removed or was already absent. + */ +export async function unregisterGitCredentialHelper(): Promise { + const code = await git(["config", "--global", "--unset-all", HELPER_KEY]); + // Exit code 5 means the key was not set, which is the desired end state. + return code === 0 || code === 5; +} diff --git a/src/helpers/git-credential-protocol.ts b/src/helpers/git-credential-protocol.ts new file mode 100644 index 00000000..a5a9b177 --- /dev/null +++ b/src/helpers/git-credential-protocol.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * git-credential-protocol.ts — parse and format git's credential helper format. + * + * @see https://git-scm.com/docs/git-credential + */ + +/** Key/value pairs from one credential request. */ +export type CredentialRequest = Record; + +/** + * Parse the key=value block git writes to a helper's stdin. + * + * @param input - Raw stdin contents; parsing stops at the first blank line. + * @returns The parsed pairs, with unknown keys preserved. + */ +export function parseCredentialRequest(input: string): CredentialRequest { + const request: CredentialRequest = {}; + for (const line of input.split("\n")) { + if (line === "") break; + const separator = line.indexOf("="); + if (separator <= 0) continue; + request[line.slice(0, separator)] = line.slice(separator + 1); + } + return request; +} + +/** + * Render a credential answer in the format git expects on stdout. + * + * The answer ends with a blank line, the protocol's terminator, so git can + * stop reading without waiting for end-of-file. + * + * @param fields - Pairs to emit; values containing a newline are dropped, + * since a newline would let a value forge additional protocol lines. + */ +export function formatCredentialResponse(fields: CredentialRequest): string { + const lines = Object.entries(fields) + .filter(([, value]) => !value.includes("\n") && !value.includes("\0")) + .map(([key, value]) => `${key}=${value}\n`) + .join(""); + return `${lines}\n`; +} diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 14bdec44..2ed0502f 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -38,22 +38,6 @@ export const EDITOR_LABELS: Record = { opencode: "opencode", }; -/** Values sent to the signup API — one per EditorTarget. */ -export type SignupEditor = - | "claude-code" - | "vscode" - | "copilot-cli" - | "cursor" - | "opencode"; - -export const SIGNUP_EDITORS: Record = { - claude: "claude-code", - cursor: "cursor", - vscode: "vscode", - copilot: "copilot-cli", - opencode: "opencode", -}; - interface InitOptions { editor?: EditorTarget; /** When true, attempt to install the archgate plugin using stored credentials. */ diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 0d9c3e4e..14a411a8 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -1,202 +1,72 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate /** - * login-flow.ts — Shared GitHub device flow + signup logic - * used by both `login` and `init` commands. + * login-flow.ts — sign in to the Archgate platform; shared by `login` and `init`. */ import { styleText } from "node:util"; -import { - requestDeviceCode, - pollForAccessToken, - getGitHubUser, - claimArchgateToken, -} from "./auth"; -import { saveCredentials } from "./credential-store"; -import { - EDITOR_LABELS, - EDITOR_TARGETS, - SIGNUP_EDITORS, - type SignupEditor, -} from "./init-project"; -import { logDebug, logError, logInfo } from "./log"; -import { withPromptFix } from "./prompt"; -import { SignupRequiredError, requestSignup } from "./signup"; - -export type { SignupEditor } from "./init-project"; - -export interface LoginFlowOptions { - /** - * Pre-selected editor for signup (skip the editor prompt). - * When omitted, the user is prompted to choose. - */ - editor?: SignupEditor; -} +import { saveTokenSet } from "./credential-store"; +import { copyToClipboard, openBrowser } from "./desktop"; +import { ensureGitCredentialHelper } from "./git-credential-config"; +import { logDebug, logInfo } from "./log"; +import { platformAuth } from "./platform-auth"; +import { UserError } from "./user-error"; export interface LoginFlowResult { /** Whether credentials were obtained. */ ok: boolean; - /** GitHub username, if login succeeded. */ - githubUser?: string; + /** Signed-in account name, if login succeeded. */ + accountName?: string; } /** - * Run the full GitHub device flow: authenticate, claim token (or sign up - * if the user is unregistered), and store credentials. + * Sign in to the platform and store the resulting session. * - * @param options - Flow overrides, such as a pre-selected editor target. - * @returns `{ ok: true }` once credentials are stored, or `{ ok: false }` on - * failure — the error is printed before returning, so callers exit quietly. + * Registers archgate as git's credential helper for the plugins host so that + * `git clone` of a plugin repository gets a freshly minted access token. + * + * @returns `{ ok: true }` once credentials are stored. */ -export async function runLoginFlow( - options?: LoginFlowOptions -): Promise { - console.log("By signing up, you agree to the Archgate Terms of Service:"); +export async function runLoginFlow(): Promise { + console.log("By signing in, you agree to the Archgate Terms of Service:"); console.log("https://archgate.dev/terms-of-service\n"); - logInfo("Authenticating with GitHub...\n"); + logDebug("Starting platform sign-in"); + const authorization = await platformAuth.requestDeviceCode(); + + const copied = await copyToClipboard(authorization.userCode); + const code = + styleText(["bold", "green"], authorization.userCode) + + (copied ? " (copied to your clipboard)" : ""); + + // The complete URI carries the code as a query parameter, so a browser that + // opens it leaves nothing to type. It is always printed as well: the browser + // may not open, and the user may be reading this on another machine. + const target = + authorization.verificationUriComplete ?? authorization.verificationUri; + const url = styleText("bold", target); + if (await openBrowser(target)) { + console.log(`Opened ${url} in your browser.`); + console.log(`If it asks for a code, enter: ${code}\n`); + } else { + console.log(`Open ${url} in your browser`); + console.log(`and enter the code: ${code}\n`); + } - logDebug("Starting login flow"); - const deviceCode = await requestDeviceCode(); - logDebug( - "Device code received, verification URI:", - deviceCode.verification_uri - ); - 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 { user, tokens } = await platformAuth.pollForTokens(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)}`); - - logInfo("Claiming archgate plugin token..."); - let archgateToken: string; - try { - archgateToken = await claimArchgateToken(githubToken); - logDebug("Token claimed successfully"); - } catch (err) { - if (!(err instanceof SignupRequiredError)) throw err; - logDebug("Signup required — starting signup flow"); - - 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 (!(await saveTokenSet(user, tokens))) { + throw new UserError( + "Signed in, but the credentials could not be stored. Configure a git credential helper and run `archgate login` again." ); - if (result === null || result === "") return { ok: false }; - archgateToken = result; } - await saveCredentials({ token: archgateToken, github_user: githubUser }); + await ensureGitCredentialHelper(); logInfo( - `Authenticated as ${styleText("bold", githubUser)}. Plugin access is now available.` - ); - return { ok: true, githubUser }; -} - -/** - * Prompt for signup details and submit the registration request. - * - * @param githubUser - GitHub login of the authenticated user. - * @param githubToken - GitHub access token proving that identity. - * @param githubEmail - Email from GitHub, or `null` to prompt for one. - * @param preselectedEditor - Skips the editor prompt when supplied. - * @returns The archgate token, or `null` on failure — the error is printed - * before returning. - */ -async function runSignupPrompt( - githubUser: string, - githubToken: string, - githubEmail: string | null, - preselectedEditor?: SignupEditor -): Promise { - // Lazy-load inquirer — it costs ~200ms to parse and is only needed for - // interactive signup prompts, not on every CLI startup. - const { default: inquirer } = await import("inquirer"); - const { email } = await withPromptFix(async () => - inquirer.prompt<{ email: string }>({ - type: "input", - name: "email", - message: "Email address:", - default: githubEmail ?? undefined, - validate: (v: string) => - /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(v) || "Enter a valid email address", - }) + `Authenticated as ${styleText("bold", user)}. Plugin access is now available.` ); - - let editor: SignupEditor = preselectedEditor ?? "claude-code"; - if (!preselectedEditor) { - // Build choices from the canonical EDITOR_LABELS + SIGNUP_EDITORS maps - // so adding a new editor in init-project.ts propagates here automatically. - const choices = EDITOR_TARGETS.map((key) => ({ - name: EDITOR_LABELS[key], - value: SIGNUP_EDITORS[key], - })); - const ans = await withPromptFix(async () => - inquirer.prompt<{ editor: SignupEditor }>({ - type: "select", - name: "editor", - message: "Which editor will you use with archgate?", - choices, - }) - ); - editor = ans.editor; - } - - const { useCase } = await withPromptFix(async () => - inquirer.prompt<{ useCase: string }>({ - type: "input", - name: "useCase", - message: "How do you plan to use archgate?", - validate: (v: string) => - v.trim().length > 0 || "Please describe your use case", - }) - ); - - const { confirmed } = await withPromptFix(async () => - inquirer.prompt<{ confirmed: boolean }>({ - type: "confirm", - name: "confirmed", - message: - "I agree to be contacted by the Archgate team to provide feedback during the beta period.", - default: true, - }) - ); - - if (!confirmed) { - logInfo("Signup cancelled."); - return null; - } - - logInfo("\nSubmitting signup request..."); - const result = await requestSignup(githubUser, email, useCase, editor); - - if (!result.ok) { - logError("Signup request failed. Please try again with `archgate login`."); - return null; - } - - if (result.token !== null && result.token !== "") return result.token; - - logInfo("Claiming archgate plugin token..."); - return claimArchgateToken(githubToken); + return { ok: true, accountName: user }; } diff --git a/src/helpers/oauth-device-flow.ts b/src/helpers/oauth-device-flow.ts new file mode 100644 index 00000000..226f4ade --- /dev/null +++ b/src/helpers/oauth-device-flow.ts @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * oauth-device-flow.ts — sign-in over the OAuth 2.0 device authorization + * grant (RFC 8628) with refresh-token renewal (RFC 6749 §6). + * + * Knows nothing about which service it talks to: every endpoint and client + * detail arrives through {@link DeviceFlowConfig}. + */ + +import { z } from "zod"; + +import { logDebug } from "./log"; +import type { + DeviceAuthorization, + PlatformAuth, + Session, + TokenSet, +} from "./platform-auth"; +import { SessionExpiredError } from "./session-expired-error"; +import { UserError } from "./user-error"; + +/** Where and as whom to sign in. */ +export interface DeviceFlowConfig { + /** RFC 8628 device authorization endpoint. */ + deviceAuthorizationEndpoint: string; + /** OAuth 2.0 token endpoint, used for both grants. */ + tokenEndpoint: string; + /** Public client identifier; a native app holds no secret. */ + clientId: string; + /** Space-separated scopes; must include `offline_access` for a refresh token. */ + scope: string; + /** Resource indicator (RFC 8707) the access token is minted for. */ + resource: string; +} + +const REQUEST_TIMEOUT_MS = 15_000; + +/** RFC 8628 default when the server omits `interval`. */ +const DEFAULT_POLL_INTERVAL_SECONDS = 5; + +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + +/** Display name when the ID token offers nothing usable. */ +const FALLBACK_USER = "archgate"; + +const DeviceCodeSchema = z.object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: z.string(), + verification_uri_complete: z.string().optional(), + expires_in: z.number(), + interval: z.number().default(DEFAULT_POLL_INTERVAL_SECONDS), +}); + +const TokenSuccessSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + id_token: z.string().optional(), + expires_in: z.number(), +}); + +type TokenSuccess = z.infer; + +const TokenErrorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}); + +const IdTokenClaimsSchema = z.object({ + sub: z.string(), + username: z.string().nullish(), + name: z.string().nullish(), + email: z.string().nullish(), +}); + +/** + * Build a {@link PlatformAuth} that signs in through the given endpoints. + * + * @param config - Endpoints and client identity of the sign-in service. + */ +export function deviceFlowAuth(config: DeviceFlowConfig): PlatformAuth { + return { + async requestDeviceCode(): Promise { + const response = await postFormReachable( + config.deviceAuthorizationEndpoint, + { + client_id: config.clientId, + scope: config.scope, + resource: config.resource, + }, + "to start sign-in" + ); + if (!response.ok) { + throw new UserError( + `Could not start sign-in (HTTP ${response.status}). Please try again.` + ); + } + + const parsed = DeviceCodeSchema.safeParse(await jsonBody(response)); + if (!parsed.success) throw unexpectedResponse(); + return { + deviceCode: parsed.data.device_code, + userCode: parsed.data.user_code, + verificationUri: parsed.data.verification_uri, + verificationUriComplete: parsed.data.verification_uri_complete, + expiresIn: parsed.data.expires_in, + interval: parsed.data.interval, + }; + }, + + async pollForTokens(authorization: DeviceAuthorization): Promise { + const deadline = Date.now() + authorization.expiresIn * 1000; + let interval = authorization.interval; + + /* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */ + while (Date.now() < deadline) { + await Bun.sleep(interval * 1000); + + // The resource is repeated here (RFC 8707 §2.2) so the first access + // token is minted for the plugins audience, not only the refreshed ones. + let response: Response; + try { + response = await postForm(config.tokenEndpoint, { + client_id: config.clientId, + grant_type: DEVICE_GRANT, + device_code: authorization.deviceCode, + resource: config.resource, + }); + } catch (error) { + // A blip while the user is still at the browser is not a verdict; + // the next poll retries until the code itself expires. + logDebug("poll failed, retrying", { reason: errorMessage(error) }); + continue; + } + const body = await jsonBody(response); + + if (response.ok) { + const granted = parseTokenResponse(body); + return { + user: userFromIdToken(granted.id_token), + tokens: toTokenSet(granted), + }; + } + + const error = TokenErrorSchema.safeParse(body); + if (!error.success) throw unexpectedResponse(); + + switch (error.data.error) { + case "authorization_pending": + break; + case "slow_down": + interval += DEFAULT_POLL_INTERVAL_SECONDS; + break; + case "access_denied": + throw new UserError("Sign-in was denied."); + case "expired_token": + throw codeExpired(); + default: + throw new UserError( + error.data.error_description ?? + `Sign-in failed: ${error.data.error}` + ); + } + } + /* oxlint-enable no-await-in-loop */ + + throw codeExpired(); + }, + + async refreshAccessToken(refreshToken: string): Promise { + const response = await postFormReachable( + config.tokenEndpoint, + { + client_id: config.clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + resource: config.resource, + }, + "to renew your session" + ); + const body = await jsonBody(response); + if (response.ok) + return toTokenSet(parseTokenResponse(body), refreshToken); + + // Only a refused grant means the session is over; anything else is the + // service failing to answer, which the caller should not treat as a + // sign-out. + const error = TokenErrorSchema.safeParse(body); + if (error.success && error.data.error === "invalid_grant") { + throw new SessionExpiredError(); + } + throw new UserError( + `The sign-in service could not renew your session (HTTP ${response.status}).`, + "Try again later." + ); + }, + }; +} + +/** + * Send a form-encoded POST, giving up after {@link REQUEST_TIMEOUT_MS}. + * + * Redirects are refused: a 307 or 308 would replay the body, which carries a + * device code or refresh token, to whatever host the `Location` names. + */ +async function postForm( + url: string, + fields: Record +): Promise { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields), + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); +} + +/** + * {@link postForm}, turning a transport failure into a user-facing error. + * + * The cause is kept in the message: TLS interception is detected by matching + * it, and the hint the user gets depends on that. + * + * @param purpose - Completes "Could not reach the sign-in service …". + * @throws {UserError} When the request could not be sent or answered. + */ +async function postFormReachable( + url: string, + fields: Record, + purpose: string +): Promise { + try { + return await postForm(url, fields); + } catch (error) { + throw new UserError( + `Could not reach the sign-in service ${purpose}.`, + `Check your connection and try again. (${errorMessage(error)})` + ); + } +} + +/** + * Read a JSON body, yielding `null` for one that is not JSON. + * + * A proxy answering with an HTML error page would otherwise surface as a + * `SyntaxError` — an internal fault that reaches Sentry — instead of the + * user-facing error the caller raises for an unexpected body. + */ +async function jsonBody(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function unexpectedResponse(): UserError { + return new UserError("Sign-in service returned an unexpected response."); +} + +function codeExpired(): UserError { + return new UserError( + "The sign-in code expired. Run `archgate login` to try again." + ); +} + +/** @throws {UserError} When the body is not a token grant. */ +function parseTokenResponse(body: unknown): TokenSuccess { + const parsed = TokenSuccessSchema.safeParse(body); + if (!parsed.success) throw unexpectedResponse(); + return parsed.data; +} + +/** + * Turn a token grant into a {@link TokenSet}. + * + * @param granted - The parsed token response. + * @param fallbackRefreshToken - Kept when the grant omits a rotated one. + * @throws {UserError} When no refresh token is available from either source. + */ +function toTokenSet( + granted: TokenSuccess, + fallbackRefreshToken?: string +): TokenSet { + const refreshToken = granted.refresh_token ?? fallbackRefreshToken; + if (refreshToken === undefined || refreshToken === "") { + throw new UserError( + "Sign-in did not return a refresh token. Please try again." + ); + } + return { + accessToken: granted.access_token, + refreshToken, + expiresAt: Date.now() + granted.expires_in * 1000, + }; +} + +/** + * Pick a display name out of an ID token: username, name, email or subject, + * in that order. Presentation only — the access token is what the backend + * verifies, so nothing here is trusted for authorization. + */ +function userFromIdToken(idToken: string | undefined): string { + const parts = idToken?.split(".") ?? []; + const payload = parts.length >= 3 ? parts[1] : undefined; + if (payload === undefined || payload === "") return FALLBACK_USER; + + try { + const claims = IdTokenClaimsSchema.safeParse( + JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) + ); + if (!claims.success) return FALLBACK_USER; + // An empty claim is skipped like an absent one: the stored session + // requires a non-empty name, so an empty one would be dropped on read. + const { username, name, email, sub } = claims.data; + const first = [username, name, email, sub].find( + (claim): claim is string => typeof claim === "string" && claim !== "" + ); + return first ?? FALLBACK_USER; + } catch { + return FALLBACK_USER; + } +} diff --git a/src/helpers/platform-auth.ts b/src/helpers/platform-auth.ts new file mode 100644 index 00000000..512d27e9 --- /dev/null +++ b/src/helpers/platform-auth.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * platform-auth.ts — how the CLI signs in to the Archgate platform. + * + * The rest of the CLI reaches the platform's sign-in service only through + * {@link platformAuth}. The mechanism behind it is an implementation detail: + * changing providers means wiring a different {@link PlatformAuth} here, not + * touching the login flow or the credential store. + */ + +import { z } from "zod"; + +import { deviceFlowAuth } from "./oauth-device-flow"; +import { PLUGINS_HOST } from "./plugin-install"; + +/** Host of the platform's sign-in service. */ +export const AUTH_HOST = "auth.archgate.dev"; + +/** Renew this long before expiry so a token cannot lapse mid-request. */ +const EXPIRY_SKEW_SECONDS = 60; + +/** A token pair plus the absolute epoch-ms instant the access token lapses. */ +export const TokenSetSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + expiresAt: z.number(), +}); + +export type TokenSet = z.infer; + +/** A signed-in account and the tokens that prove it. */ +export const SessionSchema = z.object({ + /** Display name of the account, shown by `archgate login status`. */ + user: z.string().min(1), + tokens: TokenSetSchema, +}); + +export type Session = z.infer; + +/** What the user must do to approve a sign-in started on this device. */ +export interface DeviceAuthorization { + /** Opaque handle polled until the user approves. */ + deviceCode: string; + /** Short code the user types at {@link verificationUri}. */ + userCode: string; + verificationUri: string; + /** {@link verificationUri} with the code already filled in, when offered. */ + verificationUriComplete?: string; + /** Seconds until the codes expire. */ + expiresIn: number; + /** Seconds to wait between polls. */ + interval: number; +} + +/** The sign-in operations the CLI needs from the platform. */ +export interface PlatformAuth { + /** + * Start a sign-in on this device. + * + * @throws {UserError} When the platform rejects the request. + */ + requestDeviceCode(): Promise; + + /** + * Wait until the user approves the sign-in. + * + * @param authorization - The pending sign-in from {@link requestDeviceCode}. + * @throws {UserError} On denial, expiry, or a terminal platform error. + */ + pollForTokens(authorization: DeviceAuthorization): Promise; + + /** + * Trade a refresh token for a fresh token set. + * + * @param refreshToken - The stored refresh token. + * @returns A new token set; the refresh token rotates, so store the result. + * @throws {UserError} When the refresh token is expired or revoked. + */ + refreshAccessToken(refreshToken: string): Promise; +} + +/** The platform's sign-in service, reached over the OAuth 2.0 device flow. */ +export const platformAuth: PlatformAuth = deviceFlowAuth({ + deviceAuthorizationEndpoint: `https://${AUTH_HOST}/oidc/device/auth`, + tokenEndpoint: `https://${AUTH_HOST}/oidc/token`, + // A public client: a native app holds no secret. + clientId: "gciwm5x5fc2hfr5zjy4ve", + scope: "openid profile email offline_access", + // The audience the plugins backend validates access tokens against. + resource: `https://${PLUGINS_HOST}`, +}); + +/** True when `expiresAt` is in the past or close enough to treat as lapsed. */ +export function isExpired(expiresAt: number): boolean { + return Date.now() >= expiresAt - EXPIRY_SKEW_SECONDS * 1000; +} diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 615aacf5..812a1710 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -18,16 +18,17 @@ import { import { resolveCommand } from "./platform"; import { UserError } from "./user-error"; -const PLUGINS_API = "https://plugins.archgate.dev"; +/** Host serving plugin downloads and plugin git repositories. */ +export const PLUGINS_HOST = "plugins.archgate.dev"; + +const PLUGINS_API = `https://${PLUGINS_HOST}`; /** Base marketplace URL — credentials are provided by the git credential manager. */ -const MARKETPLACE_URL = "https://plugins.archgate.dev/archgate.git"; +const MARKETPLACE_URL = `${PLUGINS_API}/archgate.git`; /** Base VS Code marketplace URL — credentials are provided by the git credential manager. */ -const VSCODE_MARKETPLACE_URL = - "https://plugins.archgate.dev/archgate/vscode.git"; +const VSCODE_MARKETPLACE_URL = `${PLUGINS_API}/archgate/vscode.git`; /** Cursor Team Marketplace URL — credentials are provided by the git credential manager. */ -const CURSOR_MARKETPLACE_URL = - "https://plugins.archgate.dev/archgate/cursor.git"; +const CURSOR_MARKETPLACE_URL = `${PLUGINS_API}/archgate/cursor.git`; /** * Run a command using `Bun.spawn` (cross-platform, no shell — ARCH-007). diff --git a/src/helpers/session-expired-error.ts b/src/helpers/session-expired-error.ts new file mode 100644 index 00000000..279a7172 --- /dev/null +++ b/src/helpers/session-expired-error.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { UserError } from "./user-error"; + +/** + * The platform refused the stored refresh token, so the user is signed out. + * + * Distinct from other {@link UserError}s so callers can treat it as an + * expected state rather than a failure to report. + */ +export class SessionExpiredError extends UserError { + constructor() { + super("Your session has expired. Run `archgate login` to sign in again."); + this.name = "SessionExpiredError"; + } +} diff --git a/src/helpers/signup.ts b/src/helpers/signup.ts deleted file mode 100644 index fd2bd4d9..00000000 --- a/src/helpers/signup.ts +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -/** - * signup.ts — Archgate plugins platform signup for unregistered users. - */ - -import { z } from "zod"; - -import { logDebug } from "./log"; - -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"; - } -} - -export function isSignupRequiredError(message?: string): boolean { - if (message === undefined || message === "") return false; - const lower = message.toLowerCase(); - return ( - lower.includes("no approved signup") || lower.includes("not registered") - ); -} - -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 { - logDebug("Submitting signup request for:", github); - 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), - redirect: "error", - }); - - if (response.status !== 201) { - logDebug("Signup request failed, status:", response.status); - return { ok: false, token: null }; - } - - const SignupResponseSchema = z.object({ - token: z.string().nullable().default(null), - }); - const result = SignupResponseSchema.safeParse( - await response.json().catch(() => ({})) - ); - const data = result.success ? result.data : { token: null }; - logDebug("Signup successful, token provided:", Boolean(data.token)); - return { ok: true, token: data.token }; -} diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts new file mode 100644 index 00000000..d1e6844c --- /dev/null +++ b/tests/commands/credential.test.ts @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { + afterEach, + beforeEach, + describe, + expect, + mock, + type Mock, + spyOn, + test, +} from "bun:test"; + +import { Command } from "@commander-js/extra-typings"; + +import { + isCredentialHelperInvocation, + registerCredentialCommand, +} from "../../src/commands/credential"; +import * as credMod from "../../src/helpers/credential-store"; +import * as exitModule from "../../src/helpers/exit"; +import { rejectionMessage } from "../test-utils"; + +let stdoutSpy: Mock; +let resolveSpy: Mock; +let invalidateSpy: Mock; + +/** Everything the command wrote to stdout during one run. */ +function written(): string { + return stdoutSpy.mock.calls.map((call) => String(call[0])).join(""); +} + +/** Run a `credential` subcommand with the given request on stdin. */ +async function run(subcommand: string, request: string): Promise { + spyOn(Bun.stdin, "text").mockResolvedValue(request); + const program = new Command(); + program.exitOverride(); + registerCredentialCommand(program); + await program.parseAsync(["node", "archgate", "credential", subcommand]); +} + +const PLUGIN_REQUEST = "protocol=https\nhost=plugins.archgate.dev\n\n"; + +beforeEach(() => { + stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + resolveSpy = spyOn(credMod, "resolveAccessToken").mockResolvedValue({ + token: "ey.access", + github_user: "octocat", + }); + invalidateSpy = spyOn(credMod, "invalidateAccessToken").mockResolvedValue( + true + ); +}); + +afterEach(() => { + mock.restore(); +}); + +// Git starts the helper with stdout as the protocol channel, so startup must +// not run the git install check, which logs and may spawn an installer. +describe("isCredentialHelperInvocation", () => { + test.each([ + [["bun", "archgate", "credential", "get"], true], + [["bun", "archgate", "--log-level", "debug", "credential", "get"], true], + [["bun", "archgate", "login"], false], + [["bun", "archgate"], false], + ])("%p → %p", (argv, expected) => { + expect(isCredentialHelperInvocation(argv)).toBe(expected); + }); +}); + +describe("archgate credential get", () => { + test("answers with the stored username and access token", async () => { + await run("get", PLUGIN_REQUEST); + + expect(written()).toBe( + "protocol=https\nhost=plugins.archgate.dev\nusername=octocat\npassword=ey.access\n\n" + ); + }); + + // Silence tells git to fall through to its other helpers rather than fail. + test("prints nothing when signed out", async () => { + resolveSpy.mockResolvedValue(null); + + await run("get", PLUGIN_REQUEST); + + expect(written()).toBe(""); + }); + + // Git uses the requested protocol for the exchange, so answering a plain + // http request would hand the token over in cleartext. + test.each([ + "protocol=https\nhost=github.com\n\n", + "protocol=https\nhost=evil.example.com\n\n", + "protocol=https\n\n", + "protocol=http\nhost=plugins.archgate.dev\n\n", + "host=plugins.archgate.dev\n\n", + ])("stays silent for a request that is not ours (%p)", async (request) => { + await run("get", request); + + expect(written()).toBe(""); + expect(resolveSpy).not.toHaveBeenCalled(); + }); +}); + +describe("archgate credential erase", () => { + // Git erases on any rejection, so this must cost the access token only — + // dropping the refresh token would turn a 401 into a full re-login. + test("drops only the access token, keeping the session", async () => { + await run("erase", PLUGIN_REQUEST); + + expect(invalidateSpy).toHaveBeenCalled(); + }); + + test.each([ + "protocol=https\nhost=github.com\n\n", + "protocol=http\nhost=plugins.archgate.dev\n\n", + ])("leaves credentials alone for %p", async (request) => { + await run("erase", request); + + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); + +describe("archgate credential store", () => { + test("consumes the request without touching stored credentials", async () => { + await run("store", PLUGIN_REQUEST); + + expect(written()).toBe(""); + expect(invalidateSpy).not.toHaveBeenCalled(); + expect(resolveSpy).not.toHaveBeenCalled(); + }); +}); + +// Each action wraps its body in the command error boundary; a failure there +// must be routed through handleCommandError rather than escaping the process. +describe("error boundaries", () => { + test.each(["get", "store", "erase"])( + "%s routes a stdin failure to the error handler", + async (subcommand) => { + spyOn(Bun.stdin, "text").mockRejectedValue(new Error("stdin closed")); + const exitSpy = spyOn(exitModule, "exitWith").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = new Command(); + program.exitOverride(); + registerCredentialCommand(program); + + try { + expect( + await rejectionMessage( + program.parseAsync(["node", "archgate", "credential", subcommand]) + ) + ).toContain("process.exit"); + } finally { + exitSpy.mockRestore(); + } + } + ); +}); diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index 8bc2d81b..a65f8a9f 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -12,9 +12,10 @@ import { beforeEach, describe, expect, + mock, + type Mock, spyOn, test, - type Mock, } from "bun:test"; import { Command } from "@commander-js/extra-typings"; @@ -22,14 +23,23 @@ import { Command } from "@commander-js/extra-typings"; import { registerLoginCommand } from "../../src/commands/login"; import * as credentialStore from "../../src/helpers/credential-store"; import * as exitMod from "../../src/helpers/exit"; +import * as gitCredentialConfig from "../../src/helpers/git-credential-config"; import * as loginFlow from "../../src/helpers/login-flow"; import * as paths from "../../src/helpers/paths"; import * as telemetry from "../../src/helpers/telemetry"; +import { rejectionMessage } from "../test-utils"; // --------------------------------------------------------------------------- // Tests — Registration // --------------------------------------------------------------------------- +/** Everything a console spy received, one line per call. */ +function printed(spy: Mock<(...args: unknown[]) => void>): string { + return spy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); +} + describe("registerLoginCommand", () => { test("registers 'login' as a subcommand", () => { const program = new Command(); @@ -78,20 +88,37 @@ describe("login action handlers", () => { let logSpy: Mock; let errorSpy: Mock; let loadCredentialsSpy: Mock; + let loadTokenSetSpy: Mock; let clearCredentialsSpy: Mock; + let ensureHelperSpy: Mock< + typeof gitCredentialConfig.ensureGitCredentialHelper + >; + // Stubbed so logout does not run `git config --global` against the real + // machine's configuration. + let unregisterHelperSpy: Mock< + typeof gitCredentialConfig.unregisterGitCredentialHelper + >; let runLoginFlowSpy: Mock; let exitWithSpy: Mock; - let trackLoginSpy: Mock; beforeEach(() => { logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); loadCredentialsSpy = spyOn(credentialStore, "loadCredentials"); + loadTokenSetSpy = spyOn(credentialStore, "loadTokenSet").mockResolvedValue( + null + ); clearCredentialsSpy = spyOn(credentialStore, "clearCredentials"); + ensureHelperSpy = spyOn( + gitCredentialConfig, + "ensureGitCredentialHelper" + ).mockResolvedValue(); + unregisterHelperSpy = spyOn( + gitCredentialConfig, + "unregisterGitCredentialHelper" + ).mockResolvedValue(true); runLoginFlowSpy = spyOn(loginFlow, "runLoginFlow"); - trackLoginSpy = spyOn(telemetry, "trackLoginResult").mockImplementation( - () => {} - ); + spyOn(telemetry, "trackLoginResult").mockImplementation(() => {}); // Stub exitWith to throw instead of calling process.exit — avoids // needing to mock telemetry flush / sentry flush internals. exitWithSpy = spyOn(exitMod, "exitWith").mockImplementation( @@ -102,13 +129,7 @@ describe("login action handlers", () => { }); afterEach(() => { - logSpy.mockRestore(); - errorSpy.mockRestore(); - loadCredentialsSpy.mockRestore(); - clearCredentialsSpy.mockRestore(); - runLoginFlowSpy.mockRestore(); - exitWithSpy.mockRestore(); - trackLoginSpy.mockRestore(); + mock.restore(); }); function makeProgram(): Command { @@ -131,9 +152,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login", "status"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("Logged in as"); expect(allOutput).toContain("octocat"); }); @@ -144,9 +163,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login", "status"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("Not logged in"); }); @@ -161,9 +178,7 @@ describe("login action handlers", () => { ).rejects.toThrow("exitWith(2)"); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(2); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("credential store unavailable"); }); }); @@ -174,18 +189,73 @@ describe("login action handlers", () => { describe("logout", () => { test("calls clearCredentials and prints success", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); const program = makeProgram(); await program.parseAsync(["node", "test", "login", "logout"]); expect(clearCredentialsSpy).toHaveBeenCalled(); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("Logged out successfully"); }); + // While archgate is the helper for the plugins host, a legacy token in + // the OS store is invisible to clearing and would outlive the logout. + test("unregisters the git helper before clearing credentials", async () => { + const order: string[] = []; + unregisterHelperSpy.mockImplementation(async () => { + order.push("unregister"); + return true; + }); + clearCredentialsSpy.mockImplementation(async () => { + order.push("clear"); + return true; + }); + + const program = makeProgram(); + await program.parseAsync(["node", "test", "login", "logout"]); + + expect(order).toEqual(["unregister", "clear"]); + }); + + test("reports a partial logout when a record cannot be removed", async () => { + clearCredentialsSpy.mockResolvedValueOnce(false); + + const program = makeProgram(); + expect( + await rejectionMessage( + program.parseAsync(["node", "test", "login", "logout"]) + ) + ).toContain("exitWith(1)"); + + const allErrors = printed(errorSpy); + expect(allErrors).toContain("could not be removed"); + }); + + test("still clears credentials when the helper entry cannot be removed", async () => { + clearCredentialsSpy.mockResolvedValueOnce(true); + unregisterHelperSpy.mockResolvedValue(false); + + const program = makeProgram(); + expect( + await rejectionMessage( + program.parseAsync(["node", "test", "login", "logout"]) + ) + ).toContain("exitWith(1)"); + + expect(clearCredentialsSpy).toHaveBeenCalled(); + }); + + test("fails when the git credential helper entry cannot be removed", async () => { + clearCredentialsSpy.mockResolvedValueOnce(true); + unregisterHelperSpy.mockResolvedValue(false); + + const program = makeProgram(); + expect( + program.parseAsync(["node", "test", "login", "logout"]) + ).rejects.toThrow("exitWith(1)"); + }); + test("exits with code 2 when clearCredentials throws (unexpected)", async () => { clearCredentialsSpy.mockRejectedValueOnce(new Error("clear failed")); @@ -195,9 +265,7 @@ describe("login action handlers", () => { ).rejects.toThrow("exitWith(2)"); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(2); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("clear failed"); }); }); @@ -217,14 +285,48 @@ describe("login action handlers", () => { await program.parseAsync(["node", "test", "login"]); // logInfo writes to console.log with "info:" prefix - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("Already logged in"); expect(allOutput).toContain("octocat"); expect(runLoginFlowSpy).not.toHaveBeenCalled(); }); + // A first sign-in can store the session yet fail the git config write, so + // a repeat login re-registers the helper instead of being a no-op. + test("re-registers the git helper for a stored platform session", async () => { + loadCredentialsSpy.mockResolvedValueOnce({ + token: "ey.access", + github_user: "octocat", + }); + loadTokenSetSpy.mockResolvedValueOnce({ + user: "octocat", + tokens: { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }, + }); + + const program = makeProgram(); + await program.parseAsync(["node", "test", "login"]); + + expect(ensureHelperSpy).toHaveBeenCalled(); + }); + + // The helper serves platform sessions only; registering it for a legacy + // token would hide that token from git. + test("leaves the git helper alone for a legacy token", async () => { + loadCredentialsSpy.mockResolvedValueOnce({ + token: "ag_beta_legacy", + github_user: "octocat", + }); + + const program = makeProgram(); + await program.parseAsync(["node", "test", "login"]); + + expect(ensureHelperSpy).not.toHaveBeenCalled(); + }); + test("exits with code 1 when login flow fails", async () => { loadCredentialsSpy.mockResolvedValueOnce(null); runLoginFlowSpy.mockResolvedValueOnce({ ok: false }); @@ -242,15 +344,13 @@ describe("login action handlers", () => { loadCredentialsSpy.mockResolvedValueOnce(null); runLoginFlowSpy.mockResolvedValueOnce({ ok: true, - githubUser: "octocat", + accountName: "octocat", }); const program = makeProgram(); await program.parseAsync(["node", "test", "login"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); // printNextStep prints either "archgate check" or "archgate init" expect(allOutput).toMatch(/archgate (check|init)/u); }); @@ -259,7 +359,7 @@ describe("login action handlers", () => { loadCredentialsSpy.mockResolvedValueOnce(null); runLoginFlowSpy.mockResolvedValueOnce({ ok: true, - githubUser: "octocat", + accountName: "octocat", }); const rootSpy = spyOn(paths, "findProjectRoot").mockReturnValue( "/fake/project" @@ -269,9 +369,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("archgate check"); } finally { rootSpy.mockRestore(); @@ -282,7 +380,7 @@ describe("login action handlers", () => { loadCredentialsSpy.mockResolvedValueOnce(null); runLoginFlowSpy.mockResolvedValueOnce({ ok: true, - githubUser: "octocat", + accountName: "octocat", }); const rootSpy = spyOn(paths, "findProjectRoot").mockReturnValue(null); @@ -290,9 +388,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("archgate init"); expect(allOutput).not.toContain("archgate check"); } finally { @@ -311,10 +407,8 @@ describe("login action handlers", () => { "exitWith(1)" ); - expect(exitWithSpy).toHaveBeenCalledWith(1); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(1); + const allErrors = printed(errorSpy); expect(allErrors).toContain("TLS certificate verification failed"); }); @@ -328,9 +422,7 @@ describe("login action handlers", () => { ); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(2); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("network timeout"); }); }); @@ -340,22 +432,62 @@ describe("login action handlers", () => { // ------------------------------------------------------------------------- describe("refresh", () => { - test("clears credentials then runs login flow", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); - runLoginFlowSpy.mockResolvedValueOnce({ - ok: true, - githubUser: "octocat", + test("unregisters the helper, clears credentials, then runs login flow", async () => { + const order: string[] = []; + unregisterHelperSpy.mockImplementation(async () => { + order.push("unregister"); + return true; + }); + clearCredentialsSpy.mockImplementation(async () => { + order.push("clear"); + return true; + }); + runLoginFlowSpy.mockImplementation(async () => { + order.push("login"); + return { ok: true, accountName: "octocat" }; }); const program = makeProgram(); await program.parseAsync(["node", "test", "login", "refresh"]); - expect(clearCredentialsSpy).toHaveBeenCalled(); - expect(runLoginFlowSpy).toHaveBeenCalled(); + expect(order).toEqual(["unregister", "clear", "login"]); + }); + + // While the helper still answers for the plugins host, clearing cannot + // see a legacy token behind it, so nothing may be touched. + test("stops before clearing when the helper entry cannot be removed", async () => { + unregisterHelperSpy.mockResolvedValue(false); + + const program = makeProgram(); + expect( + await rejectionMessage( + program.parseAsync(["node", "test", "login", "refresh"]) + ) + ).toContain("exitWith(1)"); + + expect(clearCredentialsSpy).not.toHaveBeenCalled(); + expect(runLoginFlowSpy).not.toHaveBeenCalled(); + expect(printed(errorSpy)).toContain( + "git config --global --unset-all credential.https://plugins.archgate.dev.helper" + ); + }); + + // A leftover record would keep answering for the old account. + test("stops when credentials could not be cleared", async () => { + clearCredentialsSpy.mockResolvedValueOnce(false); + + const program = makeProgram(); + expect( + await rejectionMessage( + program.parseAsync(["node", "test", "login", "refresh"]) + ) + ).toContain("exitWith(1)"); + + expect(runLoginFlowSpy).not.toHaveBeenCalled(); }); test("exits with code 1 when refresh login flow fails", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); runLoginFlowSpy.mockResolvedValueOnce({ ok: false }); const program = makeProgram(); @@ -368,7 +500,7 @@ describe("login action handlers", () => { }); test("exits with code 1 and prints TLS hint on TLS error during refresh", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); runLoginFlowSpy.mockRejectedValueOnce( new Error("unable to verify the first certificate") ); @@ -378,15 +510,13 @@ describe("login action handlers", () => { program.parseAsync(["node", "test", "login", "refresh"]) ).rejects.toThrow("exitWith(1)"); - expect(exitWithSpy).toHaveBeenCalledWith(1); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(1); + const allErrors = printed(errorSpy); expect(allErrors).toContain("TLS certificate verification failed"); }); test("exits with code 2 on non-TLS unexpected error during refresh", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); runLoginFlowSpy.mockRejectedValueOnce(new Error("server unreachable")); const program = makeProgram(); @@ -395,9 +525,7 @@ describe("login action handlers", () => { ).rejects.toThrow("exitWith(2)"); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(2); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("server unreachable"); }); }); diff --git a/tests/helpers/auth.test.ts b/tests/helpers/auth.test.ts deleted file mode 100644 index aa89f52d..00000000 --- a/tests/helpers/auth.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { rejectionMessage, restoreEnv } from "../test-utils"; - -/** Type-safe fetch mock — Bun's fetch type includes `preconnect` which mock() doesn't provide. */ -function mockFetch(handler: () => Promise) { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock(handler) as unknown as typeof fetch; -} - -describe("auth", () => { - let tempDir: string; - let originalHome: string | undefined; - let originalUserProfile: string | undefined; - let originalGitConfigNoSystem: string | undefined; - let originalGitConfigGlobal: string | undefined; - let originalFetch: typeof fetch; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-auth-test-")); - originalHome = Bun.env.HOME; - originalUserProfile = Bun.env.USERPROFILE; - originalGitConfigNoSystem = Bun.env.GIT_CONFIG_NOSYSTEM; - originalGitConfigGlobal = Bun.env.GIT_CONFIG_GLOBAL; - Bun.env.HOME = tempDir; - Bun.env.USERPROFILE = tempDir; - // Isolate git credential operations from the system credential store. - Bun.env.GIT_CONFIG_NOSYSTEM = "1"; - const emptyGitConfig = join(tempDir, ".gitconfig"); - writeFileSync(emptyGitConfig, ""); - Bun.env.GIT_CONFIG_GLOBAL = emptyGitConfig; - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - // `Bun.env.X = undefined` assigns the STRING "undefined" and leaves the key - // present — it does not unset. HOME and GIT_CONFIG_GLOBAL are normally unset - // on Windows, and Bun.env is process-global across test files, so a plain - // restore would publish HOME="undefined" to every later test and any - // subprocess it spawns. restoreEnv deletes when the capture was absent. - restoreEnv("HOME", originalHome); - restoreEnv("USERPROFILE", originalUserProfile); - restoreEnv("GIT_CONFIG_NOSYSTEM", originalGitConfigNoSystem); - restoreEnv("GIT_CONFIG_GLOBAL", originalGitConfigGlobal); - rmSync(tempDir, { recursive: true, force: true }); - globalThis.fetch = originalFetch; - }); - - describe("saveCredentials / loadCredentials", () => { - test("does not write any file to disk", async () => { - const { saveCredentials } = - await import("../../src/helpers/credential-store"); - - await saveCredentials({ - token: "ag_beta_abc123", - github_user: "testuser", - }); - - // No credentials file should exist — everything is in git credential manager. - const credPath = join(tempDir, ".archgate", "credentials"); - expect(await Bun.file(credPath).exists()).toBe(false); - }); - - test("returns null when no credentials exist anywhere", async () => { - const { loadCredentials } = - await import("../../src/helpers/credential-store"); - - const result = await loadCredentials(); - expect(result).toBeNull(); - }); - - test("returns null and deletes legacy credentials file", async () => { - const { loadCredentials } = - await import("../../src/helpers/credential-store"); - - const credPath = join(tempDir, ".archgate", "credentials"); - const { mkdirSync } = await import("node:fs"); - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); - await Bun.write( - credPath, - JSON.stringify({ token: "abc", github_user: "old" }) - ); - - const result = await loadCredentials(); - expect(result).toBeNull(); - expect(await Bun.file(credPath).exists()).toBe(false); - }); - }); - - describe("clearCredentials", () => { - test("clears git creds and removes legacy file", async () => { - const { clearCredentials } = - await import("../../src/helpers/credential-store"); - - // Create a legacy file - const { mkdirSync } = await import("node:fs"); - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); - const credPath = join(tempDir, ".archgate", "credentials"); - await Bun.write(credPath, "{}"); - - await clearCredentials(); - - expect(await Bun.file(credPath).exists()).toBe(false); - }); - - test("does not throw when no credentials exist", async () => { - const { clearCredentials } = - await import("../../src/helpers/credential-store"); - - expect(clearCredentials()).resolves.toBeUndefined(); - }); - }); - - describe("requestDeviceCode", () => { - test("sends POST to GitHub device code endpoint", async () => { - const { requestDeviceCode } = await import("../../src/helpers/auth"); - - mockFetch(async () => - Response.json({ - device_code: "dc_123", - user_code: "ABCD-1234", - verification_uri: "https://github.com/login/device", - expires_in: 900, - interval: 5, - }) - ); - - const result = await requestDeviceCode(); - expect(result.device_code).toBe("dc_123"); - expect(result.user_code).toBe("ABCD-1234"); - expect(result.verification_uri).toBe("https://github.com/login/device"); - expect(result.interval).toBe(5); - }); - - test("throws on non-200 response", async () => { - const { requestDeviceCode } = await import("../../src/helpers/auth"); - - mockFetch(async () => new Response("Bad Request", { status: 400 })); - - expect(requestDeviceCode()).rejects.toThrow("HTTP 400"); - }); - }); - - describe("getGitHubUser", () => { - test("returns login from GitHub API", async () => { - const { getGitHubUser } = await import("../../src/helpers/auth"); - - mockFetch(async () => - Response.json({ login: "octocat", email: "octo@cat.com" }) - ); - - const user = await getGitHubUser("gho_test_token"); - expect(user.login).toBe("octocat"); - expect(user.email).toBe("octo@cat.com"); - }); - - test("throws when GitHub API returns non-200", async () => { - const { getGitHubUser } = await import("../../src/helpers/auth"); - - mockFetch(async () => new Response("Unauthorized", { status: 401 })); - - expect(getGitHubUser("bad_token")).rejects.toThrow("HTTP 401"); - }); - - test("throws when login missing from response", async () => { - const { getGitHubUser } = await import("../../src/helpers/auth"); - - mockFetch(async () => Response.json({ email: "octo@cat.com" })); - - expect(getGitHubUser("gho_test_token")).rejects.toThrow( - "GitHub API did not return a username" - ); - }); - }); - - describe("claimArchgateToken", () => { - test("returns token from plugins service", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); - - mockFetch(async () => Response.json({ token: "ag_beta_claimed_token" })); - - const token = await claimArchgateToken("gho_github_token"); - expect(token).toBe("ag_beta_claimed_token"); - }); - - test("throws SignupRequiredError on 403 with no approved signup", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); - const { SignupRequiredError } = await import("../../src/helpers/signup"); - - mockFetch(async () => - Response.json( - { error: "No approved signup found for this GitHub account" }, - { status: 403 } - ) - ); - - expect(claimArchgateToken("gho_token")).rejects.toBeInstanceOf( - SignupRequiredError - ); - }); - - test("throws generic error on non-signup 403", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); - - mockFetch(async () => - Response.json({ error: "Account suspended" }, { status: 403 }) - ); - - expect(claimArchgateToken("gho_token")).rejects.toThrow( - "Account suspended" - ); - }); - - test("throws when token missing from successful response", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); - - mockFetch(async () => Response.json({})); - - expect(claimArchgateToken("gho_token")).rejects.toThrow( - "Plugins service did not return a token" - ); - }); - }); - - describe("pollForAccessToken", () => { - let originalSleep: typeof Bun.sleep; - - beforeEach(() => { - originalSleep = Bun.sleep; - }); - - afterEach(() => { - Bun.sleep = originalSleep; - }); - - test("returns token after authorization_pending then success", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - let callCount = 0; - // Deliberately incomplete fake: only the call signature fetch - // actually invokes matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock(async () => { - callCount++; - if (callCount === 1) { - return Response.json({ error: "authorization_pending" }); - } - return Response.json({ - access_token: "gho_polled_token", - token_type: "bearer", - scope: "read:user", - }); - }) as unknown as typeof fetch; - - const token = await pollForAccessToken("dc_abc", 0, 60); - expect(token).toBe("gho_polled_token"); - expect(callCount).toBe(2); - }); - - test("handles slow_down by increasing poll interval", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - const sleepArgs: number[] = []; - Bun.sleep = mock(async (ms: number | Date) => { - sleepArgs.push(typeof ms === "number" ? ms : ms.getTime()); - }); - - let callCount = 0; - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock(async () => { - callCount++; - if (callCount === 1) { - return Response.json({ error: "slow_down" }); - } - return Response.json({ - access_token: "gho_after_slow_down", - token_type: "bearer", - scope: "read:user", - }); - }) as unknown as typeof fetch; - - const token = await pollForAccessToken("dc_abc", 0, 60); - expect(token).toBe("gho_after_slow_down"); - // After slow_down, interval increases by 5; second sleep should be 5*1000 - expect(sleepArgs[1]).toBe(5 * 1000); - }); - - test("throws on expired_token", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - mockFetch(async () => - Response.json({ - error: "expired_token", - error_description: "The device code has expired.", - }) - ); - - expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( - "The device code has expired." - ); - }); - - test("throws on access_denied", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - mockFetch(async () => - Response.json({ - error: "access_denied", - error_description: "The user denied your request.", - }) - ); - - expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( - "The user denied your request." - ); - }); - - test("throws when deadline expires before authorization", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - mockFetch(async () => Response.json({ error: "authorization_pending" })); - - expect(pollForAccessToken("dc_abc", 0, 0)).rejects.toThrow( - "Device code expired. Please try again." - ); - }); - - test("throws on a non-OK HTTP status without parsing the body", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - // A gateway error carries no OAuth error code, so the status is all the - // message can report. - mockFetch(async () => new Response("upstream failure", { status: 502 })); - - expect(await rejectionMessage(pollForAccessToken("dc_abc", 0, 60))).toBe( - "GitHub token poll failed (HTTP 502)" - ); - }); - }); -}); diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts new file mode 100644 index 00000000..f2362e6b --- /dev/null +++ b/tests/helpers/credential-store-renewal.test.ts @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, spyOn, test } from "bun:test"; + +import * as credentialStore from "../../src/helpers/credential-store"; +import { + clearCredentials, + invalidateAccessToken, + loadCredentials, + resolveAccessToken, +} from "../../src/helpers/credential-store"; +import * as logMod from "../../src/helpers/log"; +import { platformAuth } from "../../src/helpers/platform-auth"; +import type { TokenSet } from "../../src/helpers/platform-auth"; +import { SessionExpiredError } from "../../src/helpers/session-expired-error"; +import { UserError } from "../../src/helpers/user-error"; +import { rejectionMessage } from "../test-utils"; + +/** A `Bun.spawn` stand-in that answers `git credential fill` with one record. */ +function gitCredentialStub(username: string, password: string) { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return { + stdout: new Response(`username=${username}\npassword=${password}\n`).body, + exited: Promise.resolve(0), + kill: () => { + // Nothing to kill: the stub has already settled. + }, + } as unknown as ReturnType; +} + +/** The record `saveTokenSet` files: one fixed account, the session as JSON. */ +function sessionRecord( + user: string, + tokens: TokenSet +): ReturnType { + return gitCredentialStub("archgate", JSON.stringify({ user, tokens })); +} + +describe("credential renewal", () => { + describe("invalidateAccessToken", () => { + const tokens = { + accessToken: "ey.rejected", + refreshToken: "refresh-keepme", + expiresAt: Date.now() + 3_600_000, + }; + + // The refresh token must survive: git erases on any rejection, and losing + // it would turn a recoverable 401 into a full device-flow login. + test("expires the access token but keeps the refresh token", async () => { + // Calls run load-fill, approve, verify-fill; the verification must see + // the blob that was just approved. + let call = 0; + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + call += 1; + return sessionRecord( + "octocat", + call >= 3 ? { ...tokens, expiresAt: 0 } : tokens + ); + }); + try { + expect(await invalidateAccessToken()).toBe(true); + + const approved = spawnSpy.mock.calls + .filter((call) => [...call[0]].includes("approve")) + .map((call) => (call[1] as { stdin?: unknown } | undefined)?.stdin) + .filter((stdin): stdin is Blob => stdin instanceof Blob); + const written = ( + await Promise.all(approved.map(async (b) => b.text())) + ).join(""); + expect(written).toContain("refresh-keepme"); + expect(written).toContain('"expiresAt":0'); + } finally { + spawnSpy.mockRestore(); + } + }); + + test("writes nothing when no token set is stored", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("", "") + ); + try { + expect(await invalidateAccessToken()).toBe(false); + + const approvals = spawnSpy.mock.calls.filter((call) => + [...call[0]].includes("approve") + ); + expect(approvals).toHaveLength(0); + } finally { + spawnSpy.mockRestore(); + } + }); + + // A store that cannot take the lapsed token set would hand git the same + // rejected access token on the next lookup, so the failure is reported. + test("reports when the lapsed token set cannot be stored", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", tokens) + ); + const saveSpy = spyOn(credentialStore, "saveTokenSet").mockResolvedValue( + false + ); + try { + expect(await invalidateAccessToken()).toBe(false); + } finally { + saveSpy.mockRestore(); + spawnSpy.mockRestore(); + } + }); + }); + + // A rotated refresh token that cannot be written leaves the stored one dead, + // so the failure has to be surfaced rather than swallowed. + test("warns when renewed credentials cannot be persisted", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", stale) + ); + const saveSpy = spyOn(credentialStore, "saveTokenSet").mockResolvedValue( + false + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockResolvedValue({ + accessToken: "ey.fresh", + refreshToken: "refresh-new", + expiresAt: Date.now() + 3_600_000, + }); + const warnSpy = spyOn(logMod, "logWarn").mockImplementation(() => { + // Captured, not printed. + }); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fresh", + github_user: "octocat", + }); + expect(warnSpy.mock.calls.flat().join(" ")).toContain("archgate login"); + } finally { + warnSpy.mockRestore(); + refreshSpy.mockRestore(); + saveSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); +}); + +// A refused refresh token is a signed-out state, and it ends the lookup: the +// legacy store is not consulted for a user who has signed in to the platform. +describe("loadCredentials with an unrenewable session", () => { + test("reports signed out without consulting the legacy store", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", stale) + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new SessionExpiredError()); + try { + expect(await loadCredentials()).toBeNull(); + + const hosts = await Promise.all( + fillSpy.mock.calls + .filter((call) => [...call[0]].includes("fill")) + .map(async (call) => { + const stdin = (call[1] as { stdin?: unknown } | undefined)?.stdin; + return stdin instanceof Blob ? stdin.text() : ""; + }) + ); + expect(hosts.join("")).not.toContain("plugins.archgate.dev"); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + // The service being down must not read as "not logged in". + test("propagates a renewal failure that is not a sign-out", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", stale) + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new UserError("Could not reach the sign-in service.")); + try { + expect(await rejectionMessage(loadCredentials())).toContain( + "Could not reach" + ); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + test("propagates a fault that is not a signed-out state", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", stale) + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new TypeError("boom")); + try { + expect(await rejectionMessage(loadCredentials())).toContain("boom"); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); +}); + +// A helper that never answers leaves the host's records unknown, so logout +// must not report them as removed. +describe("clearCredentials with an unresponsive helper", () => { + test("reports failure when the fill times out", async () => { + let call = 0; + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + call += 1; + if (call > 1) return gitCredentialStub("", ""); + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return { + stdout: new ReadableStream({ start() {} }), + exited: new Promise(() => {}), + kill: () => {}, + } as unknown as ReturnType; + }); + try { + expect(await clearCredentials()).toBe(false); + } finally { + spawnSpy.mockRestore(); + } + }, 20_000); +}); + +// Git runs the helper many times per operation, so processes can race to +// refresh the same expired token set. The platform rotates the refresh +// token, so every exchange after the first fails. +describe("concurrent renewal", () => { + const expired = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const rotated = { + accessToken: "ey.fromWinner", + refreshToken: "refresh-rotated", + expiresAt: Date.now() + 3_600_000, + }; + + test("uses the token another process already saved", async () => { + let read = 0; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { + read += 1; + return sessionRecord("octocat", read === 1 ? expired : rotated); + }); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new SessionExpiredError()); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fromWinner", + github_user: "octocat", + }); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + // The winner's write lands after its own exchange, so the loser may have + // to look more than once before the rotated record is there. + test("waits for a winner whose write lands late", async () => { + let read = 0; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { + read += 1; + return sessionRecord("octocat", read >= 3 ? rotated : expired); + }); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new SessionExpiredError()); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fromWinner", + github_user: "octocat", + }); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + test("rethrows when the stored token set did not change", async () => { + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", expired) + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new SessionExpiredError()); + try { + expect(await rejectionMessage(resolveAccessToken())).toContain( + "session has expired" + ); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); +}); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index 993cc26c..1c025d6d 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -1,16 +1,48 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { - saveCredentials, + saveTokenSet, loadCredentials, + loadTokenSet, + resolveAccessToken, clearCredentials, } from "../../src/helpers/credential-store"; -import { restoreEnv } from "../test-utils"; +import { platformAuth } from "../../src/helpers/platform-auth"; +import type { TokenSet } from "../../src/helpers/platform-auth"; +import { restoreEnv, safeRmSync } from "../test-utils"; + +/** + * A `Bun.spawn` stand-in that answers `git credential fill` with one record. + * + * @param username - Value returned in the `username` field. + * @param password - Value returned in the `password` field. + */ +function gitCredentialStub( + username: string, + password: string +): ReturnType { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return { + stdout: new Response(`username=${username}\npassword=${password}\n`).body, + exited: Promise.resolve(0), + kill: () => { + // Nothing to kill: the stub has already settled. + }, + } as unknown as ReturnType; +} + +/** The record `saveTokenSet` files: one fixed account, the session as JSON. */ +function sessionRecord( + user: string, + tokens: TokenSet +): ReturnType { + return gitCredentialStub("archgate", JSON.stringify({ user, tokens })); +} describe("credential-store", () => { let tempDir: string; @@ -39,18 +71,15 @@ describe("credential-store", () => { restoreEnv("HOME", originalHome); restoreEnv("GIT_CONFIG_NOSYSTEM", originalGitConfigNoSystem); restoreEnv("GIT_CONFIG_GLOBAL", originalGitConfigGlobal); - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - /* temp dir cleanup best-effort */ - } + safeRmSync(tempDir); }); - describe("saveCredentials", () => { + describe("saveTokenSet", () => { test("does not write any metadata file to disk", async () => { - await saveCredentials({ - token: "ag_beta_abc123", - github_user: "testuser", + await saveTokenSet("testuser", { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, }); // No credentials file should be written — everything is in git credential manager. @@ -71,9 +100,10 @@ describe("credential-store", () => { JSON.stringify({ github_user: "old", created_at: "2025-01-01" }) ); - await saveCredentials({ - token: "ag_beta_abc123", - github_user: "testuser", + await saveTokenSet("testuser", { + accessToken: "ag_beta_abc123", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, }); expect(await Bun.file(credPath).exists()).toBe(false); @@ -89,9 +119,10 @@ describe("credential-store", () => { // fill returns nothing — triggers the verification warning path. const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); try { - await saveCredentials({ - token: "ag_beta_test", - github_user: "testuser", + await saveTokenSet("testuser", { + accessToken: "ag_beta_test", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, }); // The warning is printed because fill cannot verify the stored token. @@ -138,8 +169,29 @@ describe("credential-store", () => { }); describe("clearCredentials", () => { + // A record the helper refuses to drop must not let logout report success. + test("reports failure when git cannot reject a record", async () => { + let call = 0; + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + call += 1; + // Odd calls are fills answering a record; even calls are rejects. + const stub = gitCredentialStub("octocat", "ey.access"); + return call % 2 === 0 + ? // oxlint-disable-next-line typescript/no-unsafe-type-assertion + ({ ...stub, exited: Promise.resolve(1) } as unknown as ReturnType< + typeof Bun.spawn + >) + : stub; + }); + try { + expect(await clearCredentials()).toBe(false); + } finally { + spawnSpy.mockRestore(); + } + }); + test("does not throw when no credentials exist", async () => { - expect(clearCredentials()).resolves.toBeUndefined(); + expect(await clearCredentials()).toBe(true); }); test("cleans up legacy metadata file", async () => { @@ -169,6 +221,59 @@ describe("credential-store", () => { }); }); + // `git credential approve` exits 0 even when nothing was stored, so the + // read-back must return this very blob for this account. + describe("saveTokenSet verification", () => { + const tokens = { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: 1_800_000_000_000, + }; + + test("asks for the account it wrote and rejects a different blob", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", "stale-blob") + ); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(await saveTokenSet("octocat", tokens)).toBe(false); + + const fill = spawnSpy.mock.calls.find((call) => + [...call[0]].includes("fill") + ); + const stdin = (fill?.[1] as { stdin?: unknown } | undefined)?.stdin; + expect(stdin instanceof Blob ? await stdin.text() : "").toContain( + "username=archgate" + ); + expect(warnSpy.mock.calls.flat().join(" ")).toContain( + "could not be verified" + ); + } finally { + warnSpy.mockRestore(); + spawnSpy.mockRestore(); + } + }); + + test("accepts the read-back when it matches", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", tokens) + ); + try { + expect(await saveTokenSet("octocat", tokens)).toBe(true); + const approve = spawnSpy.mock.calls.find((call) => + [...call[0]].includes("approve") + ); + const stdin = (approve?.[1] as { stdin?: unknown } | undefined)?.stdin; + // One fixed account; the display name travels inside the payload. + expect(stdin instanceof Blob ? await stdin.text() : "").toContain( + "username=archgate" + ); + } finally { + spawnSpy.mockRestore(); + } + }); + }); + describe("credential fill with store helper", () => { test("round-trips credentials through a file-based credential helper", async () => { // A store-based helper persists to a plain file, exercising the @@ -185,9 +290,10 @@ describe("credential-store", () => { const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); try { - await saveCredentials({ - token: "ag_beta_roundtrip", - github_user: "rounduser", + await saveTokenSet("rounduser", { + accessToken: "ag_beta_roundtrip", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, }); // With a working helper, verification succeeds — no warning about @@ -272,7 +378,11 @@ describe("credential-store", () => { }); const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); try { - await saveCredentials({ token: "ag_beta_x", github_user: "u" }); + await saveTokenSet("u", { + accessToken: "ag_beta_x", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }); expect(warnSpy.mock.calls.flat().join(" ")).toContain( "git credential approve failed." @@ -284,11 +394,88 @@ describe("credential-store", () => { }); }); - describe("StoredCredentials type", () => { - test("interface has expected shape", () => { - expect(typeof saveCredentials).toBe("function"); - expect(typeof loadCredentials).toBe("function"); - expect(typeof clearCredentials).toBe("function"); + describe("loadTokenSet", () => { + test.each([ + ["not JSON at all", "not-json"], + ["JSON without token fields", JSON.stringify({ a: 1 })], + ])("returns null when the stored blob is %s", async (_label, stored) => { + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("archgate", stored) + ); + try { + expect(await loadTokenSet()).toBeNull(); + } finally { + fillSpy.mockRestore(); + } + }); + + test("returns the stored token set and its user", async () => { + const tokens = { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", tokens) + ); + try { + expect(await loadTokenSet()).toEqual({ user: "octocat", tokens }); + } finally { + fillSpy.mockRestore(); + } + }); + }); + + describe("resolveAccessToken", () => { + test("returns the cached token while it is still valid", async () => { + const tokens = { + accessToken: "ey.valid", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", tokens) + ); + const refreshSpy = spyOn(platformAuth, "refreshAccessToken"); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.valid", + github_user: "octocat", + }); + expect(refreshSpy).not.toHaveBeenCalled(); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + test("renews an expired token and hands back the new one", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", stale) + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockResolvedValue({ + accessToken: "ey.fresh", + refreshToken: "refresh-new", + expiresAt: Date.now() + 3_600_000, + }); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fresh", + github_user: "octocat", + }); + expect(refreshSpy).toHaveBeenCalledWith("refresh-old"); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } }); }); }); diff --git a/tests/helpers/desktop.test.ts b/tests/helpers/desktop.test.ts new file mode 100644 index 00000000..12d0b949 --- /dev/null +++ b/tests/helpers/desktop.test.ts @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { + afterEach, + beforeEach, + describe, + expect, + mock, + type Mock, + spyOn, + test, +} from "bun:test"; + +import { + copyToClipboard, + isHeadless, + openBrowser, +} from "../../src/helpers/desktop"; +import * as platform from "../../src/helpers/platform"; +import { restoreEnv } from "../test-utils"; + +let spawnSpy: Mock; +let platformSpies: Mock<() => boolean>[]; + +/** Env vars isHeadless reads, saved and restored around every test. */ +const ENV_KEYS = [ + "CI", + "SSH_CONNECTION", + "SSH_TTY", + "DISPLAY", + "WAYLAND_DISPLAY", +]; +let originalEnv: Record; + +/** Pin the platform predicates so a test does not depend on the host. */ +function onPlatform(which: "macos" | "windows" | "wsl" | "linux"): void { + platformSpies = [ + spyOn(platform, "isMacOS").mockReturnValue(which === "macos"), + spyOn(platform, "isWindows").mockReturnValue(which === "windows"), + spyOn(platform, "isWSL").mockReturnValue(which === "wsl"), + spyOn(platform, "isLinux").mockReturnValue(which === "linux"), + ]; +} + +/** Stub Bun.spawn so the nth attempt exits with the given code. */ +function spawnExits(...codes: number[]): void { + // Replacing an existing spy would nest one mock inside another. + spawnSpy.mockRestore(); + let call = 0; + spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + const code = codes[call] ?? 1; + call += 1; + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return { + exited: Promise.resolve(code), + kill: () => { + // Nothing to kill: the stub has already settled. + }, + } as unknown as ReturnType; + }); +} + +/** Argv of each recorded spawn, in call order. */ +function captured(): string[][] { + return spawnSpy.mock.calls.map((call) => [...call[0]]); +} + +/** The `stdin` option passed to the nth spawn. */ +function stdinOf(index: number): unknown { + return (spawnSpy.mock.calls[index]?.[1] as { stdin?: unknown } | undefined) + ?.stdin; +} + +beforeEach(() => { + platformSpies = []; + // Installed unconditionally so no test can reach the real Bun.spawn, and so + // afterEach always has a spy to restore. + spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("unexpected spawn"); + }); + originalEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, Bun.env[k]])); + for (const key of ENV_KEYS) delete Bun.env[key]; + // A desktop Linux session by default; individual tests override. + Bun.env.DISPLAY = ":0"; + onPlatform("linux"); +}); + +afterEach(() => { + mock.restore(); + for (const key of ENV_KEYS) restoreEnv(key, originalEnv[key]); +}); + +describe("isHeadless", () => { + test.each(["CI", "SSH_CONNECTION", "SSH_TTY"])( + "treats %s as headless", + (key) => { + Bun.env[key] = "1"; + + expect(isHeadless()).toBe(true); + } + ); + + // An empty value is how a shell reports "unset" for an exported-but-blank var. + test("ignores an empty value", () => { + Bun.env.CI = ""; + + expect(isHeadless()).toBe(false); + }); + + test.each([ + ["DISPLAY", ":0"], + ["WAYLAND_DISPLAY", "wayland-0"], + ])("a Linux session with %s is not headless", (key, value) => { + delete Bun.env.DISPLAY; + Bun.env[key] = value; + + expect(isHeadless()).toBe(false); + }); + + test("Linux without any display server is headless", () => { + delete Bun.env.DISPLAY; + + expect(isHeadless()).toBe(true); + }); + + // WSL has no display server of its own but reaches the Windows desktop. + test("WSL is not headless despite having no display", () => { + delete Bun.env.DISPLAY; + for (const spy of platformSpies) spy.mockRestore(); + onPlatform("wsl"); + + expect(isHeadless()).toBe(false); + }); + + test.each(["macos", "windows"] as const)("%s is not headless", (which) => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform(which); + + expect(isHeadless()).toBe(false); + }); +}); + +describe("openBrowser", () => { + test.each([ + ["macos", ["open", "https://auth.archgate.dev/device"]], + ["linux", ["xdg-open", "https://auth.archgate.dev/device"]], + ["wsl", ["wslview", "https://auth.archgate.dev/device"]], + ] as const)("uses the %s launcher", async (which, expected) => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform(which); + spawnExits(0); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(true); + expect(captured()[0]).toEqual([...expected]); + }); + + // `cmd /c start` and PowerShell parse `?` and `&` out of a URL, so the + // complete verification URL would be truncated before the browser saw it. + test("hands Windows the URL without a shell in between", async () => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform("windows"); + spawnExits(0); + + await openBrowser( + "https://auth.archgate.dev/device?user_code=HZML-HXLB&x=1" + ); + + expect(captured()[0]).toEqual([ + "rundll32", + "url.dll,FileProtocolHandler", + "https://auth.archgate.dev/device?user_code=HZML-HXLB&x=1", + ]); + }); + + test("falls back to rundll32 on WSL without wslview", async () => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform("wsl"); + spawnExits(1, 0); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(true); + expect(captured()[1]?.[0]).toBe("rundll32.exe"); + }); + + test("falls through to the next launcher when the first is missing", async () => { + spawnExits(1, 0); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(true); + expect(captured()).toHaveLength(2); + expect(captured()[1]?.[0]).toBe("gio"); + }); + + test("returns false when no launcher works", async () => { + spawnExits(1, 1); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(false); + }); + + test("spawns nothing when headless", async () => { + Bun.env.CI = "1"; + spawnExits(0); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(false); + expect(captured()).toHaveLength(0); + }); + + // A launcher that never exits must not hang login behind it. + test("gives up on a launcher that never exits", async () => { + let killed = false; + spawnSpy.mockRestore(); + spawnSpy = spyOn(Bun, "spawn").mockImplementation( + () => + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + ({ + exited: new Promise(() => { + // never settles + }), + kill: () => { + killed = true; + }, + }) as unknown as ReturnType + ); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(false); + expect(killed).toBe(true); + }, 20_000); + + test("returns false when spawning throws", async () => { + // The default stub from beforeEach already throws. + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(false); + }); +}); + +describe("copyToClipboard", () => { + test.each([ + ["macos", "pbcopy"], + ["linux", "wl-copy"], + ["wsl", "clip.exe"], + ["windows", "clip.exe"], + ] as const)("uses the %s clipboard tool", async (which, expected) => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform(which); + spawnExits(0); + + expect(await copyToClipboard("HZML-HXLB")).toBe(true); + expect(captured()[0]?.[0]).toBe(expected); + }); + + test("writes the text on standard input, never as an argument", async () => { + spawnExits(0); + + await copyToClipboard("HZML-HXLB"); + + expect(captured()[0]).not.toContain("HZML-HXLB"); + const stdin = stdinOf(0); + expect(stdin).toBeInstanceOf(Blob); + expect(stdin instanceof Blob ? await stdin.text() : null).toBe("HZML-HXLB"); + }); + + test("falls through the Linux clipboard tools in order", async () => { + spawnExits(1, 1, 0); + + expect(await copyToClipboard("HZML-HXLB")).toBe(true); + expect(captured().map((c) => c[0])).toEqual(["wl-copy", "xclip", "xsel"]); + }); + + test("returns false when headless", async () => { + delete Bun.env.DISPLAY; + spawnExits(0); + + expect(await copyToClipboard("HZML-HXLB")).toBe(false); + expect(captured()).toHaveLength(0); + }); + + test("returns false when every clipboard tool is missing", async () => { + spawnExits(1, 1, 1); + + expect(await copyToClipboard("HZML-HXLB")).toBe(false); + expect(captured()).toHaveLength(3); + }); +}); diff --git a/tests/helpers/git-credential-config.test.ts b/tests/helpers/git-credential-config.test.ts new file mode 100644 index 00000000..41563a12 --- /dev/null +++ b/tests/helpers/git-credential-config.test.ts @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + ensureGitCredentialHelper, + helperCommand, + quoteForGitShell, + registerGitCredentialHelper, + unregisterGitCredentialHelper, +} from "../../src/helpers/git-credential-config"; +import * as logMod from "../../src/helpers/log"; +import { restoreEnv, safeRmSync } from "../test-utils"; + +const HELPER_KEY = "credential.https://plugins.archgate.dev.helper"; + +let tempDir: string; +let gitConfigPath: string; +let originalNoSystem: string | undefined; +let originalSystem: string | undefined; +let originalGlobal: string | undefined; + +/** + * Read the helper entries git has recorded, in order. + * + * Both piped streams are drained concurrently so neither can fill and block + * git, and the exit code is checked before the output is trusted (ARCH-007). + * Exit code 1 means the key is unset, which is an empty list rather than a + * failure. + * + * @param scope - `global` reads the global file alone; `merged` reads every + * scope in the order git consults them. + */ +async function configuredHelpers( + scope: "global" | "merged" = "global" +): Promise { + const proc = Bun.spawn( + [ + "git", + "config", + ...(scope === "global" ? ["--global"] : []), + "--get-all", + HELPER_KEY, + ], + { stdout: "pipe", stderr: "pipe", env: { ...Bun.env } } + ); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode === 1) return []; + if (exitCode !== 0) { + throw new Error(`git config exited ${exitCode}: ${stderr.trim()}`); + } + return stdout.split("\n").slice(0, -1); +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-gitcred-test-")); + originalNoSystem = Bun.env.GIT_CONFIG_NOSYSTEM; + originalSystem = Bun.env.GIT_CONFIG_SYSTEM; + originalGlobal = Bun.env.GIT_CONFIG_GLOBAL; + gitConfigPath = join(tempDir, ".gitconfig"); + writeFileSync(gitConfigPath, ""); + Bun.env.GIT_CONFIG_NOSYSTEM = "1"; + Bun.env.GIT_CONFIG_GLOBAL = gitConfigPath; +}); + +afterEach(() => { + mock.restore(); + restoreEnv("GIT_CONFIG_NOSYSTEM", originalNoSystem); + restoreEnv("GIT_CONFIG_SYSTEM", originalSystem); + restoreEnv("GIT_CONFIG_GLOBAL", originalGlobal); + safeRmSync(tempDir); +}); + +// Git started by an editor rarely inherits the shell PATH, so the helper +// must name the executable by absolute path rather than as `archgate`. +describe("helperCommand", () => { + test("invokes this executable by absolute path", () => { + const command = helperCommand(); + + expect(command.startsWith("!")).toBe(true); + expect(command.endsWith(" credential")).toBe(true); + expect(command).toContain( + quoteForGitShell(process.execPath).replaceAll(/^'|'$/gu, "") + ); + }); +}); + +describe("quoteForGitShell", () => { + test.each([ + ["a plain path", "/usr/local/bin/archgate", "/usr/local/bin/archgate"], + [ + "a path with spaces", + "/Users/Octo Cat/bin/archgate", + "'/Users/Octo Cat/bin/archgate'", + ], + [ + "a Windows path", + "C:\\Users\\octo\\.archgate\\bin\\archgate.exe", + "C:/Users/octo/.archgate/bin/archgate.exe", + ], + ["an embedded quote", "/tmp/o'neil/archgate", "'/tmp/o'\\''neil/archgate'"], + ])("handles %s", (_label, input, expected) => { + expect(quoteForGitShell(input)).toBe(expected); + }); +}); + +describe("registerGitCredentialHelper", () => { + test("records the helper for the plugins host", async () => { + expect(await registerGitCredentialHelper()).toBe(true); + + expect(await configuredHelpers()).toEqual(["", helperCommand()]); + }); + + test("replaces an entry already in the global scope", async () => { + writeFileSync( + gitConfigPath, + `[credential "https://plugins.archgate.dev"]\n\thelper = store\n` + ); + + await registerGitCredentialHelper(); + + expect(await configuredHelpers()).toEqual(["", helperCommand()]); + }); + + // Git accumulates helpers across scopes and an empty entry discards the + // list so far, so the system helper must sit before the reset for archgate + // to be the only helper consulted for this host. + test("writes the reset entry after helpers inherited from the system scope", async () => { + const systemConfigPath = join(tempDir, "system.gitconfig"); + writeFileSync( + systemConfigPath, + `[credential "https://plugins.archgate.dev"]\n\thelper = store\n` + ); + delete Bun.env.GIT_CONFIG_NOSYSTEM; + Bun.env.GIT_CONFIG_SYSTEM = systemConfigPath; + + await registerGitCredentialHelper(); + + expect(await configuredHelpers("merged")).toEqual([ + "store", + "", + helperCommand(), + ]); + }); + + test("is idempotent across repeated logins", async () => { + await registerGitCredentialHelper(); + await registerGitCredentialHelper(); + + expect(await configuredHelpers()).toEqual(["", helperCommand()]); + }); + + test("scopes the entry to the plugins host alone", async () => { + await registerGitCredentialHelper(); + + const config = readFileSync(gitConfigPath, "utf8"); + expect(config).toContain('[credential "https://plugins.archgate.dev"]'); + }); + + test("reports failure when git cannot write the config", async () => { + // Pointing the config at a directory leaves git with nothing it can write. + Bun.env.GIT_CONFIG_GLOBAL = tempDir; + + expect(await registerGitCredentialHelper()).toBe(false); + }); + + // Login must degrade to a warning, not an internal fault, without git. + test("reports failure when git cannot be started", async () => { + spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("spawn git ENOENT"); + }); + + expect(await registerGitCredentialHelper()).toBe(false); + }); +}); + +// Downloads use the access token directly, so a failed git config write must +// not fail the login — only clones lose their non-interactive credentials. +describe("ensureGitCredentialHelper", () => { + test("stays quiet when the helper is registered", async () => { + const warnSpy = spyOn(logMod, "logWarn").mockImplementation(() => {}); + + await ensureGitCredentialHelper(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(await configuredHelpers()).toEqual(["", helperCommand()]); + }); + + test("warns instead of failing when git cannot write the config", async () => { + Bun.env.GIT_CONFIG_GLOBAL = tempDir; + const warnSpy = spyOn(logMod, "logWarn").mockImplementation(() => {}); + + await ensureGitCredentialHelper(); + + expect(warnSpy.mock.calls.flat().join(" ")).toContain( + "Could not register archgate" + ); + }); +}); + +describe("unregisterGitCredentialHelper", () => { + test("removes the entries login added", async () => { + await registerGitCredentialHelper(); + + expect(await unregisterGitCredentialHelper()).toBe(true); + expect(await configuredHelpers()).toEqual([]); + }); + + // Exit code 5 means the key was already absent, which is the desired state. + test("succeeds when no helper is configured", async () => { + expect(await unregisterGitCredentialHelper()).toBe(true); + }); + + test("reports failure when git cannot be started", async () => { + spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("spawn git ENOENT"); + }); + + expect(await unregisterGitCredentialHelper()).toBe(false); + }); +}); diff --git a/tests/helpers/git-credential-protocol.test.ts b/tests/helpers/git-credential-protocol.test.ts new file mode 100644 index 00000000..b475fed5 --- /dev/null +++ b/tests/helpers/git-credential-protocol.test.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test } from "bun:test"; + +import { + formatCredentialResponse, + parseCredentialRequest, +} from "../../src/helpers/git-credential-protocol"; + +describe("parseCredentialRequest", () => { + test("reads the key=value block git writes", () => { + const request = parseCredentialRequest( + "protocol=https\nhost=plugins.archgate.dev\npath=archgate.git\n\n" + ); + + expect(request).toEqual({ + protocol: "https", + host: "plugins.archgate.dev", + path: "archgate.git", + }); + }); + + test("stops at the first blank line", () => { + const request = parseCredentialRequest( + "host=plugins.archgate.dev\n\nhost=evil.example.com\n" + ); + + expect(request.host).toBe("plugins.archgate.dev"); + }); + + test("keeps '=' inside a value", () => { + const request = parseCredentialRequest("password=a=b=c\n"); + + expect(request.password).toBe("a=b=c"); + }); + + test.each(["", "\n", "novalue\n", "=leading\n"])( + "yields no usable pairs for %p", + (input) => { + expect(parseCredentialRequest(input)).toEqual({}); + } + ); +}); + +describe("formatCredentialResponse", () => { + test("emits one newline-terminated pair per field", () => { + const output = formatCredentialResponse({ + protocol: "https", + host: "plugins.archgate.dev", + username: "octocat", + password: "ey.token", + }); + + // The trailing blank line is the protocol's terminator. + expect(output).toBe( + "protocol=https\nhost=plugins.archgate.dev\nusername=octocat\npassword=ey.token\n\n" + ); + }); + + // A newline in a value would let it forge further protocol lines, so such a + // field is dropped rather than emitted. + test.each([ + ["password", "tok\nhost=evil.example.com"], + ["username", "user\0root"], + ])("drops %s when the value can forge protocol lines", (key, value) => { + const output = formatCredentialResponse({ host: "h", [key]: value }); + + expect(output).toBe("host=h\n\n"); + }); +}); diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts index c4595d0c..e73cb020 100644 --- a/tests/helpers/login-flow.test.ts +++ b/tests/helpers/login-flow.test.ts @@ -11,467 +11,169 @@ import { test, } from "bun:test"; -// --------------------------------------------------------------------------- -// Module mocks — must be declared before imports that use them. -// --------------------------------------------------------------------------- - -// Auth + credential-store stubs are installed per-test via spyOn (see -// beforeEach), NOT mock.module. spyOn is auto-restored and scoped to this -// file; mock.module is process-global and would leak mocked implementations -// into auth.test.ts and credential-store.test.ts. -let mockRequestDeviceCode: Mock; -let mockPollForAccessToken: Mock; -let mockGetGitHubUser: Mock; -let mockClaimArchgateToken: Mock; -let mockSaveCredentials: Mock; - -// Mock inquirer for the signup flow prompts (lazy-loaded via dynamic import). -// Use Record as return type so mockImplementation can return -// different shapes for different prompts (email, editor, useCase, confirmed). -/** - * The subset of each signup question the tests read back. Declaring it lets - * the validator assertions below index the recorded call without a cast. - */ -interface SignupQuestion { - name?: string; - validate?: (value: string) => boolean | string; -} - -const mockInquirerPrompt = mock( - async (_question: SignupQuestion): Promise> => ({ - email: "test@example.com", - }) -); -void mock.module("inquirer", () => ({ - default: { prompt: mockInquirerPrompt }, -})); - -// --------------------------------------------------------------------------- -// Import SignupRequiredError BEFORE mocking — we need the real class so -// instanceof checks in login-flow.ts work correctly. -// Note: we do NOT mock signup.ts to avoid cross-test contamination with -// signup.test.ts (which uses static imports). -// --------------------------------------------------------------------------- - -import * as authMod from "../../src/helpers/auth"; import * as credMod from "../../src/helpers/credential-store"; +import * as desktopMod from "../../src/helpers/desktop"; +import * as gitConfigMod from "../../src/helpers/git-credential-config"; import { runLoginFlow } from "../../src/helpers/login-flow"; -// --------------------------------------------------------------------------- -// Imports under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- -import type { - LoginFlowOptions, - LoginFlowResult, -} from "../../src/helpers/login-flow"; -import { SignupRequiredError } from "../../src/helpers/signup"; - -// --------------------------------------------------------------------------- -// Fetch mock for signup endpoint — requestSignup() uses globalThis.fetch. -// Per ARCH-005: assign globalThis.fetch directly, don't use mock.module. -// --------------------------------------------------------------------------- - -let originalFetch: typeof globalThis.fetch; - -describe("login-flow", () => { - beforeEach(() => { - // Silence console output (restored via mock.restore() in afterEach). - spyOn(console, "log").mockImplementation(() => {}); - spyOn(console, "error").mockImplementation(() => {}); - - // Save original fetch — only needed for signup tests that go through - // the real requestSignup function. - originalFetch = globalThis.fetch; - - // Install fresh per-test spies with default implementations. - mockRequestDeviceCode = spyOn( - authMod, - "requestDeviceCode" - ).mockResolvedValue({ - device_code: "dc-test-123", - user_code: "ABCD-1234", - verification_uri: "https://github.com/login/device", - expires_in: 900, - interval: 5, - }); - mockPollForAccessToken = spyOn( - authMod, - "pollForAccessToken" - ).mockResolvedValue("gh-token-test-456"); - mockGetGitHubUser = spyOn(authMod, "getGitHubUser").mockResolvedValue({ - login: "octocat", - email: "octocat@github.com", - }); - mockClaimArchgateToken = spyOn( - authMod, - "claimArchgateToken" - ).mockResolvedValue("archgate-token-789"); - mockSaveCredentials = spyOn(credMod, "saveCredentials").mockImplementation( - async () => {} - ); - - mockInquirerPrompt.mockClear(); - mockInquirerPrompt.mockImplementation(async () => ({ - email: "test@example.com", - })); - }); - - afterEach(() => { - // Restore all spyOn spies (console + auth + credential-store). - mock.restore(); - globalThis.fetch = originalFetch; - }); - - // ----------------------------------------------------------------------- - // Type exports - // ----------------------------------------------------------------------- - - 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"); +import { platformAuth } from "../../src/helpers/platform-auth"; +import { rejectionMessage } from "../test-utils"; + +// Stubs are installed per-test via spyOn, which is auto-restored and scoped to +// this file; mock.module is process-global and would leak into sibling suites. +let mockRequestDeviceCode: Mock; +let mockPollForTokens: Mock; +let mockSaveTokenSet: Mock; +let mockEnsureHelper: Mock; +let mockOpenBrowser: Mock; +let mockCopyToClipboard: Mock; +let logSpy: Mock; + +const AUTHORIZATION = { + deviceCode: "device-abc", + userCode: "HZML-HXLB", + verificationUri: "https://auth.archgate.dev/device", + expiresIn: 600, + interval: 5, +}; + +const TOKENS = { + accessToken: "ey.access.token", + refreshToken: "refresh-abc", + expiresAt: 1_800_000_000_000, +}; + +beforeEach(() => { + mockRequestDeviceCode = spyOn( + platformAuth, + "requestDeviceCode" + ).mockResolvedValue(AUTHORIZATION); + mockPollForTokens = spyOn(platformAuth, "pollForTokens").mockResolvedValue({ + user: "octocat", + tokens: TOKENS, + }); + mockSaveTokenSet = spyOn(credMod, "saveTokenSet").mockResolvedValue(true); + mockEnsureHelper = spyOn( + gitConfigMod, + "ensureGitCredentialHelper" + ).mockResolvedValue(); + mockOpenBrowser = spyOn(desktopMod, "openBrowser").mockResolvedValue(false); + mockCopyToClipboard = spyOn(desktopMod, "copyToClipboard").mockResolvedValue( + false + ); + logSpy = spyOn(console, "log").mockImplementation(() => {}); +}); - const failure: LoginFlowResult = { ok: false }; - expect(failure.ok).toBe(false); - expect(failure.githubUser).toBeUndefined(); - }); +afterEach(() => { + mock.restore(); +}); - test("successful login: device code -> poll -> claim -> save", async () => { +describe("runLoginFlow", () => { + test("stores the token set under the signed-in identity", async () => { const result = await runLoginFlow(); - expect(result.ok).toBe(true); - expect(result.githubUser).toBe("octocat"); - - // Verify the full auth chain was called - expect(mockRequestDeviceCode).toHaveBeenCalledTimes(1); - expect(mockPollForAccessToken).toHaveBeenCalledTimes(1); - expect(mockPollForAccessToken).toHaveBeenCalledWith("dc-test-123", 5, 900); - expect(mockGetGitHubUser).toHaveBeenCalledTimes(1); - expect(mockGetGitHubUser).toHaveBeenCalledWith("gh-token-test-456"); - expect(mockClaimArchgateToken).toHaveBeenCalledTimes(1); - expect(mockClaimArchgateToken).toHaveBeenCalledWith("gh-token-test-456"); - expect(mockSaveCredentials).toHaveBeenCalledTimes(1); - expect(mockSaveCredentials).toHaveBeenCalledWith({ - token: "archgate-token-789", - github_user: "octocat", - }); + expect(result).toEqual({ ok: true, accountName: "octocat" }); + expect(mockSaveTokenSet).toHaveBeenCalledWith("octocat", TOKENS); }); - test("requestDeviceCode throws -> propagates error", async () => { - mockRequestDeviceCode.mockImplementation(async () => { - throw new Error("GitHub device code request failed (HTTP 500)"); - }); + test("shows the verification URI and user code", async () => { + await runLoginFlow(); - expect(runLoginFlow()).rejects.toThrow("GitHub device code request failed"); - expect(mockPollForAccessToken).not.toHaveBeenCalled(); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + const printed = logSpy.mock.calls.flat().join("\n"); + expect(printed).toContain("https://auth.archgate.dev/device"); + expect(printed).toContain("HZML-HXLB"); }); - test("pollForAccessToken throws -> propagates error", async () => { - mockPollForAccessToken.mockImplementation(async () => { - throw new Error("Device code expired"); - }); + test("polls the pending authorization it was handed", async () => { + await runLoginFlow(); - expect(runLoginFlow()).rejects.toThrow("Device code expired"); - expect(mockClaimArchgateToken).not.toHaveBeenCalled(); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(mockPollForTokens).toHaveBeenCalledWith(AUTHORIZATION); }); - test("getGitHubUser throws -> propagates error", async () => { - mockGetGitHubUser.mockImplementation(async () => { - throw new Error("Failed to fetch GitHub user (HTTP 401)"); - }); + test("registers archgate as git's credential helper", async () => { + await runLoginFlow(); - expect(runLoginFlow()).rejects.toThrow("Failed to fetch GitHub user"); - expect(mockClaimArchgateToken).not.toHaveBeenCalled(); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(mockEnsureHelper).toHaveBeenCalled(); }); - // ----------------------------------------------------------------------- - // claimArchgateToken throws SignupRequiredError -> enters signup flow - // ----------------------------------------------------------------------- - - test("signup required: auto-approved token returned from signup", async () => { - // First call to claimArchgateToken throws SignupRequiredError - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); - - // Mock fetch for the signup endpoint — requestSignup uses globalThis.fetch - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async (url: string | URL | Request) => { - const urlStr = - typeof url === "string" - ? url - : url instanceof URL - ? url.toString() - : url.url; - if (urlStr.includes("/api/signup")) { - return Response.json({ token: "auto-approved-token" }, { status: 201 }); - } - return originalFetch(url); - }) as unknown as typeof fetch; - - // Mock the sequence of inquirer prompts: - // 1. email -> 2. editor -> 3. useCase -> 4. confirmed - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { editor: "vscode" }; - case 3: - return { useCase: "governance" }; - case 4: - return { confirmed: true }; - default: - return {}; - } - }); + test("reports the account name the platform resolved", async () => { + mockPollForTokens.mockResolvedValue({ user: "Octo Cat", tokens: TOKENS }); const result = await runLoginFlow(); - expect(result.ok).toBe(true); - expect(result.githubUser).toBe("octocat"); - expect(mockSaveCredentials).toHaveBeenCalledWith({ - token: "auto-approved-token", - github_user: "octocat", - }); + expect(result.accountName).toBe("Octo Cat"); }); - test("signup without auto-token falls back to second claim call", async () => { - // First claimArchgateToken call throws, second succeeds - let claimCallCount = 0; - mockClaimArchgateToken.mockImplementation(async () => { - claimCallCount++; - if (claimCallCount === 1) { - throw new SignupRequiredError(); - } - return "fallback-token-xyz"; - }); - - // Mock fetch for the signup endpoint — no token returned - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async (url: string | URL | Request) => { - const urlStr = - typeof url === "string" - ? url - : url instanceof URL - ? url.toString() - : url.url; - if (urlStr.includes("/api/signup")) { - return Response.json({}, { status: 201 }); - } - return originalFetch(url); - }) as unknown as typeof fetch; - - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { editor: "cursor" }; - case 3: - return { useCase: "testing" }; - case 4: - return { confirmed: true }; - default: - return {}; - } - }); + test("fails the login when the token set cannot be persisted", async () => { + mockSaveTokenSet.mockResolvedValue(false); - const result = await runLoginFlow(); - - expect(result.ok).toBe(true); - expect(claimCallCount).toBe(2); - expect(mockSaveCredentials).toHaveBeenCalledWith({ - token: "fallback-token-xyz", - github_user: "octocat", - }); + expect(await rejectionMessage(runLoginFlow())).toContain( + "credentials could not be stored" + ); }); - test("signup cancelled (confirmed=false) -> returns ok:false", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); + test("opens the URL that already carries the code", async () => { + mockRequestDeviceCode.mockResolvedValue({ + ...AUTHORIZATION, + verificationUriComplete: + "https://auth.archgate.dev/device?user_code=HZML-HXLB", }); - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { editor: "vscode" }; - case 3: - return { useCase: "testing" }; - case 4: - return { confirmed: false }; - default: - return {}; - } - }); - - const result = await runLoginFlow(); + await runLoginFlow(); - expect(result.ok).toBe(false); - expect(result.githubUser).toBeUndefined(); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(mockOpenBrowser).toHaveBeenCalledWith( + "https://auth.archgate.dev/device?user_code=HZML-HXLB" + ); }); - test("signup request fails -> returns ok:false", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); + test("falls back to the plain URL when the provider omits the complete one", async () => { + await runLoginFlow(); - // Mock fetch for the signup endpoint — returns failure - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async (url: string | URL | Request) => { - const urlStr = - typeof url === "string" - ? url - : url instanceof URL - ? url.toString() - : url.url; - if (urlStr.includes("/api/signup")) { - return new Response("Conflict", { status: 409 }); - } - return originalFetch(url); - }) as unknown as typeof fetch; - - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { editor: "vscode" }; - case 3: - return { useCase: "testing" }; - case 4: - return { confirmed: true }; - default: - return {}; - } - }); + expect(mockOpenBrowser).toHaveBeenCalledWith( + "https://auth.archgate.dev/device" + ); + }); - const result = await runLoginFlow(); + test("copies the user code to the clipboard", async () => { + await runLoginFlow(); - expect(result.ok).toBe(false); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(mockCopyToClipboard).toHaveBeenCalledWith("HZML-HXLB"); }); - test("pre-selected editor skips editor prompt in signup flow", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); - - let signupBody: Record | null = null; - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async ( - _url: string | URL | Request, - init?: RequestInit - ) => { - const rawBody = typeof init?.body === "string" ? init.body : "{}"; - // Test-only signup request body; no schema exists for it. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - signupBody = JSON.parse(rawBody) as Record; - return Response.json({ token: "editor-preset-token" }, { status: 201 }); - }) as unknown as typeof fetch; - - // With preselected editor, only 3 prompts: email, useCase, confirmed - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { useCase: "governance" }; - case 3: - return { confirmed: true }; - default: - return {}; - } - }); + // The URL and code are printed either way: the browser may not have opened, + // and the user may be reading this on a different machine. + test.each([true, false])( + "prints the URL and code when opened=%p", + async (opened) => { + mockOpenBrowser.mockResolvedValue(opened); - const result = await runLoginFlow({ editor: "claude-code" }); + await runLoginFlow(); - expect(result.ok).toBe(true); - // The pre-selected editor "claude-code" should be passed to requestSignup - expect(signupBody).not.toBeNull(); - expect(signupBody!.editor).toBe("claude-code"); - // Only 3 prompts (no editor prompt) - expect(promptCallCount).toBe(3); - }); + const printed = logSpy.mock.calls.flat().join("\n"); + expect(printed).toContain("https://auth.archgate.dev/device"); + expect(printed).toContain("HZML-HXLB"); + } + ); - test("signup prompts validate the email and use-case answers", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); + test("mentions the clipboard only when the copy succeeded", async () => { + mockCopyToClipboard.mockResolvedValue(true); - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async () => - Response.json( - { token: "validated-token" }, - { status: 201 } - )) as unknown as typeof fetch; - - // A pre-selected editor drops the editor prompt, so the recorded calls - // are email, useCase, confirm — in that order. - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { useCase: "governance" }; - default: - return { confirmed: true }; - } - }); + await runLoginFlow(); - await runLoginFlow({ editor: "claude-code" }); + expect(logSpy.mock.calls.flat().join("\n")).toContain( + "copied to your clipboard" + ); + }); - const emailValidate = mockInquirerPrompt.mock.calls[0][0].validate; - expect(emailValidate?.("not-an-email")).toBe("Enter a valid email address"); - expect(emailValidate?.("dev@example.com")).toBe(true); + test("says nothing about the clipboard when the copy failed", async () => { + await runLoginFlow(); - const useCaseValidate = mockInquirerPrompt.mock.calls[1][0].validate; - expect(useCaseValidate?.(" ")).toBe("Please describe your use case"); - expect(useCaseValidate?.("governance")).toBe(true); + expect(logSpy.mock.calls.flat().join("\n")).not.toContain("clipboard"); }); - test("claimArchgateToken throws non-signup error -> propagates", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new Error("Token claim failed (HTTP 500)"); - }); + test("propagates a failure from the device authorization request", async () => { + mockRequestDeviceCode.mockRejectedValue(new Error("network down")); - expect(runLoginFlow()).rejects.toThrow("Token claim failed"); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(await rejectionMessage(runLoginFlow())).toContain("network down"); + expect(mockSaveTokenSet).not.toHaveBeenCalled(); }); }); diff --git a/tests/helpers/oauth-device-flow.test.ts b/tests/helpers/oauth-device-flow.test.ts new file mode 100644 index 00000000..de1f7c99 --- /dev/null +++ b/tests/helpers/oauth-device-flow.test.ts @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { deviceFlowAuth } from "../../src/helpers/oauth-device-flow"; +import type { DeviceAuthorization } from "../../src/helpers/platform-auth"; +import { + type RecordedRequest, + recordingFetch, + rejectionMessage, +} from "../test-utils"; + +const originalFetch = globalThis.fetch; + +/** Queue of responses returned by the stubbed fetch, in order. */ +let responses: Response[]; + +/** Every request the code under test sent, in order. */ +let requests: RecordedRequest[]; + +const auth = deviceFlowAuth({ + deviceAuthorizationEndpoint: "https://sso.example.test/device", + tokenEndpoint: "https://sso.example.test/token", + clientId: "client-1", + scope: "openid offline_access", + resource: "https://api.example.test", +}); + +const PENDING: DeviceAuthorization = { + deviceCode: "device-abc", + userCode: "HZML-HXLB", + verificationUri: "https://sso.example.test/activate", + expiresIn: 60, + interval: 0, +}; + +beforeEach(() => { + responses = []; + requests = []; + globalThis.fetch = recordingFetch(requests, () => { + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch call"); + return next; + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** A token endpoint error body. */ +function errorResponse(error: string): Response { + return Response.json({ error }, { status: 400 }); +} + +function deviceCodeResponse(extra: Record = {}): Response { + return Response.json({ + device_code: "device-abc", + user_code: "HZML-HXLB", + verification_uri: "https://sso.example.test/activate", + expires_in: 600, + ...extra, + }); +} + +function grantResponse(extra: Record = {}): Response { + return Response.json({ + access_token: "ey.access", + refresh_token: "refresh-abc", + expires_in: 3600, + ...extra, + }); +} + +/** An ID token whose payload carries the given claims. */ +function idTokenFor(claims: Record): string { + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `header.${payload}.sig`; +} + +// A 307 or 308 would replay the body, which carries a device code or refresh +// token, to whatever host the `Location` header names. +describe("redirect policy", () => { + test.each([ + [ + "requestDeviceCode", + async () => auth.requestDeviceCode(), + deviceCodeResponse, + ], + ["pollForTokens", async () => auth.pollForTokens(PENDING), grantResponse], + [ + "refreshAccessToken", + async () => auth.refreshAccessToken("refresh-old"), + grantResponse, + ], + ])("%s refuses to follow redirects", async (_name, call, respond) => { + responses.push(respond()); + + await call(); + + expect(requests[0]?.init?.redirect).toBe("error"); + }); +}); + +describe("requestDeviceCode", () => { + test("asks the configured endpoint as the configured client", async () => { + responses.push(deviceCodeResponse()); + + await auth.requestDeviceCode(); + + expect(requests[0]?.url).toBe("https://sso.example.test/device"); + expect(Object.fromEntries(requests[0]?.fields ?? [])).toEqual({ + client_id: "client-1", + scope: "openid offline_access", + resource: "https://api.example.test", + }); + }); + + test("returns the codes and where to enter them", async () => { + responses.push( + deviceCodeResponse({ + verification_uri_complete: + "https://sso.example.test/activate?user_code=HZML-HXLB", + interval: 7, + }) + ); + + expect(await auth.requestDeviceCode()).toEqual({ + deviceCode: "device-abc", + userCode: "HZML-HXLB", + verificationUri: "https://sso.example.test/activate", + verificationUriComplete: + "https://sso.example.test/activate?user_code=HZML-HXLB", + expiresIn: 600, + interval: 7, + }); + }); + + // RFC 8628 makes 5 seconds the default when the server omits `interval`. + test("defaults the poll interval when the server omits it", async () => { + responses.push(deviceCodeResponse()); + + expect((await auth.requestDeviceCode()).interval).toBe(5); + }); + + test("reports a non-OK response as a user-facing error", async () => { + responses.push(new Response("nope", { status: 503 })); + + expect(await rejectionMessage(auth.requestDeviceCode())).toContain( + "Could not start sign-in" + ); + }); + + test("rejects a response that is not the expected shape", async () => { + responses.push(Response.json({ nope: true })); + + expect(await rejectionMessage(auth.requestDeviceCode())).toContain( + "unexpected response" + ); + }); + + test("reports an unreachable service with the underlying cause", async () => { + globalThis.fetch = recordingFetch(requests, () => { + throw new TypeError("getaddrinfo ENOTFOUND"); + }); + + const message = await rejectionMessage(auth.requestDeviceCode()); + + expect(message).toContain("Could not reach the sign-in service"); + expect(message).toContain("ENOTFOUND"); + }); + + // A proxy's HTML error page must not escape as a SyntaxError. + test("treats a non-JSON body as an unexpected response", async () => { + responses.push(new Response("gateway", { status: 200 })); + + expect(await rejectionMessage(auth.requestDeviceCode())).toContain( + "unexpected response" + ); + }); +}); + +describe("pollForTokens", () => { + // The first access token must already carry the plugins audience; the + // resource is not implied by the device authorization alone. + test("polls the token endpoint with the device grant for the resource", async () => { + responses.push(grantResponse()); + + await auth.pollForTokens(PENDING); + + expect(requests[0]?.url).toBe("https://sso.example.test/token"); + expect(Object.fromEntries(requests[0]?.fields ?? [])).toEqual({ + client_id: "client-1", + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "device-abc", + resource: "https://api.example.test", + }); + }); + + // A dropped connection while the user is still at the browser must not + // abort the sign-in; the next poll tries again. + test("rides out a transport failure between polls", async () => { + let call = 0; + globalThis.fetch = recordingFetch(requests, () => { + call += 1; + if (call === 1) throw new TypeError("connection reset"); + return grantResponse(); + }); + + expect((await auth.pollForTokens(PENDING)).tokens.accessToken).toBe( + "ey.access" + ); + expect(requests).toHaveLength(2); + }); + + test("keeps polling while authorization is pending", async () => { + responses.push(errorResponse("authorization_pending"), grantResponse()); + + const session = await auth.pollForTokens(PENDING); + + expect(session.tokens.accessToken).toBe("ey.access"); + expect(session.tokens.refreshToken).toBe("refresh-abc"); + expect(requests).toHaveLength(2); + }); + + test("backs off by five seconds when the provider answers slow_down", async () => { + const sleepSpy = spyOn(Bun, "sleep").mockResolvedValue(); + responses.push(errorResponse("slow_down"), grantResponse()); + try { + expect((await auth.pollForTokens(PENDING)).tokens.accessToken).toBe( + "ey.access" + ); + expect(sleepSpy.mock.calls.map((call) => call[0])).toEqual([0, 5_000]); + } finally { + sleepSpy.mockRestore(); + } + }); + + test.each([ + ["access_denied", "Sign-in was denied."], + ["expired_token", "The sign-in code expired."], + ])("stops on %s", async (error, message) => { + responses.push(errorResponse(error)); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + message + ); + }); + + test("gives up once the device code lifetime elapses", async () => { + expect( + await rejectionMessage(auth.pollForTokens({ ...PENDING, expiresIn: -1 })) + ).toContain("The sign-in code expired."); + }); + + test("fails when the grant carries no refresh token", async () => { + responses.push( + Response.json({ access_token: "ey.access", expires_in: 60 }) + ); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "did not return a refresh token" + ); + }); + + test("surfaces an unrecognised error with its description", async () => { + responses.push( + Response.json( + { error: "invalid_client", error_description: "unknown client" }, + { status: 400 } + ) + ); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "unknown client" + ); + }); + + test("falls back to the error code when no description is given", async () => { + responses.push(errorResponse("invalid_client")); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "invalid_client" + ); + }); + + test("rejects an error body that is not the expected shape", async () => { + responses.push(Response.json({ nope: true }, { status: 400 })); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "unexpected response" + ); + }); + + // A proxy's HTML error page must not escape as a SyntaxError. + test("treats a non-JSON success body as an unexpected response", async () => { + responses.push(new Response("gateway", { status: 200 })); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "unexpected response" + ); + }); + + describe("names the session", () => { + test.each([ + [{ sub: "usr_1", username: "octocat" }, "octocat"], + [{ sub: "usr_1", name: "Octo Cat" }, "Octo Cat"], + [{ sub: "usr_1", email: "octo@example.com" }, "octo@example.com"], + [{ sub: "usr_1" }, "usr_1"], + // An empty claim must not win over a filled one: the stored session + // requires a non-empty name and would be dropped on the next read. + [{ sub: "usr_1", username: "", name: "Octo Cat" }, "Octo Cat"], + [{ sub: "usr_1", username: "", name: "", email: "" }, "usr_1"], + ])("from the most specific claim in %o", async (claims, expected) => { + responses.push(grantResponse({ id_token: idTokenFor(claims) })); + + expect((await auth.pollForTokens(PENDING)).user).toBe(expected); + }); + + test.each([ + ["no ID token", undefined], + ["an empty ID token", ""], + ["a token that is not a JWT", "not-a-jwt"], + ["a token with an empty payload", "header..sig"], + ["a payload that is not base64", "header.!!!notbase64!!!.sig"], + ["claims without a subject", idTokenFor({ name: "nobody" })], + ["claims that are all empty", idTokenFor({ sub: "", username: "" })], + ])("generically given %s", async (_label, idToken) => { + responses.push(grantResponse({ id_token: idToken })); + + expect((await auth.pollForTokens(PENDING)).user).toBe("archgate"); + }); + }); +}); + +describe("refreshAccessToken", () => { + test("sends the refresh grant for the configured resource", async () => { + responses.push(grantResponse()); + + await auth.refreshAccessToken("refresh-old"); + + expect(requests[0]?.url).toBe("https://sso.example.test/token"); + expect(Object.fromEntries(requests[0]?.fields ?? [])).toEqual({ + client_id: "client-1", + grant_type: "refresh_token", + refresh_token: "refresh-old", + resource: "https://api.example.test", + }); + }); + + test("returns the rotated token pair", async () => { + responses.push( + grantResponse({ access_token: "ey.new", refresh_token: "refresh-new" }) + ); + + const tokens = await auth.refreshAccessToken("refresh-old"); + + expect(tokens.accessToken).toBe("ey.new"); + expect(tokens.refreshToken).toBe("refresh-new"); + }); + + // A grant without a rotated refresh token must not discard the one held. + test("keeps the existing refresh token when none is returned", async () => { + responses.push(Response.json({ access_token: "ey.new", expires_in: 3600 })); + + expect((await auth.refreshAccessToken("refresh-old")).refreshToken).toBe( + "refresh-old" + ); + }); + + test("asks the user to sign in again when the grant is refused", async () => { + responses.push(errorResponse("invalid_grant")); + + expect( + await rejectionMessage(auth.refreshAccessToken("refresh-old")) + ).toContain("Your session has expired"); + }); + + // A failing service is not a sign-out: the user must not be told to log in + // again for a 5xx, and a network fault must stay a user-facing error. + test.each([ + ["a server error", new Response("bad gateway", { status: 502 })], + ["a non-JSON client error", new Response("nope", { status: 400 })], + ])( + "reports %s as the service failing, not as a sign-out", + async (_label, response) => { + responses.push(response); + + const message = await rejectionMessage( + auth.refreshAccessToken("refresh-old") + ); + + expect(message).toContain("could not renew your session"); + expect(message).not.toContain("sign in again"); + } + ); + + test("reports an unreachable service with the underlying cause", async () => { + globalThis.fetch = recordingFetch(requests, () => { + throw new TypeError("unable to connect: CERT_HAS_EXPIRED"); + }); + + const message = await rejectionMessage( + auth.refreshAccessToken("refresh-old") + ); + + expect(message).toContain("Could not reach the sign-in service"); + expect(message).toContain("CERT_HAS_EXPIRED"); + }); + + test("rejects a response that is not the expected shape", async () => { + responses.push(Response.json({ nope: true })); + + expect( + await rejectionMessage(auth.refreshAccessToken("refresh-old")) + ).toContain("unexpected response"); + }); +}); diff --git a/tests/helpers/platform-auth.test.ts b/tests/helpers/platform-auth.test.ts new file mode 100644 index 00000000..eb9772e1 --- /dev/null +++ b/tests/helpers/platform-auth.test.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + AUTH_HOST, + isExpired, + platformAuth, +} from "../../src/helpers/platform-auth"; +import { PLUGINS_HOST } from "../../src/helpers/plugin-install"; +import { + type RecordedRequest, + recordingFetch, + rejectionMessage, +} from "../test-utils"; + +const originalFetch = globalThis.fetch; + +/** URL and form fields of every request sent through the stubbed fetch. */ +let requests: RecordedRequest[]; + +beforeEach(() => { + requests = []; + globalThis.fetch = recordingFetch( + requests, + () => new Response("unavailable", { status: 503 }) + ); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("platformAuth", () => { + test("signs in against the platform's sign-in host", async () => { + await rejectionMessage(platformAuth.requestDeviceCode()); + + expect(new URL(requests[0]?.url ?? "").host).toBe(AUTH_HOST); + }); + + // The plugins backend validates the audience, so the access token must be + // minted for its host or every download is rejected. + test("requests tokens for the plugins host", async () => { + await rejectionMessage(platformAuth.requestDeviceCode()); + + expect(requests[0]?.fields.get("resource")).toBe(`https://${PLUGINS_HOST}`); + }); + + test("asks for a refresh token", async () => { + await rejectionMessage(platformAuth.requestDeviceCode()); + + expect(requests[0]?.fields.get("scope")).toContain("offline_access"); + }); +}); + +describe("isExpired", () => { + test.each([ + ["already past", -1_000, true], + ["inside the renewal skew", 30_000, true], + ["comfortably valid", 600_000, false], + ])("%s", (_label, offsetMs, expected) => { + expect(isExpired(Date.now() + offsetMs)).toBe(expected); + }); +}); diff --git a/tests/helpers/session-expired-error.test.ts b/tests/helpers/session-expired-error.test.ts new file mode 100644 index 00000000..aca688e0 --- /dev/null +++ b/tests/helpers/session-expired-error.test.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test } from "bun:test"; + +import { SessionExpiredError } from "../../src/helpers/session-expired-error"; +import { UserError } from "../../src/helpers/user-error"; + +describe("SessionExpiredError", () => { + // The credential store swallows this one as a signed-out state while every + // other UserError propagates, so the subclass must remain distinguishable. + test("is a UserError with its own name", () => { + const error = new SessionExpiredError(); + + expect(error).toBeInstanceOf(UserError); + expect(error.name).toBe("SessionExpiredError"); + }); + + test("tells the user to sign in again", () => { + expect(new SessionExpiredError().message).toContain("archgate login"); + }); +}); diff --git a/tests/helpers/signup.test.ts b/tests/helpers/signup.test.ts deleted file mode 100644 index d4b77ce8..00000000 --- a/tests/helpers/signup.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { afterEach, beforeEach, describe, expect, test, mock } from "bun:test"; - -import { - SignupRequiredError, - isSignupRequiredError, - requestSignup, -} from "../../src/helpers/signup"; - -/** Type-safe fetch mock — Bun's fetch type includes `preconnect` which mock() doesn't provide. */ -function mockFetch(handler: () => Promise) { - // Deliberately incomplete fake: mock() can't reproduce fetch's full type - // (preconnect etc.), and only the callable shape is ever exercised. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock(handler) as unknown as typeof fetch; -} - -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.each<[string | undefined, boolean]>([ - ["No approved signup found for this GitHub account", true], - ["User is not registered", true], - ["NO APPROVED SIGNUP", true], - ["Token expired", false], - [undefined, false], - ])("returns %p for %p", (input, expected) => { - expect(isSignupRequiredError(input)).toBe(expected); - }); -}); - -describe("requestSignup", () => { - let originalFetch: typeof fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("returns ok=true and token on 201 with token", async () => { - mockFetch(async () => - Response.json({ token: "ag_beta_auto_approved" }, { status: 201 }) - ); - - const result = await requestSignup( - "octocat", - "octo@example.com", - "testing" - ); - expect(result.ok).toBe(true); - expect(result.token).toBe("ag_beta_auto_approved"); - }); - - test("returns ok=true and token=null on 201 without token (manual approval)", async () => { - mockFetch(async () => Response.json({}, { status: 201 })); - - const result = await requestSignup( - "octocat", - "octo@example.com", - "testing" - ); - expect(result.ok).toBe(true); - expect(result.token).toBeNull(); - }); - - test("returns ok=false and token=null on non-201 status", async () => { - mockFetch(async () => new Response("Conflict", { status: 409 })); - - const result = await requestSignup( - "octocat", - "octo@example.com", - "testing" - ); - expect(result.ok).toBe(false); - expect(result.token).toBeNull(); - }); - - test("returns ok=true and token=null when response.json() throws", async () => { - mockFetch(async () => new Response("not-json", { status: 201 })); - - const result = await requestSignup( - "octocat", - "octo@example.com", - "testing" - ); - expect(result.ok).toBe(true); - expect(result.token).toBeNull(); - }); - - test("propagates rejection when fetch fails (e.g. network error or timeout)", async () => { - mockFetch(async () => { - throw new Error("network down"); - }); - - expect( - requestSignup("octocat", "octo@example.com", "testing") - ).rejects.toThrow("network down"); - }); - - test("sends default editor=claude-code when editor not provided", async () => { - let capturedBody: string | null = null; - - // Deliberately incomplete fake: only the callable shape is exercised. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock( - async (_input: string | URL | Request, init?: RequestInit) => { - capturedBody = typeof init?.body === "string" ? init.body : null; - return Response.json({ token: "ag_beta_tok" }, { status: 201 }); - } - ) as unknown as typeof fetch; - - await requestSignup("octocat", "octo@example.com", "testing"); - expect(capturedBody).not.toBeNull(); - const parsed: unknown = JSON.parse(capturedBody!); - if ( - typeof parsed !== "object" || - parsed === null || - !("editor" in parsed) - ) { - throw new Error("expected a parsed request body with an editor field"); - } - expect(parsed.editor).toBe("claude-code"); - }); -}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 927a63e3..250490a0 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -122,3 +122,43 @@ export async function tarballOf( bytes.byteOffset + bytes.byteLength ); } + +/** One request captured by {@link recordingFetch}. */ +export interface RecordedRequest { + url: string; + /** Form fields of the body, empty for a body that is not form-encoded. */ + fields: URLSearchParams; + init: RequestInit | undefined; +} + +/** + * A `globalThis.fetch` stand-in that records each call and answers with + * whatever `respond` returns or throws. + * + * @param requests - Receives one entry per call, in order. + * @param respond - Produces the response for the next call. + */ +export function recordingFetch( + requests: RecordedRequest[], + respond: () => Response +): typeof globalThis.fetch { + const impl = async ( + input: string | URL | Request, + init?: RequestInit + ): Promise => { + requests.push({ + url: input instanceof Request ? input.url : input.toString(), + fields: + init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(), + init, + }); + return respond(); + }; + return Object.assign(impl, { + preconnect: () => { + // Present only to satisfy the fetch type; no test calls it. + }, + }); +}