diff --git a/.archgate/lint/README.md b/.archgate/lint/README.md new file mode 100644 index 00000000..9c7e1d65 --- /dev/null +++ b/.archgate/lint/README.md @@ -0,0 +1,26 @@ +# Linter Rules + +This directory hosts linter-specific rules that enforce your ADRs at the linter level. + +## Convention + +Place linter plugin files here, named by tool: + +- `oxlint.js` — Custom oxlint rules (JavaScript plugin) +- `eslint.js` — Custom ESLint rules +- `biome.js` — Custom Biome rules + +## Usage with oxlint + +1. Create `.archgate/lint/oxlint.js` exporting your plugin rules. +2. Reference it in your oxlint config: + +```json +{ + "plugins": [".archgate/lint/oxlint.js"] +} +``` + +## Why here? + +Archgate standardizes `.archgate/lint/` as the location for linter rules that complement ADR checks. This keeps governance artifacts together — ADRs in `adrs/`, linter rules in `lint/`. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..86310cc7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "agent": "archgate:developer", + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": ["archgate"], + "permissions": { + "allow": [ + "mcp__plugin_archgate_archgate__*", + "Skill(archgate:architect)", + "Skill(archgate:quality-manager)", + "Skill(archgate:adr-author)" + ] + } +} diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 00000000..0206fe30 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "archgate": { + "command": "archgate", + "args": ["mcp"] + } + } +} diff --git a/.cursor/rules/archgate-governance.mdc b/.cursor/rules/archgate-governance.mdc new file mode 100644 index 00000000..853a1dd6 --- /dev/null +++ b/.cursor/rules/archgate-governance.mdc @@ -0,0 +1,29 @@ +--- +description: Archgate ADR governance — enforces architecture decision records +globs: +alwaysApply: true +--- + +# Archgate Governance + +This project uses Archgate to enforce Architecture Decision Records (ADRs). + +## Before writing code + +- Use the `review_context` MCP tool to get applicable ADR briefings for changed files +- Review the Decision and Do's/Don'ts sections of each applicable ADR + +## After writing code + +- Run the `check` MCP tool to validate compliance with all ADR rules +- Fix any violations before considering work complete + +## ADR commands + +- `list_adrs` — List all active ADRs with metadata +- `check` — Run automated compliance checks (use `staged: true` for pre-commit) +- `review_context` — Get changed files grouped by domain with ADR briefings + +## Key principle + +Architectural decisions are enforced, not suggested. If `check` reports violations, they must be fixed. diff --git a/README.md b/README.md index 0704aec2..30639b90 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ Archgate has two layers: When a rule is violated, `archgate check` reports the file, line, and which ADR was broken. Exit code 1 means violations — wire it into CI and it blocks merges automatically. -**The AI integration layer** is a Claude Code plugin that gives AI agents live access to your ADRs through the MCP server (`archgate mcp`). Agents read the decisions before writing code, validate changes after, and capture new patterns into the governance base. Archgate ships as its own governance subject — its own development is governed by the ADRs in `.archgate/adrs/`. +**The CLI is free and open source.** Writing ADRs, enforcing rules, running checks in CI, and wiring up pre-commit hooks all work without an account or subscription. + +**Editor plugins are an optional paid add-on** for teams that want AI agents (Claude Code, Cursor) to read ADRs, validate changes, and capture new patterns automatically. Plugins are distributed from [plugins.archgate.dev](https://plugins.archgate.dev). See [Editor plugins](#editor-plugins) for details. ## Installation @@ -76,7 +78,9 @@ archgate init archgate check ``` -`archgate init` creates the `.archgate/adrs/` directory, an example ADR with a companion rules file to show the pattern, and configures the Claude Code plugin if you use it. +`archgate init` creates the `.archgate/adrs/` directory with an example ADR and rules file, and configures editor settings. No account or login is needed — the CLI is fully functional without plugins. + +**Want AI agent integration?** See [Editor plugins](#editor-plugins) to add the optional paid plugin for Claude Code or Cursor. ## Writing rules @@ -117,15 +121,37 @@ Rules receive the list of files to check (filtered by the ADR's `files` glob if ## Commands +### `archgate login` + +Authenticate with GitHub to access the optional paid editor plugins. + +```bash +archgate login # authenticate via GitHub Device Flow +archgate login status # show current auth status +archgate login logout # remove stored credentials +archgate login refresh # re-authenticate and claim a new token +``` + +Opens a browser-based GitHub Device Flow. Once authorized, an archgate plugin token is stored in `~/.archgate/credentials`. This token is used by `archgate init` to install the editor plugin. Not required for CLI-only usage. + ### `archgate init` Initialize governance in the current project. ```bash -archgate init +archgate init # Claude Code (default) +archgate init --editor cursor # Cursor +archgate init --install-plugin # force plugin install attempt ``` -Creates `.archgate/adrs/` with an example ADR and rules file, and optionally wires up the Claude Code plugin. +Creates `.archgate/adrs/` with an example ADR and rules file and configures editor settings. Works without an account — plugin installation only happens when you are logged in. + +**Plugin install behavior** (optional — requires `archgate login`): + +- If you are logged in, init auto-detects your credentials and installs the plugin. +- For **Claude Code**: if the `claude` CLI is on PATH, the plugin is installed automatically via `claude plugin marketplace add` and `claude plugin install`. If not, the manual commands are printed. +- For **Cursor**: the plugin bundle is downloaded from [plugins.archgate.dev](https://plugins.archgate.dev) and extracted into `.cursor/`. +- Use `--install-plugin` to explicitly request plugin installation (useful if auto-detection is skipped). ### `archgate check` @@ -230,15 +256,41 @@ pre-commit: run: archgate check --staged ``` -## Claude Code plugin +## Editor plugins + +> **Plugins are an optional paid add-on.** The CLI works fully without them. Plugins add AI agent integration — your AI coding agent reads ADRs before writing code, validates changes after implementation, and captures new patterns back into ADRs. + +Plugins are distributed from [plugins.archgate.dev](https://plugins.archgate.dev). + +### Setup + +```bash +# 1. Log in (one-time) — links your GitHub account and issues a plugin token +archgate login + +# 2. Initialize a project with the plugin +archgate init # Claude Code (default) +archgate init --editor cursor # or Cursor +``` + +If you are logged in, `archgate init` auto-detects your credentials and installs the plugin. You can also pass `--install-plugin` explicitly. + +### Claude Code + +If the `claude` CLI is on your PATH, the plugin is installed automatically. Otherwise, run the printed commands manually: + +```bash +claude plugin marketplace add https://:@plugins.archgate.dev/archgate.git +claude plugin install archgate@archgate +``` + +Once installed, run `archgate:onboard` in Claude Code to initialize governance for your project. -The Claude Code plugin (`archgate:developer`) gives AI agents a full governance workflow: +### Cursor -- Reads applicable ADRs before writing code -- Validates changes after implementation -- Captures new patterns back into ADRs +The Cursor plugin bundle is downloaded from [plugins.archgate.dev](https://plugins.archgate.dev) and extracted into `.cursor/` automatically. -Install the plugin from [archgate/claude-code-plugin](https://github.com/archgate/claude-code-plugin), then run `archgate:onboard` once in your project to initialize governance. +Once installed, run the `ag-onboard` skill in Cursor to initialize governance for your project. ## Contributing diff --git a/src/cli.ts b/src/cli.ts index 01cd1844..b9df07b1 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { registerAdrCommand } from "./commands/adr/index"; import { registerUpgradeCommand } from "./commands/upgrade"; import { registerCleanCommand } from "./commands/clean"; import { registerCheckCommand } from "./commands/check"; +import { registerLoginCommand } from "./commands/login"; import { registerMcpCommand } from "./commands/mcp"; import { checkForUpdatesIfNeeded } from "./helpers/update-check"; import { logError } from "./helpers/log"; @@ -35,6 +36,7 @@ async function main() { .description("AI governance for software development"); registerInitCommand(program); + registerLoginCommand(program); registerAdrCommand(program); registerCheckCommand(program); registerMcpCommand(program); diff --git a/src/commands/init.ts b/src/commands/init.ts index 2fb6b664..0c0c2692 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,9 @@ import type { Command } from "@commander-js/extra-typings"; -import { logError } from "../helpers/log"; +import { styleText } from "node:util"; +import { logError, logInfo, logWarn } from "../helpers/log"; import { initProject } from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; +import { loadCredentials } from "../helpers/auth"; const VALID_EDITORS = ["claude", "cursor"] as const; @@ -14,6 +16,10 @@ export function registerInitCommand(program: Command) { "editor integration to configure (claude, cursor)", "claude" ) + .option( + "--install-plugin", + "install the archgate plugin (requires prior `archgate login`)" + ) .action(async (opts) => { try { const editor = opts.editor as string; @@ -24,9 +30,15 @@ export function registerInitCommand(program: Command) { process.exit(1); } + // Auto-detect: install plugin if credentials exist (unless explicitly off) + const installPlugin = + opts.installPlugin ?? (await loadCredentials()) !== null; + const result = await initProject(process.cwd(), { editor: editor as EditorTarget, + installPlugin, }); + console.log(`Initialized Archgate governance in ${result.projectRoot}`); console.log(` adrs/ - architecture decision records`); console.log(` lint/ - linter-specific rules`); @@ -35,6 +47,38 @@ export function registerInitCommand(program: Command) { } else { console.log(` .claude/ - Claude Code settings configured`); } + + // Plugin install output + if (result.plugin?.installed) { + console.log(""); + if (result.plugin.autoInstalled) { + logInfo( + editor === "cursor" + ? "Archgate plugin installed for Cursor." + : "Archgate plugin installed for Claude Code." + ); + if (result.plugin.detail) { + console.log(` ${result.plugin.detail}`); + } + } else { + // Claude Code — claude CLI not found, show manual commands + logWarn( + "Claude CLI not found. To install the plugin manually, run:" + ); + console.log( + ` ${styleText("bold", "claude plugin marketplace add")} ${result.plugin.detail}` + ); + console.log( + ` ${styleText("bold", "claude plugin install")} archgate@archgate` + ); + } + } else if (installPlugin) { + // User wanted plugin but no credentials + logWarn( + "Plugin not installed — not logged in.", + "Run `archgate login` first, then re-run `archgate init --install-plugin`." + ); + } } catch (err) { logError(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 00000000..5e6df87b --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,116 @@ +import type { Command } from "@commander-js/extra-typings"; +import { styleText } from "node:util"; +import { logError, logInfo } from "../helpers/log"; +import { + requestDeviceCode, + pollForAccessToken, + getGitHubUser, + claimArchgateToken, + saveCredentials, + loadCredentials, + clearCredentials, +} from "../helpers/auth"; + +export function registerLoginCommand(program: Command) { + const login = program + .command("login") + .description("Authenticate with GitHub to access archgate plugins"); + + login.action(async () => { + try { + // Check if already logged in + const existing = await loadCredentials(); + if (existing) { + logInfo( + `Already logged in as ${styleText("bold", existing.github_user)}.`, + "Run `archgate login --refresh` to re-authenticate." + ); + return; + } + + await runDeviceFlow(); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + login + .command("status") + .description("Show current authentication status") + .action(async () => { + const creds = await loadCredentials(); + if (creds) { + console.log( + `Logged in as ${styleText("bold", creds.github_user)} (since ${creds.created_at})` + ); + } else { + console.log("Not logged in. Run `archgate login` to authenticate."); + } + }); + + login + .command("logout") + .description("Remove stored credentials") + .action(async () => { + await clearCredentials(); + console.log("Logged out successfully."); + }); + + login + .command("refresh") + .description("Re-authenticate and claim a new token") + .action(async () => { + try { + await clearCredentials(); + await runDeviceFlow(); + } catch (err) { + logError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); +} + +async function runDeviceFlow(): Promise { + console.log("Authenticating with GitHub...\n"); + + // Step 1: Request device code + const deviceCode = await requestDeviceCode(); + + console.log( + `Open ${styleText("bold", deviceCode.verification_uri)} in your browser` + ); + console.log( + `and enter the code: ${styleText(["bold", "green"], deviceCode.user_code)}\n` + ); + console.log("Waiting for authorization..."); + + // Step 2: Poll for access token + const githubToken = await pollForAccessToken( + deviceCode.device_code, + deviceCode.interval, + deviceCode.expires_in + ); + + // Step 3: Get GitHub username + const githubUser = await getGitHubUser(githubToken); + logInfo(`GitHub user: ${styleText("bold", githubUser)}`); + + // Step 4: Exchange GitHub token for archgate plugin token + console.log("Claiming archgate plugin token..."); + const archgateToken = await claimArchgateToken(githubToken); + + // Step 5: Store credentials + await saveCredentials({ + token: archgateToken, + github_user: githubUser, + created_at: new Date().toISOString().split("T")[0], + }); + + console.log( + `\nAuthenticated as ${styleText("bold", githubUser)}. Plugin access is now available.` + ); + console.log( + "Run `archgate init` to set up a project with the archgate plugin." + ); +} diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts new file mode 100644 index 00000000..041a3849 --- /dev/null +++ b/src/helpers/auth.ts @@ -0,0 +1,264 @@ +/** + * auth.ts — GitHub Device Flow authentication and archgate token management. + * + * Implements RFC 8628 (OAuth 2.0 Device Authorization Grant) for CLI login, + * plus local storage of the archgate plugin token in ~/.archgate/credentials. + */ + +import { unlinkSync } from "node:fs"; +import { internalPath, createPathIfNotExists } from "./paths"; +import { logDebug } from "./log"; + +// --------------------------------------------------------------------------- +// 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"; +const CREDENTIALS_FILE = "credentials"; + +/** + * 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 +// --------------------------------------------------------------------------- + +export interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +interface DeviceTokenSuccessResponse { + access_token: string; + token_type: string; + scope: string; +} + +interface DeviceTokenPendingResponse { + error: "authorization_pending" | "slow_down"; + error_description?: string; +} + +interface DeviceTokenErrorResponse { + error: "expired_token" | "access_denied" | string; + error_description?: string; +} + +type DeviceTokenResponse = + | DeviceTokenSuccessResponse + | DeviceTokenPendingResponse + | DeviceTokenErrorResponse; + +export interface StoredCredentials { + token: string; + github_user: string; + created_at: string; +} + +// --------------------------------------------------------------------------- +// 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 { + 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) { + throw new Error( + `GitHub device code request failed (HTTP ${response.status})` + ); + } + + return (await response.json()) as DeviceCodeResponse; +} + +/** + * Step 2: Poll GitHub until the user authorizes (or the code expires). + * Returns the GitHub access token on success. + */ +export async function pollForAccessToken( + deviceCode: string, + interval: number, + expiresIn: number +): Promise { + const deadline = Date.now() + expiresIn * 1000; + let pollInterval = interval; + + // eslint-disable-next-line 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 Error(`GitHub token poll failed (HTTP ${response.status})`); + } + + const data = (await response.json()) as DeviceTokenResponse; + + if ("access_token" in data) { + return data.access_token; + } + + if ("error" in data) { + if (data.error === "authorization_pending") { + continue; + } + if (data.error === "slow_down") { + pollInterval += 5; + continue; + } + // expired_token, access_denied, or other terminal error + throw new Error( + data.error_description ?? `GitHub authorization failed: ${data.error}` + ); + } + } + + throw new Error("Device code expired. Please try again."); +} + +/** + * Step 3: Get the authenticated GitHub username. + */ +export async function getGitHubUser(accessToken: string): Promise { + 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 Error(`Failed to fetch GitHub user (HTTP ${response.status})`); + } + + const data = (await response.json()) as { login?: string }; + if (!data.login) { + throw new Error("GitHub API did not return a username"); + } + return data.login; +} + +// --------------------------------------------------------------------------- +// 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 { + 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), + }); + + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: string; + }; + const message = + body.error ?? `Token claim failed (HTTP ${response.status})`; + throw new Error(message); + } + + const data = (await response.json()) as { token?: string }; + if (!data.token) { + throw new Error("Plugins service did not return a token"); + } + return data.token; +} + +// --------------------------------------------------------------------------- +// Credential Storage +// --------------------------------------------------------------------------- + +function credentialsPath(): string { + return internalPath(CREDENTIALS_FILE); +} + +/** + * Persist archgate credentials to ~/.archgate/credentials (JSON). + */ +export async function saveCredentials( + credentials: StoredCredentials +): Promise { + createPathIfNotExists(internalPath()); + await Bun.write( + credentialsPath(), + JSON.stringify(credentials, null, 2) + "\n" + ); + logDebug("Credentials saved to", credentialsPath()); +} + +/** + * Load stored archgate credentials, or null if none exist. + */ +export async function loadCredentials(): Promise { + const file = Bun.file(credentialsPath()); + if (!(await file.exists())) { + return null; + } + + try { + const data = (await file.json()) as StoredCredentials; + if (!data.token || !data.github_user) { + return null; + } + return data; + } catch { + logDebug("Failed to parse credentials file"); + return null; + } +} + +/** + * Remove stored credentials (logout). + */ +export async function clearCredentials(): Promise { + if (await Bun.file(credentialsPath()).exists()) { + unlinkSync(credentialsPath()); + logDebug("Credentials removed"); + } +} diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 6e616163..4187c77e 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -9,6 +9,16 @@ export type EditorTarget = "claude" | "cursor"; export interface InitOptions { editor?: EditorTarget; + /** When true, attempt to install the archgate plugin using stored credentials. */ + installPlugin?: boolean; +} + +export interface PluginResult { + installed: boolean; + /** For claude manual: marketplace URL; for cursor: file count summary */ + detail?: string; + /** When true, plugin was auto-installed via editor CLI (no manual steps needed). */ + autoInstalled?: boolean; } export interface InitResult { @@ -16,6 +26,7 @@ export interface InitResult { adrsDir: string; lintDir: string; editorSettingsPath: string; + plugin?: PluginResult; } /** @@ -80,10 +91,58 @@ Archgate standardizes \`.archgate/lint/\` as the location for linter rules that ? await configureCursorSettings(projectRoot) : await configureClaudeSettings(projectRoot); + // Plugin installation (optional — requires stored credentials) + let plugin: PluginResult | undefined; + if (options?.installPlugin) { + plugin = await tryInstallPlugin(projectRoot, editor); + } + return { projectRoot, adrsDir: paths.adrsDir, lintDir: paths.lintDir, editorSettingsPath, + plugin, }; } + +/** + * Attempt to install the archgate plugin using stored credentials. + * Returns null-safe result — never throws. + */ +async function tryInstallPlugin( + projectRoot: string, + editor: EditorTarget +): Promise { + const { loadCredentials } = await import("./auth"); + const credentials = await loadCredentials(); + if (!credentials) { + return { installed: false }; + } + + if (editor === "cursor") { + const { installCursorPlugin } = await import("./plugin-install"); + const files = await installCursorPlugin(projectRoot, credentials.token); + return { + installed: true, + autoInstalled: true, + detail: `Extracted ${files.length} files to .cursor/`, + }; + } + + // Claude Code — try auto-install via `claude` CLI, fall back to manual URL + const { isClaudeCliAvailable, installClaudePlugin, buildMarketplaceUrl } = + await import("./plugin-install"); + + if (await isClaudeCliAvailable()) { + try { + await installClaudePlugin(credentials); + return { installed: true, autoInstalled: true }; + } catch { + // Fall through to manual instructions + } + } + + const url = buildMarketplaceUrl(credentials); + return { installed: true, detail: url }; +} diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts new file mode 100644 index 00000000..8058d6fc --- /dev/null +++ b/src/helpers/plugin-install.ts @@ -0,0 +1,155 @@ +/** + * plugin-install.ts — Download and install the archgate plugin for supported editors. + * + * - Claude Code: auto-installs via `claude` CLI, or prints manual commands as fallback + * - Cursor: downloads cursor.tar.gz from the plugins service and extracts it + */ + +import { join } from "node:path"; +import { mkdirSync, unlinkSync } from "node:fs"; +import { $ } from "bun"; +import { logDebug } from "./log"; +import type { StoredCredentials } from "./auth"; + +const PLUGINS_API = "https://plugins.archgate.dev"; + +// --------------------------------------------------------------------------- +// Claude Code — CLI auto-install + manual fallback +// --------------------------------------------------------------------------- + +/** + * Build the authenticated git marketplace URL for Claude Code plugin installation. + */ +export function buildMarketplaceUrl(credentials: StoredCredentials): string { + return `https://${credentials.github_user}:${credentials.token}@plugins.archgate.dev/archgate.git`; +} + +/** + * Check whether the `claude` CLI is available on the system PATH. + */ +export async function isClaudeCliAvailable(): Promise { + const result = await $`claude --version`.nothrow().quiet(); + return result.exitCode === 0; +} + +/** + * Install the archgate plugin via the `claude` CLI. + * + * Runs: + * claude plugin marketplace add + * claude plugin install archgate@archgate + * + * Throws on failure so the caller can fall back to manual instructions. + */ +export async function installClaudePlugin( + credentials: StoredCredentials +): Promise { + const url = buildMarketplaceUrl(credentials); + + logDebug("Adding archgate marketplace to claude CLI"); + const addResult = await $`claude plugin marketplace add ${url}` + .nothrow() + .quiet(); + if (addResult.exitCode !== 0) { + throw new Error( + `claude plugin marketplace add failed (exit ${addResult.exitCode})` + ); + } + + logDebug("Installing archgate plugin via claude CLI"); + const installResult = await $`claude plugin install archgate@archgate` + .nothrow() + .quiet(); + if (installResult.exitCode !== 0) { + throw new Error( + `claude plugin install failed (exit ${installResult.exitCode})` + ); + } +} + +// --------------------------------------------------------------------------- +// Cursor — download and extract plugin bundle +// --------------------------------------------------------------------------- + +/** + * Download the cursor.tar.gz from the plugins service and extract it to the project root. + * Creates/overwrites .cursor/ folder contents with the pre-built agent and skills. + */ +export async function installCursorPlugin( + projectRoot: string, + token: string +): Promise { + const response = await fetch(`${PLUGINS_API}/api/cursor`, { + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": "archgate-cli", + }, + signal: AbortSignal.timeout(30_000), + }); + + if (response.status === 401) { + throw new Error( + "Plugin download unauthorized. Your token may have expired — run `archgate login refresh`." + ); + } + + if (!response.ok) { + throw new Error( + `Plugin download failed (HTTP ${response.status}). Try again later.` + ); + } + + const tarGzBuffer = await response.arrayBuffer(); + + logDebug( + `Downloaded cursor plugin archive (${Math.round(tarGzBuffer.byteLength / 1024)} KB)` + ); + + const extractedFiles = await extractTarGz( + new Uint8Array(tarGzBuffer), + projectRoot + ); + + return extractedFiles; +} + +/** + * Extract a .tar.gz buffer to a destination directory. + * Uses system tar (available on macOS, Linux, and Windows 10+). + */ +async function extractTarGz( + data: Uint8Array, + destDir: string +): Promise { + // Write the archive to a temporary file + const tmpArchive = join(destDir, ".archgate-cursor-plugin.tar.gz"); + await Bun.write(tmpArchive, data); + + try { + mkdirSync(destDir, { recursive: true }); + + // Extract using tar (available on macOS, Linux, and Windows 10+) + const result = await $`tar -xzf ${tmpArchive} -C ${destDir}`.nothrow(); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to extract plugin archive (tar exit code ${result.exitCode})` + ); + } + + // List extracted files for reporting + const listResult = await $`tar -tzf ${tmpArchive}`.nothrow().text(); + const files = listResult + .split("\n") + .map((f) => f.trim()) + .filter(Boolean); + + return files; + } finally { + try { + unlinkSync(tmpArchive); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts new file mode 100644 index 00000000..67a7714c --- /dev/null +++ b/tests/commands/login.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { Command } from "@commander-js/extra-typings"; +import { registerLoginCommand } from "../../src/commands/login"; + +describe("registerLoginCommand", () => { + test("registers 'login' as a subcommand", () => { + const program = new Command(); + registerLoginCommand(program); + const sub = program.commands.find((c) => c.name() === "login"); + expect(sub).toBeDefined(); + }); + + test("has a description", () => { + const program = new Command(); + registerLoginCommand(program); + const sub = program.commands.find((c) => c.name() === "login")!; + expect(sub.description()).toBeTruthy(); + }); + + test("registers status subcommand", () => { + const program = new Command(); + registerLoginCommand(program); + const login = program.commands.find((c) => c.name() === "login")!; + const status = login.commands.find((c) => c.name() === "status"); + expect(status).toBeDefined(); + }); + + test("registers logout subcommand", () => { + const program = new Command(); + registerLoginCommand(program); + const login = program.commands.find((c) => c.name() === "login")!; + const logout = login.commands.find((c) => c.name() === "logout"); + expect(logout).toBeDefined(); + }); + + test("registers refresh subcommand", () => { + const program = new Command(); + registerLoginCommand(program); + const login = program.commands.find((c) => c.name() === "login")!; + const refresh = login.commands.find((c) => c.name() === "refresh"); + expect(refresh).toBeDefined(); + }); +}); diff --git a/tests/helpers/auth.test.ts b/tests/helpers/auth.test.ts new file mode 100644 index 00000000..9cda859d --- /dev/null +++ b/tests/helpers/auth.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** Type-safe fetch mock — Bun's fetch type includes `preconnect` which mock() doesn't provide. */ +function mockFetch(handler: () => Promise) { + globalThis.fetch = mock(handler) as unknown as typeof fetch; +} + +describe("auth", () => { + let tempDir: string; + let originalHome: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-auth-test-")); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + }); + + afterEach(() => { + process.env.HOME = originalHome; + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("saveCredentials / loadCredentials", () => { + test("round-trips credentials to ~/.archgate/credentials", async () => { + const { saveCredentials, loadCredentials } = await import( + "../../src/helpers/auth" + ); + + await saveCredentials({ + token: "ag_beta_abc123", + github_user: "testuser", + created_at: "2026-01-15", + }); + + const loaded = await loadCredentials(); + expect(loaded).not.toBeNull(); + expect(loaded!.token).toBe("ag_beta_abc123"); + expect(loaded!.github_user).toBe("testuser"); + expect(loaded!.created_at).toBe("2026-01-15"); + }); + + test("returns null when no credentials file exists", async () => { + const { loadCredentials } = await import("../../src/helpers/auth"); + + const result = await loadCredentials(); + expect(result).toBeNull(); + }); + + test("returns null when credentials file is invalid JSON", async () => { + const { loadCredentials } = await import("../../src/helpers/auth"); + + const credPath = join(tempDir, ".archgate", "credentials"); + const { mkdirSync } = await import("node:fs"); + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + await Bun.write(credPath, "not-json"); + + const result = await loadCredentials(); + expect(result).toBeNull(); + }); + + test("returns null when credentials file is missing required fields", async () => { + const { loadCredentials } = await import("../../src/helpers/auth"); + + 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" })); + + const result = await loadCredentials(); + expect(result).toBeNull(); + }); + }); + + describe("clearCredentials", () => { + test("removes credentials file", async () => { + const { saveCredentials, clearCredentials, loadCredentials } = + await import("../../src/helpers/auth"); + + await saveCredentials({ + token: "ag_beta_abc123", + github_user: "testuser", + created_at: "2026-01-15", + }); + + await clearCredentials(); + const loaded = await loadCredentials(); + expect(loaded).toBeNull(); + }); + + test("does not throw when no credentials file exists", async () => { + const { clearCredentials } = await import("../../src/helpers/auth"); + + // Should not throw + await clearCredentials(); + }); + }); + + describe("requestDeviceCode", () => { + test("sends POST to GitHub device code endpoint", async () => { + const { requestDeviceCode } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => + Promise.resolve( + Response.json({ + device_code: "dc_123", + user_code: "ABCD-1234", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 5, + }) + ) + ); + + try { + 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); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("throws on non-200 response", async () => { + const { requestDeviceCode } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => + Promise.resolve(new Response("Bad Request", { status: 400 })) + ); + + try { + await expect(requestDeviceCode()).rejects.toThrow("HTTP 400"); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + describe("getGitHubUser", () => { + test("returns login from GitHub API", async () => { + const { getGitHubUser } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => Promise.resolve(Response.json({ login: "octocat" }))); + + try { + const user = await getGitHubUser("gho_test_token"); + expect(user).toBe("octocat"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("throws when GitHub API returns non-200", async () => { + const { getGitHubUser } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => + Promise.resolve(new Response("Unauthorized", { status: 401 })) + ); + + try { + await expect(getGitHubUser("bad_token")).rejects.toThrow("HTTP 401"); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + describe("claimArchgateToken", () => { + test("returns token from plugins service", async () => { + const { claimArchgateToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => + Promise.resolve(Response.json({ token: "ag_beta_claimed_token" })) + ); + + try { + const token = await claimArchgateToken("gho_github_token"); + expect(token).toBe("ag_beta_claimed_token"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("throws with error message from service on 403", async () => { + const { claimArchgateToken } = await import("../../src/helpers/auth"); + + const originalFetch = globalThis.fetch; + mockFetch(() => + Promise.resolve( + Response.json( + { error: "No approved signup found for this GitHub account" }, + { status: 403 } + ) + ) + ); + + try { + await expect(claimArchgateToken("gho_token")).rejects.toThrow( + "No approved signup" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); diff --git a/tests/helpers/plugin-install.test.ts b/tests/helpers/plugin-install.test.ts new file mode 100644 index 00000000..9a4fc696 --- /dev/null +++ b/tests/helpers/plugin-install.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { + buildMarketplaceUrl, + isClaudeCliAvailable, +} from "../../src/helpers/plugin-install"; + +describe("plugin-install", () => { + describe("buildMarketplaceUrl", () => { + test("builds URL with credentials embedded", () => { + const url = buildMarketplaceUrl({ + token: "ag_beta_abc123def456", + github_user: "octocat", + created_at: "2026-01-15", + }); + expect(url).toBe( + "https://octocat:ag_beta_abc123def456@plugins.archgate.dev/archgate.git" + ); + }); + + test("uses github_user as the URL username", () => { + const url = buildMarketplaceUrl({ + token: "ag_beta_token", + github_user: "my-handle", + created_at: "2026-02-01", + }); + expect(url).toContain("my-handle:"); + expect(url).toContain(":ag_beta_token@"); + }); + }); + + describe("isClaudeCliAvailable", () => { + test("returns a boolean", async () => { + const result = await isClaudeCliAvailable(); + expect(typeof result).toBe("boolean"); + }); + }); +});