From 67f029e18a2f78f0856c7f818a559c3233d716b3 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 11:08:41 +1000 Subject: [PATCH 01/10] Realign with MCP package version --- package.json | 2 +- packages/api-grade-core/package.json | 2 +- packages/api-grade-mcp/package.json | 2 +- packages/backstage-plugin-api-grade-backend/package.json | 2 +- packages/backstage-plugin-api-grade/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d0d30d4..bc4ff24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/api-grade", - "version": "0.2.0", + "version": "0.2.1", "description": "Grade API quality and share diagnostics using Spectral-compatible linting", "keywords": [ "api", diff --git a/packages/api-grade-core/package.json b/packages/api-grade-core/package.json index c8ea26f..0e3693a 100644 --- a/packages/api-grade-core/package.json +++ b/packages/api-grade-core/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/api-grade-core", - "version": "0.2.0", + "version": "0.2.1", "description": "Core grading library for api-grade — standalone, framework-agnostic", "keywords": [ "api", diff --git a/packages/api-grade-mcp/package.json b/packages/api-grade-mcp/package.json index 6090900..52ed53c 100644 --- a/packages/api-grade-mcp/package.json +++ b/packages/api-grade-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/api-grade-mcp", - "version": "0.1.4", + "version": "0.2.1", "description": "MCP server exposing api-grade capabilities for LLMs and agentic AI tooling", "keywords": [ "api", diff --git a/packages/backstage-plugin-api-grade-backend/package.json b/packages/backstage-plugin-api-grade-backend/package.json index 849afc9..61229b4 100644 --- a/packages/backstage-plugin-api-grade-backend/package.json +++ b/packages/backstage-plugin-api-grade-backend/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/backstage-plugin-api-grade-backend", - "version": "0.2.0", + "version": "0.2.1", "description": "Backstage backend plugin — grades API entity specs and returns results via HTTP", "keywords": [ "backstage", diff --git a/packages/backstage-plugin-api-grade/package.json b/packages/backstage-plugin-api-grade/package.json index f048041..5340628 100644 --- a/packages/backstage-plugin-api-grade/package.json +++ b/packages/backstage-plugin-api-grade/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/backstage-plugin-api-grade", - "version": "0.2.0", + "version": "0.2.1", "description": "Backstage frontend plugin — displays API quality grades on API entity pages", "keywords": [ "backstage", From 2c6607a133ad915271e3ca3eb9b1b940ab6d6ca8 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 12:03:54 +1000 Subject: [PATCH 02/10] Plan refactoring of existing MCP feature into core package and CLI. --- .specify/feature.json | 2 +- .../checklists/requirements.md | 41 +++ specs/008-cli-github-pat/spec.md | 278 ++++++++++++++++++ 3 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 specs/008-cli-github-pat/checklists/requirements.md create mode 100644 specs/008-cli-github-pat/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 632bf0f..4f13bc1 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/007-ai-support" + "feature_directory": "specs/008-cli-github-pat" } diff --git a/specs/008-cli-github-pat/checklists/requirements.md b/specs/008-cli-github-pat/checklists/requirements.md new file mode 100644 index 0000000..c396d96 --- /dev/null +++ b/specs/008-cli-github-pat/checklists/requirements.md @@ -0,0 +1,41 @@ +# Specification Quality Checklist: Shared GitHub PAT Ruleset Support for the CLI + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-21 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan` +- All items passed validation on first iteration. This feature is a refactor: the + GitHub PAT authentication, fetch-failure classification, and multi-level + ruleset-configuration logic already exist, tested, in `api-grade-mcp` + (specs/007-ai-support) and are being extracted into `api-grade-core` so the CLI + can consume them. A dedicated user story (User Story 3, P1) and FR-002/SC-003 + exist specifically to guard against behavioral regression in the MCP server + during extraction. diff --git a/specs/008-cli-github-pat/spec.md b/specs/008-cli-github-pat/spec.md new file mode 100644 index 0000000..16bc938 --- /dev/null +++ b/specs/008-cli-github-pat/spec.md @@ -0,0 +1,278 @@ +# Feature Specification: Shared GitHub PAT Ruleset Support for the CLI + +**Feature Branch**: `008-cli-github-pat` + +**Created**: 2026-06-21 + +**Status**: Draft + +**Input**: User description: "Add CLI support for rulesets hosted on GitHub private repos (via PAT)." Refined: the GitHub PAT ruleset-fetching and multi-level persistent ruleset configuration capability already exists in `api-grade-mcp`, is well tested, and must be extracted into `api-grade-core` so both the CLI and the MCP server consume one shared implementation. The MCP server's behavior (including its error handling, response shapes, and messages) must remain unchanged. The CLI gains new command-line options and persistent configuration capabilities to use the shared implementation. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Grade using a private-repo ruleset from the CLI (Priority: P1) + +A platform team stores their organisation's Spectral ruleset in a private GitHub +repository. A developer runs the api-grade CLI against an API specification, +supplying the private repository's ruleset URL and a GitHub Personal Access Token +(PAT). The CLI authenticates the request, fetches the ruleset, and grades the API +specification against it — using the same authentication and fetch-failure +classification logic already proven in the MCP server, now shared via the core +package. + +**Why this priority**: This is the capability the feature exists to deliver for CLI +users. Without it, organisations with private API governance rules cannot use the +CLI's custom ruleset support, even though the equivalent capability already works +in the MCP server. + +**Independent Test**: Can be fully tested by creating a private GitHub repository +containing a minimal valid Spectral ruleset, generating a PAT scoped to read that +repository, running the CLI with the ruleset's URL and the token supplied, and +confirming grading succeeds using that ruleset's rules. + +**Acceptance Scenarios**: + +1. **Given** a private GitHub repository containing a valid Spectral ruleset and a + PAT with read access to that repository, **When** the user runs the CLI with the + ruleset's URL and the token supplied, **Then** the CLI fetches the ruleset + successfully and grades the API specification using its rules. +2. **Given** the same private repository and ruleset, **When** the user runs the CLI + with the ruleset's URL but no token (or an invalid token), **Then** the CLI fails + the request, exits non-zero, and prints a clear error indicating that + authentication is required or failed — without leaking the token value in any + logged output. +3. **Given** a token supplied via the `GITHUB_TOKEN` environment variable, **When** + the user runs the CLI with a private ruleset URL and no token-related + command-line option, **Then** the CLI uses the environment variable token + automatically. + +--- + +### User Story 2 - Configure a persistent default ruleset for repeated CLI/CI runs (Priority: P2) + +A CI/CD pipeline (or a developer working locally) grades multiple API specifications +against the same private-repository ruleset across many invocations. Rather than +supplying `--ruleset` and a token on every command, the default ruleset and its +associated authentication are configured once — at workspace or global scope — and +every subsequent CLI invocation uses it automatically, mirroring the persistent +configuration scopes already available through the MCP server's configuration +tools. + +**Why this priority**: CI/CD enforcement of a minimum grade is one of the CLI's +primary use cases. Requiring `--ruleset` and a token on every invocation is +needless friction once the equivalent persistent-configuration capability already +exists and is proven in the MCP server; extending it to the CLI is the direct +payoff of sharing the implementation via core. + +**Independent Test**: Can be tested independently by configuring a default ruleset +and token at workspace scope, then running the CLI multiple times with no +`--ruleset` option, confirming every run uses the configured ruleset, and +confirming a global-scope default is used when no workspace default is configured. + +**Acceptance Scenarios**: + +1. **Given** a default ruleset (with associated GitHub PAT) configured at workspace + scope, **When** the CLI is run without a `--ruleset` option, **Then** the CLI + grades using the configured ruleset. +2. **Given** a default ruleset is configured at global scope only, **When** the CLI + is run in a workspace with no workspace-level default, **Then** the CLI falls + back to the global default. +3. **Given** both a workspace and a global default are configured, **When** the CLI + is run without `--ruleset`, **Then** the workspace-level default takes + precedence. +4. **Given** a workspace or global default is configured, **When** the CLI is run + with an explicit `--ruleset` option, **Then** the per-invocation `--ruleset` + value takes precedence over any configured default. +5. **Given** `GITHUB_TOKEN` is set in the environment, **When** the CLI is invoked + against a private ruleset URL (supplied directly or via configured default) with + no token-related command-line option or stored auth config, **Then** the CLI + uses the environment variable token automatically. + +--- + +### User Story 3 - MCP behavior is unaffected by the refactor (Priority: P1) + +The MCP server's GitHub PAT ruleset support, multi-level configuration tools +(`set-ruleset-config`, `get-ruleset-config`), and error responses are already +tested and trusted by AI tooling integrations. As the underlying implementation +moves into the shared core package, every existing MCP tool input, output, error +code, error message, and recovery-option payload must remain exactly as it was +before the refactor. + +**Why this priority**: Existing MCP consumers (Claude Code, GitHub Copilot) +integrate against the current tool contracts. Any behavioral drift — even a +rewording of an error message — could break downstream parsing or user-facing +guidance that AI tools have been built and verified against. This is as critical +as delivering the new CLI capability itself, since the refactor must not regress a +working feature to deliver a new one. + +**Independent Test**: Can be tested independently by running the MCP server's +existing automated test suite, unmodified in its assertions, against the +post-refactor implementation and confirming 100% of existing tests pass without +any change to expected inputs, outputs, or messages. + +**Acceptance Scenarios**: + +1. **Given** the MCP server's existing test suite for ruleset configuration and + GitHub PAT authentication, **When** the suite is run after the core-package + refactor, **Then** every test passes without modification to its assertions. +2. **Given** an auth failure, not-found failure, or network failure during a + ruleset fetch via the MCP server, **When** the failure occurs post-refactor, + **Then** the returned error code, message text, and recovery-option payload are + identical to pre-refactor behavior. +3. **Given** the `set-ruleset-config` and `get-ruleset-config` MCP tools, **When** + invoked post-refactor with the same inputs used before the refactor, **Then** + they return identical outputs. + +### Edge Cases + +- What happens when the supplied URL points to a public (not private) GitHub + repository and a token is also supplied? The token is used if provided and + grading proceeds normally; no error is raised for "unnecessary" authentication. +- What happens when the GitHub host is unreachable (network outage) rather than + returning an auth error? The CLI MUST distinguish this from an authentication + failure and report a network/connectivity error, using the same failure + classification (auth-failed / not-found / network-unreachable / config-invalid) + the core package already defines for the MCP server. +- What happens when the private ruleset reference does not specify a branch or + ref? The CLI defaults to the repository's default branch, consistent with + existing GitHub raw-content conventions already used by the core/MCP fetch logic. +- What happens to the MCP server's session-scope configuration, which has no direct + CLI equivalent (a CLI invocation is a single short-lived process, not a + long-running session)? Session scope remains an MCP-only concept; the CLI uses + only the workspace and global scopes already supported by the shared + configuration capability, and the core package's session-scope handling is left + unused — not removed — by the CLI. +- What happens to Microsoft Entra ID authentication, which the MCP server's config + schema already supports alongside GitHub PAT? Entra ID remains out of scope for + CLI exposure in this feature (addressed by Feature 10); the core extraction + preserves the existing generic auth-config shape so Entra support can be added to + the CLI later without another refactor. +- How does the CLI behave when a configured default ruleset cannot be fetched + (auth, access, or network failure)? The CLI MUST fail the invocation non-zero + with an error naming the failure category, distinct from (and reported before) a + grade-threshold failure, since grading cannot meaningfully occur without the + ruleset. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The GitHub PAT ruleset-fetch authentication logic, fetch-failure + classification (auth-failed / not-found / network-unreachable / config-invalid), + and multi-level ruleset configuration resolution (precedence across per-request, + workspace, and global scopes, plus the session scope used only by the MCP server) + currently implemented in `api-grade-mcp` MUST be extracted into `api-grade-core` + as a single shared implementation. +- **FR-002**: `api-grade-mcp` MUST be refactored to consume the extracted + capability from `api-grade-core` rather than maintaining its own copy, with no + change to any tool's observable input/output contract, error code, error message + text, or recovery-option payload. +- **FR-003**: The CLI MUST allow a custom ruleset to be supplied via a URL pointing + to a file within a private GitHub repository, using the existing `--ruleset` + option already used for local paths and public URLs. +- **FR-004**: The CLI MUST support supplying a GitHub PAT to authenticate a private + ruleset fetch, via (in order of precedence): (1) a command-line token option, (2) + the `GITHUB_TOKEN` environment variable, (3) auth configuration persisted at + workspace or global scope. The first configured source in this order is used. +- **FR-005**: The CLI MUST gain persistent ruleset configuration commands/options + allowing a default ruleset (and associated GitHub PAT) to be set at workspace + scope and at global scope, with workspace taking precedence over global, and an + explicit per-invocation `--ruleset` taking precedence over both — consistent with + the precedence rules already implemented for the MCP server's session, workspace, + and global scopes. +- **FR-006**: The CLI MUST send the resolved token as a Bearer token in the + `Authorization` HTTP header when fetching a ruleset from a GitHub host, using the + shared core implementation rather than a separate CLI-specific mechanism. +- **FR-007**: The CLI MUST NOT print, log, or otherwise expose a resolved token + value or a stored auth configuration's secret fields in any standard output, + error output, or verbose/debug trace. +- **FR-008**: The CLI MUST report ruleset fetch failures using the same failure + classification the core package defines (auth-failed / not-found / + network-unreachable / config-invalid), presented in a CLI-appropriate form + (human-readable stderr message, or structured JSON when `--format json` is used) + rather than the MCP server's structured recovery-options payload. +- **FR-009**: The CLI MUST exit with a non-zero status code and MUST NOT proceed + with partial or default-ruleset grading when a specified or configured private + ruleset cannot be fetched for any reason. +- **FR-010**: When no token is available (via any source) and a ruleset URL targets + a private repository, the CLI MUST report an authentication-required error rather + than a generic fetch failure. +- **FR-011**: This capability MUST apply uniformly for both OpenAPI and AsyncAPI + specification grading, consistent with the project's multi-format requirement. +- **FR-012**: This capability MUST work in both direct/local and containerised CLI + execution. Containerised execution documentation MUST describe how the token and + persisted workspace/global config are made available to the container (e.g., via + `-e GITHUB_TOKEN` and bind-mounting the workspace/home config directories). +- **FR-013**: If a ruleset URL does not specify a branch or ref, resolution MUST + default to the target repository's default branch. +- **FR-014**: The core package's extracted capability MUST remain dependency-light + and framework-agnostic, with no MCP-protocol-specific or CLI-specific types + leaking into its public interface, so that future consumers (beyond CLI and MCP) + can adopt it without depending on either. + +### Key Entities + +- **Private Ruleset Reference**: A URL identifying a Spectral-compatible ruleset + file hosted within a private GitHub repository, optionally including a + branch/ref. +- **GitHub Token**: A Personal Access Token credential, supplied via command-line + option, environment variable, or persisted auth configuration, used solely to + authenticate ruleset-fetch requests against GitHub hosts. +- **Ruleset Configuration**: A persisted record (workspace- or global-scoped, per + the existing MCP config model) pairing a default ruleset path with optional auth + credentials, now resolvable by both the CLI and the MCP server through the shared + core implementation. +- **Fetch Failure Classification**: One of auth-failed, not-found, + network-unreachable, or config-invalid — the shared, core-defined categorisation + of why a ruleset fetch did not succeed, consumed identically by CLI error + reporting and MCP error responses. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A developer with a valid PAT can grade an API specification against a + ruleset stored in a private GitHub repository from the CLI using only the + existing `--ruleset` option plus a token, with no additional setup beyond + generating the token. +- **SC-002**: A developer can configure a default private-repository ruleset once + (workspace or global scope) and have every subsequent CLI invocation in that + scope use it without resupplying `--ruleset` or the token. +- **SC-003**: 100% of the MCP server's existing automated tests covering ruleset + configuration, GitHub PAT authentication, and fetch-failure handling pass + unmodified after the core-package refactor. +- **SC-004**: 100% of CLI authentication and access failures during private-ruleset + fetch produce an error message that correctly distinguishes "invalid/missing + token" from "no access to repository" from "network failure," verified across the + CLI's automated test suite. +- **SC-005**: A token supplied via any CLI-supported source (CLI option, + environment variable, persisted config) never appears in CLI stdout, stderr, or + log output across the automated test suite, including in verbose/debug modes. +- **SC-006**: The GitHub PAT authentication and ruleset-resolution logic exists in + exactly one place in the codebase (the core package) after this feature, with + zero duplicated implementations between the CLI and the MCP server. + +## Assumptions + +- "GitHub private repos" refers to repositories on github.com (and GitHub + Enterprise Server instances) that require authentication to read; GitLab, + Bitbucket, and other private hosting providers are out of scope. +- The CLI adopts the same workspace (`.api-grade/config.json`) and global + (`~/.api-grade/config.json`) persisted configuration files and schema already + used by the MCP server for ruleset/auth defaults. The CLI's existing + `.apigrade.json` general-options file (minGrade, format, top, verbose) is + unaffected and remains separate from this ruleset/auth configuration. +- The MCP server's session scope (in-memory, per-server-process default with + recovery-option semantics) has no CLI equivalent and is not exposed by the CLI; + the CLI uses only the workspace and global scopes. +- Microsoft Entra ID-protected sources (SharePoint, OneDrive) remain out of scope + for CLI exposure in this feature; the core extraction preserves the existing + generic auth-config shape so this can be added later (Feature 10) without a + further refactor. +- Standard GitHub raw-content URL conventions (owner/repo/branch/path) are assumed + for resolving the ruleset file location within the repository. +- "No behavioral change to the MCP server" is verified via its existing automated + test suite; any test gaps in that suite are out of scope to backfill as part of + this feature, though new shared-core tests are expected to cover the extracted + logic. From 2a1f34e5c843378965a3a7e0c3fa9ab92b09b37b Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 12:12:22 +1000 Subject: [PATCH 03/10] Clarify Entra ID elements of specification --- .../checklists/requirements.md | 14 ++- specs/008-cli-github-pat/spec.md | 107 ++++++++++++++---- 2 files changed, 92 insertions(+), 29 deletions(-) diff --git a/specs/008-cli-github-pat/checklists/requirements.md b/specs/008-cli-github-pat/checklists/requirements.md index c396d96..2ebcbe2 100644 --- a/specs/008-cli-github-pat/checklists/requirements.md +++ b/specs/008-cli-github-pat/checklists/requirements.md @@ -33,9 +33,11 @@ - Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan` - All items passed validation on first iteration. This feature is a refactor: the - GitHub PAT authentication, fetch-failure classification, and multi-level - ruleset-configuration logic already exist, tested, in `api-grade-mcp` - (specs/007-ai-support) and are being extracted into `api-grade-core` so the CLI - can consume them. A dedicated user story (User Story 3, P1) and FR-002/SC-003 - exist specifically to guard against behavioral regression in the MCP server - during extraction. + GitHub PAT and Entra ID authentication, fetch-failure classification, and + multi-level ruleset-configuration logic already exist, tested, in + `api-grade-mcp` (specs/007-ai-support) and are being extracted into + `api-grade-core` so the CLI can consume them. A dedicated user story (User + Story 3, P1) and FR-002/SC-003 exist specifically to guard against behavioral + regression in the MCP server during extraction. Entra ID is extracted alongside + GitHub PAT but deliberately kept inaccessible at the CLI surface (User Story 4, + FR-015/FR-016, SC-007) as groundwork for a planned future CLI feature. diff --git a/specs/008-cli-github-pat/spec.md b/specs/008-cli-github-pat/spec.md index 16bc938..7ce4bce 100644 --- a/specs/008-cli-github-pat/spec.md +++ b/specs/008-cli-github-pat/spec.md @@ -6,7 +6,7 @@ **Status**: Draft -**Input**: User description: "Add CLI support for rulesets hosted on GitHub private repos (via PAT)." Refined: the GitHub PAT ruleset-fetching and multi-level persistent ruleset configuration capability already exists in `api-grade-mcp`, is well tested, and must be extracted into `api-grade-core` so both the CLI and the MCP server consume one shared implementation. The MCP server's behavior (including its error handling, response shapes, and messages) must remain unchanged. The CLI gains new command-line options and persistent configuration capabilities to use the shared implementation. +**Input**: User description: "Add CLI support for rulesets hosted on GitHub private repos (via PAT)." Refined: the GitHub PAT ruleset-fetching and multi-level persistent ruleset configuration capability already exists in `api-grade-mcp`, is well tested, and must be extracted into `api-grade-core` so both the CLI and the MCP server consume one shared implementation. The MCP server's behavior (including its error handling, response shapes, and messages) must remain unchanged. The CLI gains new command-line options and persistent configuration capabilities to use the shared implementation. The existing Microsoft Entra ID authentication capability is extracted into core alongside GitHub PAT (same no-regression requirement for the MCP server), but is deliberately kept inaccessible and undocumented at the CLI surface in this feature — laying groundwork for a planned future CLI feature rather than shipping Entra ID support to CLI users now. ## User Scenarios & Testing *(mandatory)* @@ -124,6 +124,44 @@ any change to expected inputs, outputs, or messages. invoked post-refactor with the same inputs used before the refactor, **Then** they return identical outputs. +### User Story 4 - CLI rejects Entra ID authentication explicitly (Priority: P3) + +A user tries to configure or invoke the CLI using a Microsoft Entra ID auth +configuration — for example, by setting `auth.type: "entra-id"` in a persisted +config file, or by passing an Entra-related command-line option — believing it is +supported because it is documented for the MCP server. Since Entra ID auth is +extracted into core but intentionally not wired up for CLI use yet, the CLI must +reject the attempt clearly rather than silently ignoring it, attempting an +unsupported flow, or producing a confusing low-level error. + +**Why this priority**: Without an explicit rejection, a user copying an MCP-style +Entra ID config into a CLI context would get a confusing failure (or, worse, a +silent fallback to the built-in ruleset) instead of understanding that the +capability is not yet available for the CLI. This is lower priority than the core +GitHub PAT delivery because it is a guardrail for a not-yet-offered capability, not +a capability itself. + +**Independent Test**: Can be tested independently by configuring an +`auth.type: "entra-id"` entry in a workspace or global config file (or supplying an +equivalent command-line option, if one exists), running the CLI, and confirming it +exits non-zero with a clear "unsupported authentication type" error rather than +attempting an Entra ID flow or falling back silently. + +**Acceptance Scenarios**: + +1. **Given** a workspace or global config file with `auth.type: "entra-id"` set as + the default ruleset's auth configuration, **When** the CLI is run without an + explicit `--ruleset` override, **Then** the CLI exits non-zero with an error + stating that Entra ID authentication is not supported by the CLI. +2. **Given** a command-line option intended to select Entra ID authentication (if + exposed in argument parsing at all), **When** the CLI is run with that option, + **Then** the CLI exits non-zero with the same unsupported-authentication-type + error rather than attempting any Entra ID device-code or token flow. +3. **Given** an unsupported-auth-type rejection has occurred, **When** the error is + reported, **Then** it does not attempt to fetch the ruleset, does not fall back + to the built-in default ruleset silently, and does not partially apply any other + configured options. + ### Edge Cases - What happens when the supplied URL points to a public (not private) GitHub @@ -144,10 +182,15 @@ any change to expected inputs, outputs, or messages. configuration capability, and the core package's session-scope handling is left unused — not removed — by the CLI. - What happens to Microsoft Entra ID authentication, which the MCP server's config - schema already supports alongside GitHub PAT? Entra ID remains out of scope for - CLI exposure in this feature (addressed by Feature 10); the core extraction - preserves the existing generic auth-config shape so Entra support can be added to - the CLI later without another refactor. + schema already supports alongside GitHub PAT? The Entra ID auth logic is + extracted into core (same as GitHub PAT) so the MCP server keeps working + unchanged, but it is not wired up to any CLI command-line option or made part of + CLI documentation. If a config file or command-line input nonetheless specifies + Entra ID as the auth type for a CLI invocation, the CLI MUST reject it with an + explicit "unsupported authentication type" error and exit non-zero, rather than + attempting the flow or ignoring the field. This groundwork is intended to make a + planned future CLI feature (full Entra ID support, Feature 10) easier to deliver + without another core refactor. - How does the CLI behave when a configured default ruleset cannot be fetched (auth, access, or network failure)? The CLI MUST fail the invocation non-zero with an error naming the failure category, distinct from (and reported before) a @@ -158,16 +201,18 @@ any change to expected inputs, outputs, or messages. ### Functional Requirements -- **FR-001**: The GitHub PAT ruleset-fetch authentication logic, fetch-failure - classification (auth-failed / not-found / network-unreachable / config-invalid), - and multi-level ruleset configuration resolution (precedence across per-request, - workspace, and global scopes, plus the session scope used only by the MCP server) - currently implemented in `api-grade-mcp` MUST be extracted into `api-grade-core` - as a single shared implementation. +- **FR-001**: The GitHub PAT and Microsoft Entra ID ruleset-fetch authentication + logic, fetch-failure classification (auth-failed / not-found / + network-unreachable / config-invalid), and multi-level ruleset configuration + resolution (precedence across per-request, workspace, and global scopes, plus the + session scope used only by the MCP server) currently implemented in + `api-grade-mcp` MUST be extracted into `api-grade-core` as a single shared + implementation, covering both auth types even though the CLI exposes only one of + them in this feature. - **FR-002**: `api-grade-mcp` MUST be refactored to consume the extracted - capability from `api-grade-core` rather than maintaining its own copy, with no - change to any tool's observable input/output contract, error code, error message - text, or recovery-option payload. + capability (both GitHub PAT and Entra ID auth) from `api-grade-core` rather than + maintaining its own copy, with no change to any tool's observable input/output + contract, error code, error message text, or recovery-option payload. - **FR-003**: The CLI MUST allow a custom ruleset to be supplied via a URL pointing to a file within a private GitHub repository, using the existing `--ruleset` option already used for local paths and public URLs. @@ -210,6 +255,14 @@ any change to expected inputs, outputs, or messages. and framework-agnostic, with no MCP-protocol-specific or CLI-specific types leaking into its public interface, so that future consumers (beyond CLI and MCP) can adopt it without depending on either. +- **FR-015**: The CLI MUST NOT expose any documented command-line option or + documented config-file field for selecting Microsoft Entra ID as an auth type. +- **FR-016**: If a config file or command-line input resolved by the CLI specifies + an auth type other than GitHub PAT (e.g., `entra-id`) for the active ruleset + configuration, the CLI MUST exit non-zero with an explicit + unsupported-authentication-type error, MUST NOT attempt the Entra ID + authentication flow, and MUST NOT silently fall back to the built-in default + ruleset. ### Key Entities @@ -220,9 +273,10 @@ any change to expected inputs, outputs, or messages. option, environment variable, or persisted auth configuration, used solely to authenticate ruleset-fetch requests against GitHub hosts. - **Ruleset Configuration**: A persisted record (workspace- or global-scoped, per - the existing MCP config model) pairing a default ruleset path with optional auth - credentials, now resolvable by both the CLI and the MCP server through the shared - core implementation. + the existing MCP config model) pairing a default ruleset path with an optional + auth configuration (GitHub PAT or Entra ID), now resolvable by both the CLI and + the MCP server through the shared core implementation. The CLI only acts on + GitHub PAT auth configurations; it rejects Entra ID ones. - **Fetch Failure Classification**: One of auth-failed, not-found, network-unreachable, or config-invalid — the shared, core-defined categorisation of why a ruleset fetch did not succeed, consumed identically by CLI error @@ -249,9 +303,14 @@ any change to expected inputs, outputs, or messages. - **SC-005**: A token supplied via any CLI-supported source (CLI option, environment variable, persisted config) never appears in CLI stdout, stderr, or log output across the automated test suite, including in verbose/debug modes. -- **SC-006**: The GitHub PAT authentication and ruleset-resolution logic exists in - exactly one place in the codebase (the core package) after this feature, with - zero duplicated implementations between the CLI and the MCP server. +- **SC-006**: The GitHub PAT and Entra ID authentication and ruleset-resolution + logic exists in exactly one place in the codebase (the core package) after this + feature, with zero duplicated implementations between the CLI and the MCP + server. +- **SC-007**: 100% of CLI invocations that resolve to an Entra ID auth + configuration (via config file or command-line input) exit non-zero with an + unsupported-authentication-type error, verified across the CLI's automated test + suite, with no Entra ID flow attempted and no silent fallback. ## Assumptions @@ -267,9 +326,11 @@ any change to expected inputs, outputs, or messages. recovery-option semantics) has no CLI equivalent and is not exposed by the CLI; the CLI uses only the workspace and global scopes. - Microsoft Entra ID-protected sources (SharePoint, OneDrive) remain out of scope - for CLI exposure in this feature; the core extraction preserves the existing - generic auth-config shape so this can be added later (Feature 10) without a - further refactor. + for CLI exposure in this feature. The underlying Entra ID auth logic is extracted + into core alongside GitHub PAT (so the MCP server has no behavioral regression + and so Feature 10 can wire up full CLI support later without another refactor), + but the CLI deliberately rejects any attempt to use it rather than exposing a + partial or undocumented implementation. - Standard GitHub raw-content URL conventions (owner/repo/branch/path) are assumed for resolving the ruleset file location within the repository. - "No behavioral change to the MCP server" is verified via its existing automated From c2e7f2e982b59c6cbd231a9e433b85a1835d5bfa Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 12:43:43 +1000 Subject: [PATCH 04/10] Initial plan --- CLAUDE.md | 2 +- .../contracts/cli-options.md | 102 ++++++++++ .../contracts/core-package-api.md | 127 +++++++++++++ specs/008-cli-github-pat/data-model.md | 85 +++++++++ specs/008-cli-github-pat/plan.md | 174 ++++++++++++++++++ specs/008-cli-github-pat/quickstart.md | 90 +++++++++ specs/008-cli-github-pat/research.md | 147 +++++++++++++++ 7 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 specs/008-cli-github-pat/contracts/cli-options.md create mode 100644 specs/008-cli-github-pat/contracts/core-package-api.md create mode 100644 specs/008-cli-github-pat/data-model.md create mode 100644 specs/008-cli-github-pat/plan.md create mode 100644 specs/008-cli-github-pat/quickstart.md create mode 100644 specs/008-cli-github-pat/research.md diff --git a/CLAUDE.md b/CLAUDE.md index 7bc5146..9e1821b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan -at specs/007-ai-support/plan.md +at specs/008-cli-github-pat/plan.md diff --git a/specs/008-cli-github-pat/contracts/cli-options.md b/specs/008-cli-github-pat/contracts/cli-options.md new file mode 100644 index 0000000..a910f4a --- /dev/null +++ b/specs/008-cli-github-pat/contracts/cli-options.md @@ -0,0 +1,102 @@ +# Contract: CLI Options & Subcommands — GitHub PAT Ruleset Support + +This documents the command-line surface added/changed by this feature. It is the +contract the CLI implementation and its tests must satisfy. + +## `api-grade ` (existing command, extended) + +New/changed options: + +| Option | Type | Description | +|---|---|---| +| `--ruleset ` | string | **Unchanged** signature. Now additionally accepts a URL pointing into a private GitHub repository (FR-003). Highest-precedence ruleset source (per-request). | +| `--token ` | string | **NEW**. GitHub Personal Access Token used to authenticate a `--ruleset` URL fetch, or a resolved workspace/global default's URL fetch. Highest-precedence token source (FR-004). Never echoed in any output (FR-007). | + +Unchanged options (`--min-grade`, `--format`, `--top`, `--verbose`) behave exactly as +before; `--url` remains reserved/unsupported (unchanged early-exit behavior). + +### Token resolution (FR-004) + +Order, first match wins: +1. `--token ` command-line option +2. `GITHUB_TOKEN` environment variable +3. `auth.githubToken` field of the `RulesetConfig` at whichever scope the ruleset was + resolved from (workspace or global) — set via `config set-ruleset` + +### Ruleset resolution (FR-005, unchanged precedence from MCP) + +Order, first match wins: +1. `--ruleset ` (per-request) +2. Workspace config (`.api-grade/config.json`) +3. Global config (`~/.api-grade/config.json`) +4. Built-in default ruleset + +### Exit behavior on ruleset/auth failure (FR-009, FR-010, FR-016) + +- Private URL + no usable token (any source) → exit 1, "authentication required" + message — distinct wording from a generic fetch failure (FR-010). +- Private URL + invalid/rejected token → exit 1, `auth-failed` classification. +- URL not found / no access → exit 1, `not-found` classification. +- Host unreachable → exit 1, `network-unreachable` classification. +- Resolved auth config malformed → exit 1, `config-invalid` classification. +- Resolved auth `type === 'entra-id'` → exit 1, unsupported-authentication-type error + (FR-016) — checked and reported *before* any fetch attempt. +- In every failure case above: no grading occurs, no fallback to the built-in ruleset, + process exits non-zero (FR-009). + +### Output shape on fetch/auth failure + +**Human (`--format human`, default)** — stderr, e.g.: +``` +Error: Could not fetch ruleset from '' ( default): the credentials were rejected (401/403). +``` + +**JSON (`--format json`)** — stdout, single object, process still exits 1: +```json +{ + "error": "RULESET_AUTH_FAILED", + "failureReason": "auth-failed", + "rulesetUrl": "", + "scope": "workspace", + "message": "Could not fetch ruleset from '' (workspace default): the credentials were rejected (401/403)." +} +``` + +`error` values mirror the MCP's existing `ERROR_CODES` constants for the equivalent +classification (`RULESET_AUTH_FAILED`, `RULESET_NOT_FOUND`, `RULESET_INVALID_HOST`, +`RULESET_BAD_CONFIG`) plus a CLI-only `UNSUPPORTED_AUTH_TYPE` for the Entra ID +rejection case. No `recoveryOptions` or `instructions` fields are present (FR-008). + +## `api-grade config set-ruleset` (NEW subcommand) + +| Option | Type | Required | Description | +|---|---|---|---| +| `--scope ` | string | yes | Which persisted config file to write. | +| `--ruleset ` | string | no | Path or URL to set as the default. Omit to clear the default at that scope. | +| `--token ` | string | no | GitHub PAT to persist alongside the ruleset (stored under `auth.githubToken`, `auth.type: 'github-pat'`). Omit to leave/clear auth. | + +Writes via core's `saveWorkspaceConfig`/`saveGlobalConfig` to the same file the MCP +server's `set-ruleset-config` tool writes — both surfaces interoperate on the same +file. Does not expose `--auth-type entra-id` or any equivalent (FR-015). + +On success, prints a one-line confirmation (human) or `{ scope, rulesetPath, +configFile }` (JSON) — never the token value. + +## `api-grade config get-ruleset` (NEW subcommand) + +No options. Reads workspace and global config, resolves the effective ruleset (using +the same `resolveRuleset` core function, with the CLI's inert `SessionState`), and +prints: + +- Human: effective scope + path, plus per-scope (workspace/global) values, token + presence indicated only as `(token configured)` / `(no token)` / `(from + GITHUB_TOKEN)` — never the value. +- JSON: `{ effective: { scope, rulesetPath }, workspace: {...} | null, global: {...} + | null, builtIn: 'default' }` — same redaction rule as MCP's `get-ruleset-config` + `sanitizeAuth` (`tokenSource` field, not the token). + +If the effective resolution's auth type is `entra-id`, this command reports it +explicitly as unsupported-by-CLI in its output (informational only here — this read +path does not exit non-zero on its own, since no fetch is attempted; rejection with a +non-zero exit applies specifically to the grading command path per FR-016 Acceptance +Scenario 1). diff --git a/specs/008-cli-github-pat/contracts/core-package-api.md b/specs/008-cli-github-pat/contracts/core-package-api.md new file mode 100644 index 0000000..f8974cb --- /dev/null +++ b/specs/008-cli-github-pat/contracts/core-package-api.md @@ -0,0 +1,127 @@ +# Contract: `api-grade-core` Public Surface Additions + +This documents the new exports `packages/api-grade-core/src/index.ts` must provide so +both `api-grade-mcp` and the CLI can consume one shared implementation (FR-001, +FR-014). All listed members are **moved verbatim** (same name, signature, and +behavior) from their current `api-grade-mcp/src/...` locations — this contract exists +to pin that no signature drifts during the move. + +## From `auth/github.ts` + +```ts +export const INITIAL_FETCH_TIMEOUT_MS: number; // 5_000 +export const RETRY_FETCH_TIMEOUT_MS: number; // 30_000 + +export class RulesetAuthError extends Error { + readonly reason: 'auth-failed' | 'not-found' | 'network-unreachable'; + readonly url: string; +} + +export function fetchRulesetContent( + url: string, + token: string | undefined, + timeoutMs: number +): Promise; + +export function fetchRulesetWithGithubPat( + url: string, + token: string, + timeoutMs: number +): Promise; +``` + +## From `auth/entra.ts` + +```ts +export class EntraAuthRequired extends Error { + readonly userCode: string; + readonly verificationUri: string; + readonly expiresIn: number; +} + +export function acquireEntraToken(tenantId: string, clientId: string): Promise; +``` + +(The CLI does not call `acquireEntraToken` — it only needs to detect `auth.type === +'entra-id'` and reject. This export exists so MCP can keep importing it from core.) + +## From `config/ruleset-config.ts` + +```ts +export class ConfigWriteError extends Error { + readonly code: 'CONFIG_WRITE_ERROR'; + readonly cause?: unknown; +} + +export function getWorkspaceConfigPath(): string; // join(cwd(), '.api-grade', 'config.json') +export function getGlobalConfigPath(): string; // join(homedir(), '.api-grade', 'config.json') +export function loadWorkspaceConfig(): Promise; +export function loadGlobalConfig(): Promise; +export function saveWorkspaceConfig(config: RulesetConfig): Promise; +export function saveGlobalConfig(config: RulesetConfig): Promise; +``` + +## From `config/resolve-ruleset.ts` + +```ts +export function resolveRuleset( + perRequestPath: string | undefined | null, + sessionState: SessionState, + workspaceConfig: RulesetConfig | null, + globalConfig: RulesetConfig | null +): RulesetResolution; +``` + +## From `types.ts` (new exported types) + +```ts +export interface AuthConfig { + type: 'github-pat' | 'entra-id'; + githubToken?: string; + tenantId?: string; + clientId?: string; +} + +export interface RulesetConfig { + rulesetPath: string | null; + auth: AuthConfig | null; +} + +export type RulesetScope = 'per-request' | 'session' | 'workspace' | 'global' | 'built-in'; + +export interface RulesetResolution { + rulesetPath: string | null; + scope: RulesetScope; + auth: AuthConfig | null; +} + +export interface SessionState { + defaultRuleset: RulesetConfig | null; + sessionRulesetOverride: 'builtin' | null; +} +``` + +## Non-goals for this contract + +- `RecoveryOptionId` / `RecoveryOption` and the MCP error-response builders + (`mcpError`, `buildRulesetFetchFailureResponse`, `describeFetchFailureReason`, + `errorCodeForFailureReason`, `ERROR_CODES`) stay in `api-grade-mcp/src/utils/errors.ts` + — they are MCP-protocol-shaped (tool `content`/`isError` envelopes) and explicitly + excluded from core by FR-014. The CLI implements its own minimal, separate mapping + from `RulesetAuthError.reason` / `'config-invalid'` to CLI output (see + `contracts/cli-options.md`), reusing only the *reason strings*, not MCP's response + builders. +- `GradeEngine`'s existing `rulesetUrl`/`rulesetToken` fields on `GradeRequest` are + unchanged and unused by this feature (see research.md R3 for why the CLI fetches via + `fetchRulesetContent` + a temp file instead, matching MCP's `grade.ts` pattern). + +## Verification + +- `packages/api-grade-mcp/src/tools/*.ts` and `packages/api-grade-mcp/src/utils/*.ts` + import the above exclusively from `@dawmatt/api-grade-core` after the refactor — + zero remaining files under `packages/api-grade-mcp/src/auth/` or + `packages/api-grade-mcp/src/config/`. +- `packages/api-grade-mcp`'s existing test suite (`tests/unit/github.test.ts`, + `tests/unit/resolve-ruleset.test.ts`, `tests/unit/ruleset-config.test.ts`, and all + `tests/integration/*.test.ts`) passes with **no assertion changes** — only import + path updates are permitted in test files, per SC-003. diff --git a/specs/008-cli-github-pat/data-model.md b/specs/008-cli-github-pat/data-model.md new file mode 100644 index 0000000..0a6db52 --- /dev/null +++ b/specs/008-cli-github-pat/data-model.md @@ -0,0 +1,85 @@ +# Phase 1 Data Model: Shared GitHub PAT Ruleset Support for the CLI + +All entities below already exist in `api-grade-mcp/src/types.ts` and are relocated +(not redefined) into `api-grade-core/src/types.ts` as part of this feature, per +FR-001/FR-002. No field is added, removed, or renamed — this is an extraction, not a +redesign, to guarantee MCP behavioral parity (SC-003). + +## AuthConfig + +Describes how to authenticate a ruleset fetch. + +| Field | Type | Notes | +|---|---|---| +| `type` | `'github-pat' \| 'entra-id'` | Discriminator. The CLI only *acts on* `'github-pat'`; resolving to `'entra-id'` is a rejection condition (FR-016). | +| `githubToken` | `string?` | Present only for `type: 'github-pat'`. Never logged, printed, or serialized to stdout/stderr (FR-007). Falls back to `GITHUB_TOKEN` env var when absent. | +| `tenantId` | `string?` | Present only for `type: 'entra-id'`. Unused by the CLI beyond detecting rejection. | +| `clientId` | `string?` | Present only for `type: 'entra-id'`. Unused by the CLI beyond detecting rejection. | + +## RulesetConfig + +A persisted (workspace- or global-scope) record pairing a default ruleset with +optional auth. + +| Field | Type | Notes | +|---|---|---| +| `rulesetPath` | `string \| null` | Absolute/relative file path, or HTTPS URL. `null` clears the default at that scope. | +| `auth` | `AuthConfig \| null` | Optional auth paired with `rulesetPath`. | + +Persisted as JSON at `.api-grade/config.json` (workspace, resolved from +`process.cwd()`) or `~/.api-grade/config.json` (global) — identical paths/format the +MCP server already reads/writes, so configuration set via either surface (MCP tool or +CLI subcommand) is interoperable. + +## RulesetScope + +`'per-request' | 'session' | 'workspace' | 'global' | 'built-in'` + +The CLI exercises `'per-request'` (via `--ruleset`), `'workspace'`, `'global'`, and +`'built-in'`. `'session'` is reachable in the type but never produced by the CLI, +since the CLI always passes an inert `SessionState` (see research.md R2) — it is not +removed from the shared type because the MCP server still relies on it (per the +spec's edge case on session-scope being MCP-only, left unused not removed). + +## RulesetResolution + +| Field | Type | Notes | +|---|---|---| +| `rulesetPath` | `string \| null` | The resolved path/URL, or `null` for built-in. | +| `scope` | `RulesetScope` | Which level produced this resolution — used in CLI error messages and `config get-ruleset` output. | +| `auth` | `AuthConfig \| null` | Auth paired with the resolved ruleset, if any. | + +Produced by `resolveRuleset(perRequestPath, sessionState, workspaceConfig, +globalConfig)`, unchanged precedence order: per-request → session → workspace → +global → built-in (FR-005). + +## SessionState + +| Field | Type | Notes | +|---|---|---| +| `defaultRuleset` | `RulesetConfig \| null` | Always `null` for every CLI invocation (no session concept — fresh inert object constructed per run). | +| `sessionRulesetOverride` | `'builtin' \| null` | Always `null` for the CLI. | + +## Fetch Failure Classification + +`'auth-failed' | 'not-found' | 'network-unreachable' | 'config-invalid'` + +Carried by core's `RulesetAuthError` (first three reasons, thrown from +`fetchRulesetContent`) plus a CLI-local `'config-invalid'` case raised when a resolved +`auth.type === 'github-pat'` has no usable token from any source (CLI option, env var, +stored config) for a URL requiring auth, or when stored auth JSON is structurally +invalid. Consumed identically by CLI error reporting (human/JSON) and (pre-existing, +unchanged) MCP error responses — FR-008. + +## New CLI-only concepts (not persisted, not shared with MCP) + +These exist solely in `src/cli/` to adapt the shared core types to a CLI surface — +they are not added to `api-grade-core` since they have no MCP equivalent and FR-014 +requires core stay free of CLI-specific types. + +| Concept | Shape | Purpose | +|---|---|---| +| Token resolution order | `--token` CLI option → `GITHUB_TOKEN` env var → resolved scope's `auth.githubToken` | Implements FR-004's required precedence. | +| CLI fetch-failure output (human) | stderr string, reason-specific wording | FR-008 human-readable form. | +| CLI fetch-failure output (JSON) | `{ error, failureReason, rulesetUrl, scope, message }` printed to stdout when `--format json` | FR-008 machine-readable form; deliberately omits MCP's `recoveryOptions`/`instructions`. | +| Unsupported-auth-type error | stderr string + exit code 1, no JSON variant beyond `{ error: 'UNSUPPORTED_AUTH_TYPE', message }` | FR-016/SC-007. | diff --git a/specs/008-cli-github-pat/plan.md b/specs/008-cli-github-pat/plan.md new file mode 100644 index 0000000..c35883b --- /dev/null +++ b/specs/008-cli-github-pat/plan.md @@ -0,0 +1,174 @@ +# Implementation Plan: Shared GitHub PAT Ruleset Support for the CLI + +**Branch**: `008-cli-github-pat` | **Date**: 2026-06-21 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/008-cli-github-pat/spec.md` + +**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow. + +## Summary + +Extract the GitHub PAT (and Entra ID) ruleset-fetch authentication, fetch-failure +classification, and multi-level configuration-resolution logic currently living in +`api-grade-mcp` into `api-grade-core`, with zero behavioral change to the MCP server. +Refactor `api-grade-mcp` to consume the extracted modules. Then extend the CLI +(`src/cli`) to consume the same core modules, adding a `--token` option, `GITHUB_TOKEN` +env var support, and new `config` subcommands (`config set-ruleset` / `config +get-ruleset`) for workspace/global persistent ruleset+auth defaults — mirroring the +MCP's `set-ruleset-config`/`get-ruleset-config` tools but using CLI-appropriate +input/output (no session scope, no recovery-options payload). The CLI explicitly +rejects `entra-id` auth configurations with a clear error rather than attempting or +silently ignoring them. + +## Technical Context + +**Language/Version**: TypeScript 5.4 (Node.js >=20), ES modules throughout. + +**Primary Dependencies**: `@dawmatt/api-grade-core` (shared logic), `commander` +(CLI argument parsing, already in use), `@azure/msal-node` (Entra ID device-code +flow — moves from `api-grade-mcp` to `api-grade-core`), `zod` (stays in +`api-grade-mcp` only; core remains dependency-light per FR-014). + +**Storage**: JSON config files on local disk — `.api-grade/config.json` (workspace) +and `~/.api-grade/config.json` (global), schema unchanged from the existing MCP +`RulesetConfig`/`AuthConfig` shape. The CLI's separate `.apigrade.json` general-options +file is untouched. + +**Testing**: Vitest (`vitest run`), consistent with all existing packages. New unit +tests for the extracted core modules; existing MCP unit/integration tests must pass +unmodified (assertion-for-assertion) post-refactor; new CLI integration tests for +`--token`, `GITHUB_TOKEN`, `config set-ruleset`/`config get-ruleset`, precedence, and +Entra ID rejection. + +**Target Platform**: Cross-platform Node.js CLI (Windows/macOS minimum, per +Constitution V), local and containerised (Docker) execution. + +**Project Type**: Monorepo library + CLI + MCP server (existing `packages/*` + +root `src/cli` structure). + +**Performance Goals**: N/A — network-bound by GitHub fetch latency; no new +performance-sensitive code path introduced. + +**Constraints**: Core package must stay framework-agnostic (FR-014: no MCP-protocol +or CLI-specific types in its public interface). Token values must never be logged +(FR-007/SC-005, enforced by never including secret fields in any printed/serialized +CLI output, including verbose traces). + +**Scale/Scope**: Single-developer-facing CLI invocations and CI pipeline runs; no +concurrency or multi-tenancy concerns. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **Principle I (Multi-Format)**: PASS. Auth/config/fetch logic is format-agnostic; + ruleset content is consumed identically by `GradeEngine` regardless of OpenAPI vs + AsyncAPI (FR-011). No format-specific branching introduced. +- **Principle II (Core-First Architecture)**: PASS — this is the entire point of the + feature (FR-001, FR-002, SC-006: exactly one implementation, shared by CLI and MCP). +- **Principle III (Spectral-Ruleset Based Grading)**: PASS. Custom ruleset supply via + secured location is exactly what this feature adds for the CLI; grading algorithm + itself is unchanged. +- **Principle IV (Test-Driven Quality)**: PASS, with an explicit gate: MCP's existing + test suite must pass **unmodified** (SC-003), and new core/CLI tests are required + (SC-004, SC-005, SC-007). Tests for the extracted core modules will be written + alongside the extraction (moved/adapted from `api-grade-mcp/tests/unit/github.test.ts`, + `resolve-ruleset.test.ts`, `ruleset-config.test.ts`). +- **Principle V (Cross-Platform & Zero-Cost)**: PASS. No new paid dependency; PAT + generation is free; `@azure/msal-node` (already a dependency) is reused, not added. + Containerised execution documented per FR-012. +- **Principle VI (Educational Excellence)**: N/A — no new sample APIs or diagnostic + copy introduced by this feature. +- **AI Integration Requirements**: N/A for new behavior — MCP tool contracts + (`set-ruleset-config`, `get-ruleset-config`, `grade-api`, etc.) are required to stay + byte-identical (FR-002, SC-003), so no Claude Code / Copilot re-verification is + needed beyond confirming the existing MCP test suite (which already covers these + tools) still passes. +- **CI/CD Integration Requirements**: PASS — feature directly extends CI/CD usability + (persistent config for pipelines, FR-005/SC-002) and preserves existing `--min-grade` + / JSON output / non-zero exit behavior; adds new non-zero exit paths for fetch + failures (FR-009) and unsupported-auth-type rejections (FR-016), consistent with the + "non-zero on failure" requirement. + +**Result**: No violations. No Complexity Tracking entries required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-cli-github-pat/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +packages/api-grade-core/src/ +├── auth/ +│ ├── github.ts # MOVED from api-grade-mcp/src/auth/github.ts +│ │ # (fetchRulesetContent, fetchRulesetWithGithubPat, +│ │ # RulesetAuthError, timeout constants) +│ └── entra.ts # MOVED from api-grade-mcp/src/auth/entra.ts +│ # (acquireEntraToken, EntraAuthRequired) +├── config/ +│ ├── ruleset-config.ts # MOVED from api-grade-mcp/src/config/ruleset-config.ts +│ │ # (load/save workspace+global RulesetConfig, paths) +│ └── resolve-ruleset.ts # MOVED from api-grade-mcp/src/config/resolve-ruleset.ts +│ # (precedence resolution; session scope param stays, +│ # unused by CLI per spec's session-scope note) +├── types.ts # EXTENDED: add AuthConfig, RulesetConfig, +│ # RulesetScope, RulesetResolution, SessionState +│ # (moved here from api-grade-mcp/src/types.ts) +└── index.ts # EXTENDED: export the above for CLI + MCP consumption + +packages/api-grade-mcp/src/ +├── auth/ # REMOVED (re-exported or directly imported from core) +├── config/ # REMOVED (directly imported from core) +├── types.ts # TRIMMED: only MCP-specific types remain +│ # (RecoveryOptionId, RecoveryOption) +└── tools/*.ts # UPDATED: import auth/config/types from + # '@dawmatt/api-grade-core' instead of relative + # '../auth/...', '../config/...', '../types.js' + # No change to tool schemas, logic, or output shape. + +src/cli/ +├── index.ts # UPDATED: add --token option, GITHUB_TOKEN env +│ # fallback, resolve-ruleset call, fetch-failure +│ # error reporting (human + JSON), Entra ID rejection, +│ # 'config' subcommand registration +├── config-loader.ts # UNCHANGED (.apigrade.json general options; separate +│ # from ruleset/auth config) +└── ruleset-config-cli.ts # NEW: 'config set-ruleset' / 'config get-ruleset' + # subcommands, thin CLI adapter over core's + # ruleset-config.ts + resolve-ruleset.ts + +tests/ +├── unit/cli-ruleset-config.test.ts # NEW +└── integration/cli-github-pat.test.ts # NEW + +packages/api-grade-core/tests/ +├── unit/auth-github.test.ts # NEW (moved/adapted from mcp tests) +├── unit/ruleset-config.test.ts # NEW (moved/adapted) +└── unit/resolve-ruleset.test.ts # NEW (moved/adapted) + +packages/api-grade-mcp/tests/ +└── (existing unit/integration tests UNCHANGED — must pass with no edits, per FR-002/SC-003) +``` + +**Structure Decision**: Existing monorepo layout (`packages/*` + root `src/cli`) is +reused as-is — no new package is introduced. Extraction follows the precedent already +set in the repo: shared logic lives in `packages/api-grade-core/src`, consumed by both +`packages/api-grade-mcp/src` and root `src/cli`. Module paths are preserved 1:1 +(`auth/github.ts`, `auth/entra.ts`, `config/ruleset-config.ts`, +`config/resolve-ruleset.ts`) to keep the diff a near-mechanical move plus import-path +fixups in the MCP package, minimizing risk of behavioral drift (FR-002). + +## Complexity Tracking + +*No violations — table omitted.* diff --git a/specs/008-cli-github-pat/quickstart.md b/specs/008-cli-github-pat/quickstart.md new file mode 100644 index 0000000..9675eca --- /dev/null +++ b/specs/008-cli-github-pat/quickstart.md @@ -0,0 +1,90 @@ +# Quickstart: GitHub PAT Ruleset Support for the CLI + +## 1. Grade against a private-repo ruleset, one-off + +```bash +api-grade openapi.yaml \ + --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --token ghp_xxxxxxxxxxxxxxxxxxxx +``` + +Or via environment variable instead of `--token`: + +```bash +export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx +api-grade openapi.yaml --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml +``` + +## 2. Configure a persistent default (workspace scope) for CI + +```bash +api-grade config set-ruleset \ + --scope workspace \ + --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --token ghp_xxxxxxxxxxxxxxxxxxxx +``` + +This writes `.api-grade/config.json` in the current directory. Every subsequent +invocation in this workspace uses it automatically: + +```bash +api-grade openapi.yaml --min-grade B --format json +``` + +Check what's configured: + +```bash +api-grade config get-ruleset +``` + +## 3. Configure a global default (applies to all workspaces without their own) + +```bash +api-grade config set-ruleset \ + --scope global \ + --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --token ghp_xxxxxxxxxxxxxxxxxxxx +``` + +Workspace-scoped config (if present) always wins over global. + +## 4. Containerised (CI) execution + +```bash +docker run --rm \ + -e GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx \ + -v "$PWD":/workspace \ + -v "$HOME/.api-grade":/root/.api-grade \ + -w /workspace \ + dawmatt/api-grade:latest \ + openapi.yaml --min-grade B +``` + +The `-v "$PWD":/workspace` mount makes `.api-grade/config.json` (workspace scope) +visible; `-v "$HOME/.api-grade":/root/.api-grade` makes the global scope visible. +`-e GITHUB_TOKEN` supplies the token without persisting it anywhere on disk. + +## 5. What happens if access fails + +```bash +api-grade openapi.yaml --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml +# (no token available from any source) +``` +``` +Error: Authentication required to fetch ruleset from 'https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml' (per-request). Supply a token via --token or the GITHUB_TOKEN environment variable. +``` +Exit code: `1`. No grading is attempted; the built-in ruleset is **not** used as a +silent fallback. + +## 6. Entra ID configs are rejected, not attempted + +If a config file (e.g. one shared from an MCP-server setup) specifies +`"auth": { "type": "entra-id", ... }`: + +```bash +api-grade openapi.yaml +``` +``` +Error: Microsoft Entra ID authentication is not supported by the CLI. Configure a GitHub PAT instead (--token, GITHUB_TOKEN, or `api-grade config set-ruleset --token`). +``` +Exit code: `1`. No Entra ID device-code flow is attempted. diff --git a/specs/008-cli-github-pat/research.md b/specs/008-cli-github-pat/research.md new file mode 100644 index 0000000..20f6aa2 --- /dev/null +++ b/specs/008-cli-github-pat/research.md @@ -0,0 +1,147 @@ +# Phase 0 Research: Shared GitHub PAT Ruleset Support for the CLI + +## R1: How should the extracted modules be exported from `api-grade-core` without leaking MCP- or CLI-specific types? + +- **Decision**: Move `auth/github.ts`, `auth/entra.ts`, `config/ruleset-config.ts`, + `config/resolve-ruleset.ts` into `packages/api-grade-core/src/` at identical relative + paths, and move the *data* types they depend on (`AuthConfig`, `RulesetConfig`, + `RulesetScope`, `RulesetResolution`, `SessionState`) into core's `types.ts`. MCP-only + types (`RecoveryOptionId`, `RecoveryOption`) stay in `api-grade-mcp/src/types.ts`. + Export the moved modules' public surface from `packages/api-grade-core/src/index.ts`. +- **Rationale**: `SessionState` is a plain `{ defaultRuleset, sessionRulesetOverride }` + shape with no MCP-protocol dependency — it's a generic "current in-memory override" + concept, not an MCP SDK type, so it satisfies FR-014. The CLI will construct an + always-empty/inert `SessionState` (since it has no session concept per the spec's + edge case) when calling `resolveRuleset`, rather than the core module needing a + CLI-specific code path. +- **Alternatives considered**: + - *Keep session-handling out of core, pass `null` instead of `SessionState`*: rejected + because it would require changing `resolveRuleset`'s signature/behavior, risking + drift from MCP's tested precedence logic (FR-002 prohibits any behavioral change). + - *Duplicate the modules into core and leave MCP's copies in place, syncing + manually*: rejected outright — directly violates SC-006 (exactly one + implementation) and Constitution Principle II. + +## R2: How does the CLI supply a `SessionState` given it has no session concept? + +- **Decision**: The CLI constructs a fresh, throwaway `SessionState` object + (`{ defaultRuleset: null, sessionRulesetOverride: null }`) on every invocation before + calling `resolveRuleset`. This is process-local and discarded at exit — there is no + persistence, matching the spec's stated edge case ("session scope remains an + MCP-only concept... left unused — not removed — by the CLI"). +- **Rationale**: Reuses `resolveRuleset` verbatim with zero new branching, preserving + FR-002's no-duplication/no-divergence requirement while satisfying FR-005's + precedence (per-request > workspace > global > built-in — session is simply always + inert for the CLI since nothing ever sets `defaultRuleset` on it). +- **Alternatives considered**: Overloading `resolveRuleset` with an optional + session parameter — rejected as an unnecessary signature change to already-tested + code; constructing an inert object is strictly simpler and changes nothing in core. + +## R3: How should the CLI obtain and use a token for `--ruleset `? + +- **Decision**: Extend `src/cli/index.ts`'s action handler to: (1) resolve the + effective ruleset path/auth via `resolveRuleset` (per-request `--ruleset` value, + then workspace config, then global config); (2) if the resolved path is an HTTP(S) + URL, resolve a token via precedence `--token` CLI option → `GITHUB_TOKEN` env var → + stored `auth.githubToken` in the resolved scope's config; (3) call core's + `fetchRulesetContent` (already exported, used identically by MCP's `grade.ts`) to + fetch ruleset content, write to a temp file, and pass that temp path to + `GradeEngine.grade()` exactly as MCP's `grade.ts` already does. +- **Rationale**: Reusing `fetchRulesetContent` + the temp-file pattern verbatim (rather + than `GradeEngine`'s own `rulesetUrl`/`rulesetToken` fields in `GradeRequest`, which + bypass the shared auth/config-resolution/failure-classification path entirely) is + required by FR-006 (shared core implementation, not a separate CLI-specific + mechanism) and FR-008 (shared failure classification). +- **Alternatives considered**: Using `GradeRequest.rulesetUrl`/`rulesetToken` directly + — rejected because that path in `grader.ts` calls `loadRulesetFromUrl`, which + silently falls back to the *default* ruleset on any fetch error (see + `rulesets/loader.ts` lines 71-76) — directly violating FR-009 (must not proceed with + default-ruleset grading on fetch failure) and FR-010 (must distinguish + auth-required). The MCP's own `grade.ts` deliberately avoids this path for the same + reason, fetching via `fetchRulesetContent` itself and writing a temp file instead. + +## R4: How should CLI persistent ruleset configuration commands be structured? + +- **Decision**: Add a `config` subcommand to the existing `commander` program in + `src/cli/index.ts`, with two further subcommands: `config set-ruleset` and `config + get-ruleset`, implemented in a new `src/cli/ruleset-config-cli.ts`. `set-ruleset` + accepts `--scope `, `--ruleset `, and `--token + `; it writes via core's `saveWorkspaceConfig`/`saveGlobalConfig` using the same + `RulesetConfig`/`AuthConfig` JSON shape the MCP server already persists and reads. + `get-ruleset` prints the effective resolution plus per-scope values, with secrets + redacted (mirroring MCP's `sanitizeAuth` pattern — show `tokenSource`, never the + token itself). +- **Rationale**: Directly fulfills FR-005 (persistent config commands/options at + workspace/global scope) and SC-002, reusing the exact persisted file format/location + documented in the spec's Assumptions, so a workspace already configured via the MCP + server's `set-ruleset-config` tool is immediately usable by the CLI with no + migration step, and vice versa. +- **Alternatives considered**: Exposing config via `--set-ruleset-config`-style flags + on the main grade command — rejected as needlessly overloading the primary command's + option surface for an orthogonal "manage persistent config" operation; a subcommand + is the more conventional CLI pattern (consistent with tools like `npm config set`/`git + config`) and keeps `index.ts`'s main action handler focused on grading. + +## R5: How should the CLI reject Entra ID auth configurations? + +- **Decision**: After resolving the effective `RulesetResolution` (whether from + `--ruleset`, workspace, or global config), if `resolution.auth?.type === 'entra-id'`, + the CLI immediately prints a clear stderr error ("Microsoft Entra ID authentication + is not supported by the CLI...") and calls `process.exit(1)` *before* any fetch + attempt or fallback — satisfying FR-016 and Acceptance Scenario 3 of User Story 4 + (no partial application of other options). Because FR-015 prohibits any documented + CLI option/config field for selecting Entra ID, this check exists purely as a + guardrail against configs created by, or copied from, the MCP server's config files + (which do support `entra-id`) — there is no CLI flag that sets this type. +- **Rationale**: The check must happen at the single point where resolution completes, + not duplicated across the grade path and the `config get-ruleset` path, since both + could surface a stored Entra ID config. Placing the check immediately after + `resolveRuleset()` in a small shared helper avoids duplicating the rejection logic. +- **Alternatives considered**: Silently ignoring `auth.type: 'entra-id'` and falling + back to no-auth fetch — rejected; this is exactly the silent-fallback behavior + Acceptance Scenario 1 (User Story 4) and FR-016 explicitly prohibit. + +## R6: How should fetch failures be reported in the CLI, given the MCP's `recoveryOptions` payload is MCP-specific? + +- **Decision**: Reuse core's `RulesetAuthError` (`reason: 'auth-failed' | 'not-found' | + 'network-unreachable'`) plus a CLI-local `'config-invalid'` case (for malformed + stored auth, e.g. `githubToken` missing and no env var present despite `type: + 'github-pat'`). Map each reason to a human-readable stderr message in `--format + human` (reusing wording adapted from MCP's `describeFetchFailureReason`, since that + helper is general-purpose prose, not an MCP-protocol structure) or a `{ error, + failureReason, rulesetUrl, scope, message }` JSON object on stdout in `--format + json`, then `process.exit(1)`. The MCP's `recoveryOptions`/`instructions` fields are + intentionally omitted — they encode an interactive AI-agent recovery flow with no CLI + analogue (FR-008 explicitly calls for "CLI-appropriate form... rather than the MCP + server's structured recovery-options payload"). +- **Rationale**: Satisfies FR-008 (shared classification, CLI-appropriate + presentation) and FR-009/SC-004 (non-zero exit, distinguishable failure categories) + without coupling the CLI to MCP-specific response shapes. +- **Alternatives considered**: Reusing `buildRulesetFetchFailureResponse` verbatim and + printing its JSON — rejected because its `recoveryOptions`/`instructions` fields are + meaningless and confusing in a non-interactive CI/CD context, and the spec + explicitly calls for a distinct CLI-appropriate form. + +## R7: Containerised execution — how are token and persisted config made available? + +- **Decision**: Document (in `docs/cli`) that containerised runs must pass + `-e GITHUB_TOKEN=` (or an equivalent secret-injection mechanism) and + bind-mount the workspace directory (for `.api-grade/config.json`) and/or + `$HOME/.api-grade` (for the global config) into the container, consistent with the + existing root `Dockerfile`'s working-directory conventions. No code change is + required beyond what's already needed for FR-004/FR-005 — `process.cwd()` and + `homedir()` resolve correctly inside a container as long as the relevant paths are + mounted. +- **Rationale**: Directly satisfies FR-012. No new environment-detection logic is + needed since Node's `os.homedir()`/`process.cwd()` already behave correctly in + containers; the only requirement is operator-facing documentation. +- **Alternatives considered**: Baking a config-path override flag (e.g. + `--config-dir`) for container use — rejected as unnecessary; bind-mounting the + existing fixed paths is sufficient and avoids adding new surface area not requested + by the spec (YAGNI per Constitution Development Workflow). + +## Summary of resolved unknowns + +No `NEEDS CLARIFICATION` markers remain. All decisions above directly trace to +functional requirements FR-001 through FR-016 and the spec's stated Assumptions and +Edge Cases. From 67d15b9da9b2eca048d95399acb2031efb154996 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 14:08:13 +1000 Subject: [PATCH 05/10] Refactor plan clarified --- .../contracts/cli-options.md | 84 ++++++-- specs/008-cli-github-pat/data-model.md | 8 +- specs/008-cli-github-pat/plan.md | 59 ++++-- specs/008-cli-github-pat/quickstart.md | 42 +++- specs/008-cli-github-pat/research.md | 86 +++++++- specs/008-cli-github-pat/spec.md | 185 ++++++++++++++++-- 6 files changed, 398 insertions(+), 66 deletions(-) diff --git a/specs/008-cli-github-pat/contracts/cli-options.md b/specs/008-cli-github-pat/contracts/cli-options.md index a910f4a..9c5d007 100644 --- a/specs/008-cli-github-pat/contracts/cli-options.md +++ b/specs/008-cli-github-pat/contracts/cli-options.md @@ -10,19 +10,61 @@ New/changed options: | Option | Type | Description | |---|---|---| | `--ruleset ` | string | **Unchanged** signature. Now additionally accepts a URL pointing into a private GitHub repository (FR-003). Highest-precedence ruleset source (per-request). | -| `--token ` | string | **NEW**. GitHub Personal Access Token used to authenticate a `--ruleset` URL fetch, or a resolved workspace/global default's URL fetch. Highest-precedence token source (FR-004). Never echoed in any output (FR-007). | +| `--auth-type ` | string | **NEW**. Authorisation type to use when fetching a *remote* (URL) ruleset, equivalent to the persisted `auth.type` field (FR-017). Documented values: `none` (default), `github-pat`. `entra-id` is accepted but undocumented, and always triggers the FR-016 rejection rather than any auth attempt. Any other value is a `config-invalid` failure. Ignored entirely for local (file-path) rulesets (FR-019). | +| `--token ` | string | **NEW**. GitHub Personal Access Token used to authenticate a `--ruleset` URL fetch, or a resolved workspace/global default's URL fetch. Only consulted when the resolved authorisation type is `github-pat` (FR-004, FR-018). Highest-precedence token source. Never echoed in any output (FR-007). | Unchanged options (`--min-grade`, `--format`, `--top`, `--verbose`) behave exactly as before; `--url` remains reserved/unsupported (unchanged early-exit behavior). -### Token resolution (FR-004) +### Authorisation type resolution (FR-017, FR-018, FR-019) Order, first match wins: +1. `--auth-type ` command-line option +2. `auth.type` field of the `RulesetConfig` at whichever scope the ruleset was + resolved from (workspace or global) +3. `none` (default — equivalent to `auth.type` being absent) + +This resolution applies only to *remote* (URL) ruleset sources. For a local +(file-path) ruleset, the resolved authorisation type is computed for warning +purposes only (FR-021) and otherwise has no effect — no auth step is ever performed +for a local read. + +If the resolved type is `none`, no authentication step is attempted for the fetch: +`GITHUB_TOKEN` and any stored `auth.githubToken` are not consulted, even if present +(FR-018, SC-008). If the resolved type is `entra-id`, the CLI rejects the invocation +per FR-016 before any fetch is attempted. + +### Token resolution (FR-004) + +Only performed when the resolved authorisation type (above) is `github-pat`. Order, +first match wins: 1. `--token ` command-line option 2. `GITHUB_TOKEN` environment variable 3. `auth.githubToken` field of the `RulesetConfig` at whichever scope the ruleset was resolved from (workspace or global) — set via `config set-ruleset` +### Ignored-option warnings (FR-020, FR-021, SC-009) + +The CLI emits one stderr warning line per ignored option (not a single combined +warning), in both cases below. Each warning names the option and the reason. Neither +case causes a non-zero exit by itself; the invocation continues. + +- **Resolved authorisation type is `none`** (explicit `--auth-type none` or + defaulted) **and `--token` is supplied**: + ``` + Warning: --token is ignored because the authorisation type is 'none'. Use --auth-type github-pat to authenticate this request. + ``` +- **Resolved ruleset source is local** (a file path) **and `--auth-type` and/or + `--token` are supplied**: + ``` + Warning: --auth-type is ignored because the ruleset is a local file; authorisation only applies to remote (URL) rulesets. + Warning: --token is ignored because the ruleset is a local file; authorisation only applies to remote (URL) rulesets. + ``` + +If both conditions could apply (e.g. a local ruleset with `--token` and no +`--auth-type`), the local-ruleset wording is used, since it is the more specific and +fundamental reason the option does not apply. + ### Ruleset resolution (FR-005, unchanged precedence from MCP) Order, first match wins: @@ -31,14 +73,23 @@ Order, first match wins: 3. Global config (`~/.api-grade/config.json`) 4. Built-in default ruleset -### Exit behavior on ruleset/auth failure (FR-009, FR-010, FR-016) - -- Private URL + no usable token (any source) → exit 1, "authentication required" - message — distinct wording from a generic fetch failure (FR-010). -- Private URL + invalid/rejected token → exit 1, `auth-failed` classification. +### Exit behavior on ruleset/auth failure (FR-009, FR-010, FR-016, FR-018) + +- Resolved auth type `none` for a private URL (whether by explicit `--auth-type + none`, default, or persisted config) → no auth attempted (FR-018); the + unauthenticated request to GitHub then fails and is reported as `auth-failed` or + `not-found` depending on GitHub's response — exit 1. Any `--token` supplied is + separately warned-and-ignored per FR-020, but does not change this outcome. +- Resolved auth type `github-pat` for a private URL + no usable token (any source) + → exit 1, "authentication required" message — distinct wording from a generic + fetch failure (FR-010). +- Resolved auth type `github-pat` + invalid/rejected token → exit 1, `auth-failed` + classification. - URL not found / no access → exit 1, `not-found` classification. - Host unreachable → exit 1, `network-unreachable` classification. -- Resolved auth config malformed → exit 1, `config-invalid` classification. +- Resolved auth config malformed, or `--auth-type` set to an unrecognised value + (other than `none`/`github-pat`/`entra-id`) → exit 1, `config-invalid` + classification. - Resolved auth `type === 'entra-id'` → exit 1, unsupported-authentication-type error (FR-016) — checked and reported *before* any fetch attempt. - In every failure case above: no grading occurs, no fallback to the built-in ruleset, @@ -73,11 +124,12 @@ rejection case. No `recoveryOptions` or `instructions` fields are present (FR-00 |---|---|---|---| | `--scope ` | string | yes | Which persisted config file to write. | | `--ruleset ` | string | no | Path or URL to set as the default. Omit to clear the default at that scope. | -| `--token ` | string | no | GitHub PAT to persist alongside the ruleset (stored under `auth.githubToken`, `auth.type: 'github-pat'`). Omit to leave/clear auth. | +| `--auth-type ` | string | no | Authorisation type to persist alongside the ruleset, stored as `auth.type`. Defaults to `github-pat` if omitted but `--token` is supplied; defaults to clearing `auth` (equivalent to `none`) if both are omitted. Passing `none` explicitly clears any persisted token at that scope. Does not accept `entra-id` (FR-015) — supplying it is rejected the same way as on the grading command (FR-016). | +| `--token ` | string | no | GitHub PAT to persist alongside the ruleset (stored under `auth.githubToken`). Only meaningful when the resolved `--auth-type` is `github-pat`; ignored with a warning (FR-020) if combined with `--auth-type none`. | Writes via core's `saveWorkspaceConfig`/`saveGlobalConfig` to the same file the MCP server's `set-ruleset-config` tool writes — both surfaces interoperate on the same -file. Does not expose `--auth-type entra-id` or any equivalent (FR-015). +file. Does not expose `entra-id` as a settable `--auth-type` value (FR-015). On success, prints a one-line confirmation (human) or `{ scope, rulesetPath, configFile }` (JSON) — never the token value. @@ -88,12 +140,12 @@ No options. Reads workspace and global config, resolves the effective ruleset (u the same `resolveRuleset` core function, with the CLI's inert `SessionState`), and prints: -- Human: effective scope + path, plus per-scope (workspace/global) values, token - presence indicated only as `(token configured)` / `(no token)` / `(from - GITHUB_TOKEN)` — never the value. -- JSON: `{ effective: { scope, rulesetPath }, workspace: {...} | null, global: {...} - | null, builtIn: 'default' }` — same redaction rule as MCP's `get-ruleset-config` - `sanitizeAuth` (`tokenSource` field, not the token). +- Human: effective scope + path + resolved auth type, plus per-scope + (workspace/global) values, token presence indicated only as `(token configured)` / + `(no token)` / `(from GITHUB_TOKEN)` — never the value. +- JSON: `{ effective: { scope, rulesetPath, authType }, workspace: {...} | null, + global: {...} | null, builtIn: 'default' }` — same redaction rule as MCP's + `get-ruleset-config` `sanitizeAuth` (`tokenSource` field, not the token). If the effective resolution's auth type is `entra-id`, this command reports it explicitly as unsupported-by-CLI in its output (informational only here — this read diff --git a/specs/008-cli-github-pat/data-model.md b/specs/008-cli-github-pat/data-model.md index 0a6db52..cd20b75 100644 --- a/specs/008-cli-github-pat/data-model.md +++ b/specs/008-cli-github-pat/data-model.md @@ -11,7 +11,7 @@ Describes how to authenticate a ruleset fetch. | Field | Type | Notes | |---|---|---| -| `type` | `'github-pat' \| 'entra-id'` | Discriminator. The CLI only *acts on* `'github-pat'`; resolving to `'entra-id'` is a rejection condition (FR-016). | +| `type` | `'github-pat' \| 'entra-id'` | Discriminator. The CLI only *acts on* `'github-pat'`; resolving to `'entra-id'` is a rejection condition (FR-016). The CLI's own default/no-auth state (`'none'`) is represented as `auth: null` (an absent `AuthConfig`), not as a third discriminator value on this core type — `--auth-type none` resolves to `auth: null`, not `{ type: 'none' }` (FR-017). | | `githubToken` | `string?` | Present only for `type: 'github-pat'`. Never logged, printed, or serialized to stdout/stderr (FR-007). Falls back to `GITHUB_TOKEN` env var when absent. | | `tenantId` | `string?` | Present only for `type: 'entra-id'`. Unused by the CLI beyond detecting rejection. | | `clientId` | `string?` | Present only for `type: 'entra-id'`. Unused by the CLI beyond detecting rejection. | @@ -79,7 +79,9 @@ requires core stay free of CLI-specific types. | Concept | Shape | Purpose | |---|---|---| -| Token resolution order | `--token` CLI option → `GITHUB_TOKEN` env var → resolved scope's `auth.githubToken` | Implements FR-004's required precedence. | +| Auth-type resolution order | `--auth-type` CLI option → resolved scope's `auth.type` (via its `AuthConfig`) → `'none'` default (`auth: null`) | Implements FR-017's required precedence; gates whether token resolution (below) runs at all for a remote ruleset (FR-018). Computed but inert for local rulesets (FR-019). | +| Token resolution order | (only when resolved auth type is `'github-pat'`) `--token` CLI option → `GITHUB_TOKEN` env var → resolved scope's `auth.githubToken` | Implements FR-004's required precedence, now gated by auth-type resolution. | +| Ignored-option warning | stderr string per ignored option, printed before proceeding, no exit-code effect | FR-020 (`auth-type` resolves to `none` but `--token` supplied) / FR-021 (ruleset source is local but `--auth-type`/`--token` supplied). | | CLI fetch-failure output (human) | stderr string, reason-specific wording | FR-008 human-readable form. | | CLI fetch-failure output (JSON) | `{ error, failureReason, rulesetUrl, scope, message }` printed to stdout when `--format json` | FR-008 machine-readable form; deliberately omits MCP's `recoveryOptions`/`instructions`. | -| Unsupported-auth-type error | stderr string + exit code 1, no JSON variant beyond `{ error: 'UNSUPPORTED_AUTH_TYPE', message }` | FR-016/SC-007. | +| Unsupported-auth-type error | stderr string + exit code 1, no JSON variant beyond `{ error: 'UNSUPPORTED_AUTH_TYPE', message }` | FR-016/SC-007. Triggered by resolved type `'entra-id'`, including via `--auth-type entra-id`. | diff --git a/specs/008-cli-github-pat/plan.md b/specs/008-cli-github-pat/plan.md index c35883b..7475223 100644 --- a/specs/008-cli-github-pat/plan.md +++ b/specs/008-cli-github-pat/plan.md @@ -10,15 +10,21 @@ Extract the GitHub PAT (and Entra ID) ruleset-fetch authentication, fetch-failure classification, and multi-level configuration-resolution logic currently living in -`api-grade-mcp` into `api-grade-core`, with zero behavioral change to the MCP server. -Refactor `api-grade-mcp` to consume the extracted modules. Then extend the CLI -(`src/cli`) to consume the same core modules, adding a `--token` option, `GITHUB_TOKEN` -env var support, and new `config` subcommands (`config set-ruleset` / `config -get-ruleset`) for workspace/global persistent ruleset+auth defaults — mirroring the -MCP's `set-ruleset-config`/`get-ruleset-config` tools but using CLI-appropriate +`api-grade-mcp` into `api-grade-core`, with zero behavioral change to the MCP server +and zero behavioral or import-surface change for the `backstage-plugin-api-grade` / +`backstage-plugin-api-grade-backend` packages, which also depend on `api-grade-core` +(FR-022/FR-023). Refactor `api-grade-mcp` to consume the extracted modules. Then +extend the CLI (`src/cli`) to consume the same core modules, adding a new +`--auth-type ` option that gates a `--token` option / `GITHUB_TOKEN` +env var / persisted-config token resolution (FR-017/FR-018), and new `config` +subcommands (`config set-ruleset` / `config get-ruleset`) for workspace/global +persistent ruleset+auth defaults — mirroring the MCP's +`set-ruleset-config`/`get-ruleset-config` tools but using CLI-appropriate input/output (no session scope, no recovery-options payload). The CLI explicitly -rejects `entra-id` auth configurations with a clear error rather than attempting or -silently ignoring them. +rejects `entra-id` auth configurations (including via `--auth-type entra-id`) with a +clear error rather than attempting or silently ignoring them, and prints a +non-fatal warning for any authorisation-related option supplied but rendered moot by +a `none` auth type or a local ruleset source (FR-020/FR-021). ## Technical Context @@ -36,9 +42,12 @@ file is untouched. **Testing**: Vitest (`vitest run`), consistent with all existing packages. New unit tests for the extracted core modules; existing MCP unit/integration tests must pass -unmodified (assertion-for-assertion) post-refactor; new CLI integration tests for -`--token`, `GITHUB_TOKEN`, `config set-ruleset`/`config get-ruleset`, precedence, and -Entra ID rejection. +unmodified (assertion-for-assertion) post-refactor; existing +`backstage-plugin-api-grade`/`backstage-plugin-api-grade-backend` build and test +suites must also pass unmodified (FR-023/SC-010); new CLI integration tests for +`--auth-type`, `--token`, `GITHUB_TOKEN`, `config set-ruleset`/`config get-ruleset`, +auth-type/token precedence, ignored-option warnings (FR-020/FR-021), and Entra ID +rejection. **Target Platform**: Cross-platform Node.js CLI (Windows/macOS minimum, per Constitution V), local and containerised (Docker) execution. @@ -66,6 +75,11 @@ concurrency or multi-tenancy concerns. AsyncAPI (FR-011). No format-specific branching introduced. - **Principle II (Core-First Architecture)**: PASS — this is the entire point of the feature (FR-001, FR-002, SC-006: exactly one implementation, shared by CLI and MCP). + Extended by FR-022/FR-023: the same core package is also consumed directly by + `backstage-plugin-api-grade-backend` (and transitively by `backstage-plugin-api-grade`), + so "core-first" here additionally means the refactor must not break a third, + pre-existing consumer — verified by those packages' existing test suites passing + unmodified (SC-010). - **Principle III (Spectral-Ruleset Based Grading)**: PASS. Custom ruleset supply via secured location is exactly what this feature adds for the CLI; grading algorithm itself is unchanged. @@ -138,14 +152,17 @@ packages/api-grade-mcp/src/ # No change to tool schemas, logic, or output shape. src/cli/ -├── index.ts # UPDATED: add --token option, GITHUB_TOKEN env -│ # fallback, resolve-ruleset call, fetch-failure -│ # error reporting (human + JSON), Entra ID rejection, +├── index.ts # UPDATED: add --auth-type option (gates token +│ # resolution per FR-018), --token option, GITHUB_TOKEN +│ # env fallback, resolve-ruleset call, ignored-option +│ # warnings (FR-020/FR-021), fetch-failure error +│ # reporting (human + JSON), Entra ID rejection, │ # 'config' subcommand registration ├── config-loader.ts # UNCHANGED (.apigrade.json general options; separate │ # from ruleset/auth config) └── ruleset-config-cli.ts # NEW: 'config set-ruleset' / 'config get-ruleset' - # subcommands, thin CLI adapter over core's + # subcommands (--scope, --ruleset, --auth-type, + # --token), thin CLI adapter over core's # ruleset-config.ts + resolve-ruleset.ts tests/ @@ -159,6 +176,12 @@ packages/api-grade-core/tests/ packages/api-grade-mcp/tests/ └── (existing unit/integration tests UNCHANGED — must pass with no edits, per FR-002/SC-003) + +packages/backstage-plugin-api-grade/ +packages/backstage-plugin-api-grade-backend/ +└── (NO source changes; existing build + test suites UNCHANGED — must pass with no + edits, per FR-022/FR-023/SC-010. These packages are not touched by this feature; + they are listed here solely as a verification target for the core refactor.) ``` **Structure Decision**: Existing monorepo layout (`packages/*` + root `src/cli`) is @@ -167,7 +190,11 @@ set in the repo: shared logic lives in `packages/api-grade-core/src`, consumed b `packages/api-grade-mcp/src` and root `src/cli`. Module paths are preserved 1:1 (`auth/github.ts`, `auth/entra.ts`, `config/ruleset-config.ts`, `config/resolve-ruleset.ts`) to keep the diff a near-mechanical move plus import-path -fixups in the MCP package, minimizing risk of behavioral drift (FR-002). +fixups in the MCP package, minimizing risk of behavioral drift (FR-002). The +`backstage-plugin-api-grade`/`backstage-plugin-api-grade-backend` packages require no +source changes at all — they import only symbols that remain in place under FR-022's +compatibility guarantee — so they appear in the structure above only as a +verification target (run their existing suites), not an implementation target. ## Complexity Tracking diff --git a/specs/008-cli-github-pat/quickstart.md b/specs/008-cli-github-pat/quickstart.md index 9675eca..e666984 100644 --- a/specs/008-cli-github-pat/quickstart.md +++ b/specs/008-cli-github-pat/quickstart.md @@ -5,14 +5,19 @@ ```bash api-grade openapi.yaml \ --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --auth-type github-pat \ --token ghp_xxxxxxxxxxxxxxxxxxxx ``` -Or via environment variable instead of `--token`: +Or via environment variable instead of `--token` (still requires `--auth-type +github-pat` — the token is never consulted unless the authorisation type resolves to +`github-pat`): ```bash export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx -api-grade openapi.yaml --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml +api-grade openapi.yaml \ + --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --auth-type github-pat ``` ## 2. Configure a persistent default (workspace scope) for CI @@ -21,6 +26,7 @@ api-grade openapi.yaml --ruleset https://raw.githubusercontent.com/my-org/privat api-grade config set-ruleset \ --scope workspace \ --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --auth-type github-pat \ --token ghp_xxxxxxxxxxxxxxxxxxxx ``` @@ -43,6 +49,7 @@ api-grade config get-ruleset api-grade config set-ruleset \ --scope global \ --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --auth-type github-pat \ --token ghp_xxxxxxxxxxxxxxxxxxxx ``` @@ -76,7 +83,36 @@ Error: Authentication required to fetch ruleset from 'https://raw.githubusercont Exit code: `1`. No grading is attempted; the built-in ruleset is **not** used as a silent fallback. -## 6. Entra ID configs are rejected, not attempted +## 6. `--token` without `--auth-type` is ignored, not silently used + +`none` is the default authorisation type. Supplying `--token` alone does not opt in +to using it: + +```bash +api-grade openapi.yaml \ + --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --token ghp_xxxxxxxxxxxxxxxxxxxx +``` +``` +Warning: --token is ignored because the authorisation type is 'none'. Use --auth-type github-pat to authenticate this request. +Error: Could not fetch ruleset from '...' (per-request): the repository or file was not found, or you do not have access (404). +``` +Exit code: `1`. The warning does not change the outcome — the fetch is still made +unauthenticated and still fails against a private repository. + +## 7. Authorisation options are ignored for a local ruleset file + +```bash +api-grade openapi.yaml --ruleset ./rules/custom-ruleset.yaml --auth-type github-pat --token ghp_xxxxxxxxxxxxxxxxxxxx +``` +``` +Warning: --auth-type is ignored because the ruleset is a local file; authorisation only applies to remote (URL) rulesets. +Warning: --token is ignored because the ruleset is a local file; authorisation only applies to remote (URL) rulesets. +``` +Grading proceeds normally using the local file; exit code reflects only the grading +result, not the ignored options. + +## 8. Entra ID configs are rejected, not attempted If a config file (e.g. one shared from an MCP-server setup) specifies `"auth": { "type": "entra-id", ... }`: diff --git a/specs/008-cli-github-pat/research.md b/specs/008-cli-github-pat/research.md index 20f6aa2..4c083f9 100644 --- a/specs/008-cli-github-pat/research.md +++ b/specs/008-cli-github-pat/research.md @@ -41,7 +41,8 @@ - **Decision**: Extend `src/cli/index.ts`'s action handler to: (1) resolve the effective ruleset path/auth via `resolveRuleset` (per-request `--ruleset` value, - then workspace config, then global config); (2) if the resolved path is an HTTP(S) + then workspace config, then global config); (2) resolve the authorisation type + per R8 below, and only if it is `github-pat` and the resolved path is an HTTP(S) URL, resolve a token via precedence `--token` CLI option → `GITHUB_TOKEN` env var → stored `auth.githubToken` in the resolved scope's config; (3) call core's `fetchRulesetContent` (already exported, used identically by MCP's `grade.ts`) to @@ -85,14 +86,17 @@ ## R5: How should the CLI reject Entra ID auth configurations? - **Decision**: After resolving the effective `RulesetResolution` (whether from - `--ruleset`, workspace, or global config), if `resolution.auth?.type === 'entra-id'`, - the CLI immediately prints a clear stderr error ("Microsoft Entra ID authentication - is not supported by the CLI...") and calls `process.exit(1)` *before* any fetch - attempt or fallback — satisfying FR-016 and Acceptance Scenario 3 of User Story 4 - (no partial application of other options). Because FR-015 prohibits any documented - CLI option/config field for selecting Entra ID, this check exists purely as a - guardrail against configs created by, or copied from, the MCP server's config files - (which do support `entra-id`) — there is no CLI flag that sets this type. + `--ruleset`, workspace, or global config) and applying any `--auth-type` override + (R8), if the resulting type is `'entra-id'`, the CLI immediately prints a clear + stderr error ("Microsoft Entra ID authentication is not supported by the CLI...") + and calls `process.exit(1)` *before* any fetch attempt or fallback — satisfying + FR-016 and Acceptance Scenario 3 of User Story 5 (no partial application of other + options). Because FR-015 prohibits any *documented* CLI option/config field for + selecting Entra ID, `--auth-type entra-id` is deliberately accepted by argument + parsing (so this rejection path is reachable without attempting an undocumented + flow) but never appears in `--help` output or docs (FR-017). This guards both + configs copied from the MCP server's config files (which do support `entra-id`) + and a user directly trying the value. - **Rationale**: The check must happen at the single point where resolution completes, not duplicated across the grade path and the `config get-ruleset` path, since both could surface a stored Entra ID config. Placing the check immediately after @@ -140,8 +144,70 @@ existing fixed paths is sufficient and avoids adding new surface area not requested by the spec (YAGNI per Constitution Development Workflow). +## R8: How should the CLI resolve and gate the authorisation type ahead of token resolution? + +- **Decision**: Add a CLI-only resolution step, run immediately after `resolveRuleset` + and before any token lookup: `--auth-type` CLI option → resolved scope's + `auth?.type` → `'none'` default. This resolved type is computed unconditionally + (even for a local ruleset, since the warning logic in FR-021 needs to know whether + an option was "supplied but moot" vs. "supplied and used"), but it only *gates* + behavior for a remote (URL) ruleset: `'none'` skips token resolution entirely + (R3/FR-018), `'github-pat'` proceeds to token resolution, `'entra-id'` triggers + rejection (R5). `--auth-type` is not added as a new field on core's `AuthConfig` + type — it is a CLI-only input that, together with the resolved scope's existing + `auth.type`, produces the same three-way value purely within `src/cli/`. +- **Rationale**: Implements FR-017/FR-018/FR-019 precedence without modifying core's + `AuthConfig` shape (preserving FR-014's framework-agnostic/dependency-light + constraint and FR-022's "no signature change" guarantee for Backstage consumers of + the same type). Resolving it once, before token lookup and before the Entra ID + check, gives both downstream checks (R3, R5) and the warning logic (R8a below) a + single source of truth. +- **Alternatives considered**: Threading `--auth-type` into `resolveRuleset()`'s + signature as a new parameter — rejected because it would change a function shared + with, and already behaviorally frozen for, the MCP server (FR-002) and would leak a + CLI-only concept into core (FR-014); computing it as a separate CLI-local step after + `resolveRuleset()` returns avoids touching that function at all. + +### R8a: How should "ignored option" warnings be implemented without affecting exit code? + +- **Decision**: A small CLI-local helper collects ignored-option warnings (one entry + per option, per FR-020/FR-021's per-option wording) during the auth-type/token + resolution step in `src/cli/index.ts`, prints each to stderr via `console.warn` + immediately (not batched/deduped beyond one line per option), and never sets a + non-zero exit code itself — the invocation proceeds to grading (or to the + `entra-id`/fetch-failure exit paths, which are independent of these warnings). +- **Rationale**: SC-009 requires every such case to "still complete the invocation + (no non-zero exit caused solely by the ignored option)" — implementing this as a + side-effecting print with no control-flow impact is the simplest way to guarantee + that, and keeps the warning logic colocated with the resolution step that already + knows which options were supplied vs. consulted. +- **Alternatives considered**: Returning warnings as part of `RulesetResolution` (a + core type) — rejected; this is CLI-presentation-only state with no MCP equivalent + and no business reason to live in core (FR-014). + +## R9: How is "no behavioral change" verified for the Backstage plugin packages, given the core refactor touches their only dependency? + +- **Decision**: No code change is made to `backstage-plugin-api-grade` or + `backstage-plugin-api-grade-backend` in this feature. Verification is purely + regression-based: run both packages' existing build and test commands, unmodified, + against the post-refactor `api-grade-core`, and confirm a clean build plus 100% + pass rate with zero assertion edits (FR-023/SC-010). This mirrors exactly how MCP + no-regression (FR-002/SC-003) is verified — by running the existing suite, not by + writing new Backstage-specific tests for this feature. +- **Rationale**: FR-022 already constrains the refactor itself (no signature/behavior + change to any currently-exported, currently-imported symbol); the only thing left to + *verify* is that the constraint held, which the existing suites already do without + modification. Writing new tests against the Backstage packages would exceed this + feature's scope (the spec's Assumptions explicitly defer backfilling test gaps in + those suites). +- **Alternatives considered**: Adding a lightweight new integration test in the + Backstage backend package that explicitly imports the new `auth`/`config` exports — + rejected; FR-023 only requires the *existing* suite to keep passing, and the new + exports are CLI/MCP-facing, not something the Backstage plugins are expected to + adopt as part of this feature. + ## Summary of resolved unknowns No `NEEDS CLARIFICATION` markers remain. All decisions above directly trace to -functional requirements FR-001 through FR-016 and the spec's stated Assumptions and +functional requirements FR-001 through FR-023 and the spec's stated Assumptions and Edge Cases. diff --git a/specs/008-cli-github-pat/spec.md b/specs/008-cli-github-pat/spec.md index 7ce4bce..fca5a93 100644 --- a/specs/008-cli-github-pat/spec.md +++ b/specs/008-cli-github-pat/spec.md @@ -6,7 +6,7 @@ **Status**: Draft -**Input**: User description: "Add CLI support for rulesets hosted on GitHub private repos (via PAT)." Refined: the GitHub PAT ruleset-fetching and multi-level persistent ruleset configuration capability already exists in `api-grade-mcp`, is well tested, and must be extracted into `api-grade-core` so both the CLI and the MCP server consume one shared implementation. The MCP server's behavior (including its error handling, response shapes, and messages) must remain unchanged. The CLI gains new command-line options and persistent configuration capabilities to use the shared implementation. The existing Microsoft Entra ID authentication capability is extracted into core alongside GitHub PAT (same no-regression requirement for the MCP server), but is deliberately kept inaccessible and undocumented at the CLI surface in this feature — laying groundwork for a planned future CLI feature rather than shipping Entra ID support to CLI users now. +**Input**: User description: "Add CLI support for rulesets hosted on GitHub private repos (via PAT)." Refined: the GitHub PAT ruleset-fetching and multi-level persistent ruleset configuration capability already exists in `api-grade-mcp`, is well tested, and must be extracted into `api-grade-core` so both the CLI and the MCP server consume one shared implementation. The MCP server's behavior (including its error handling, response shapes, and messages) must remain unchanged. The CLI gains new command-line options and persistent configuration capabilities to use the shared implementation. The existing Microsoft Entra ID authentication capability is extracted into core alongside GitHub PAT (same no-regression requirement for the MCP server), but is deliberately kept inaccessible and undocumented at the CLI surface in this feature — laying groundwork for a planned future CLI feature rather than shipping Entra ID support to CLI users now. The `backstage-plugin-api-grade` and `backstage-plugin-api-grade-backend` packages, which also depend on `api-grade-core` (the backend plugin imports it directly), must continue to function exactly as before: this feature changes `api-grade-core`'s internal structure and adds new exports, but introduces no functionality change for existing Backstage plugin consumers. ## User Scenarios & Testing *(mandatory)* @@ -34,17 +34,29 @@ confirming grading succeeds using that ruleset's rules. 1. **Given** a private GitHub repository containing a valid Spectral ruleset and a PAT with read access to that repository, **When** the user runs the CLI with the - ruleset's URL and the token supplied, **Then** the CLI fetches the ruleset - successfully and grades the API specification using its rules. + ruleset's URL, `--auth-type github-pat`, and the token supplied, **Then** the CLI + fetches the ruleset successfully and grades the API specification using its + rules. 2. **Given** the same private repository and ruleset, **When** the user runs the CLI - with the ruleset's URL but no token (or an invalid token), **Then** the CLI fails - the request, exits non-zero, and prints a clear error indicating that - authentication is required or failed — without leaking the token value in any - logged output. + with the ruleset's URL, `--auth-type github-pat`, but no token (or an invalid + token), **Then** the CLI fails the request, exits non-zero, and prints a clear + error indicating that authentication is required or failed — without leaking the + token value in any logged output. 3. **Given** a token supplied via the `GITHUB_TOKEN` environment variable, **When** - the user runs the CLI with a private ruleset URL and no token-related - command-line option, **Then** the CLI uses the environment variable token - automatically. + the user runs the CLI with a private ruleset URL and `--auth-type github-pat` but + no token-related command-line option, **Then** the CLI uses the environment + variable token automatically. +4. **Given** no `--auth-type` option is supplied and no auth type is configured at + any persisted scope, **When** the user runs the CLI with a private ruleset URL and + a `--token`, **Then** the resolved authorisation type is `none` (the default), the + CLI does not attempt to use the supplied token, prints a warning that `--token` is + being ignored because the authorisation type is `none`, and the subsequent fetch + fails as an unauthenticated request to a private repository. +5. **Given** a ruleset is supplied as a local file path rather than a URL, **When** + the user runs the CLI with `--auth-type github-pat` and/or `--token` supplied, + **Then** the CLI ignores both options for the purposes of reading the local file, + prints a warning for each ignored option explaining that authorisation does not + apply to local rulesets, and proceeds to grade using the local file. --- @@ -85,8 +97,16 @@ confirming a global-scope default is used when no workspace default is configure value takes precedence over any configured default. 5. **Given** `GITHUB_TOKEN` is set in the environment, **When** the CLI is invoked against a private ruleset URL (supplied directly or via configured default) with - no token-related command-line option or stored auth config, **Then** the CLI - uses the environment variable token automatically. + an explicit or resolved authorisation type of `github-pat` and no token-related + command-line option or stored auth config, **Then** the CLI uses the environment + variable token automatically. +6. **Given** a workspace or global default ruleset configuration persists + `auth.type: "github-pat"` with a stored token, **When** the CLI is run without + `--auth-type` or `--token`, **Then** the CLI uses the persisted authorisation type + and token, exactly as if `--auth-type github-pat` had been supplied explicitly. +7. **Given** a workspace or global default ruleset configuration has no `auth` field + (or omits `auth.type`), **When** the CLI is run without `--auth-type`, **Then** + the resolved authorisation type is `none`, identical to the CLI's own default. --- @@ -124,7 +144,50 @@ any change to expected inputs, outputs, or messages. invoked post-refactor with the same inputs used before the refactor, **Then** they return identical outputs. -### User Story 4 - CLI rejects Entra ID authentication explicitly (Priority: P3) +### User Story 4 - Backstage plugin packages are unaffected by the refactor (Priority: P1) + +The `backstage-plugin-api-grade-backend` package imports `api-grade-core` directly +(e.g. to grade APIs server-side within a Backstage instance), and +`backstage-plugin-api-grade` depends on the same core package transitively through +the backend's API contract. As `api-grade-core`'s internal module layout changes +(new `auth/` and `config/` modules, extended `types.ts`, extended `index.ts` +exports) to support the CLI and MCP refactor, every existing import, type, and +function the Backstage plugins currently rely on from `api-grade-core` must +continue to resolve and behave exactly as before. + +**Why this priority**: The Backstage plugins are an existing, shipped integration +with their own consumers (Backstage instance operators). A core-package refactor +done for the CLI/MCP's benefit must not silently break an unrelated consumer that +happens to share the same dependency — this is as critical as the MCP +no-regression requirement (User Story 3), since both are pre-existing consumers of +`api-grade-core` that must not regress. + +**Independent Test**: Can be tested independently by running the existing +`backstage-plugin-api-grade` and `backstage-plugin-api-grade-backend` build and test +suites, unmodified in their assertions, against the post-refactor `api-grade-core` +and confirming both packages build successfully and 100% of existing tests pass +without any change to expected behavior. + +**Acceptance Scenarios**: + +1. **Given** the `backstage-plugin-api-grade-backend` package's existing imports + from `api-grade-core`, **When** the core package is refactored to extract + GitHub PAT/Entra ID auth and configuration logic, **Then** every import used by + the backend plugin continues to resolve to the same exported symbol with the + same type and behavior as before the refactor. +2. **Given** the `backstage-plugin-api-grade` and `backstage-plugin-api-grade-backend` + packages' existing automated test suites, **When** the suites are run after the + core-package refactor, **Then** every test passes without modification to its + assertions. +3. **Given** a Backstage instance running the existing plugins, **When** an API is + graded through the plugin's existing (non-PAT, non-Entra-ID) flow post-refactor, + **Then** the grading result is identical to pre-refactor behavior — this feature + introduces no new functionality, configuration, or behavior for Backstage plugin + consumers. + +--- + +### User Story 5 - CLI rejects Entra ID authentication explicitly (Priority: P3) A user tries to configure or invoke the CLI using a Microsoft Entra ID auth configuration — for example, by setting `auth.type: "entra-id"` in a persisted @@ -153,10 +216,11 @@ attempting an Entra ID flow or falling back silently. the default ruleset's auth configuration, **When** the CLI is run without an explicit `--ruleset` override, **Then** the CLI exits non-zero with an error stating that Entra ID authentication is not supported by the CLI. -2. **Given** a command-line option intended to select Entra ID authentication (if - exposed in argument parsing at all), **When** the CLI is run with that option, - **Then** the CLI exits non-zero with the same unsupported-authentication-type - error rather than attempting any Entra ID device-code or token flow. +2. **Given** the `--auth-type` command-line option is supplied with the value + `entra-id` (an accepted-but-undocumented value, recognised only so it can be + rejected), **When** the CLI is run with that option, **Then** the CLI exits + non-zero with the same unsupported-authentication-type error rather than + attempting any Entra ID device-code or token flow. 3. **Given** an unsupported-auth-type rejection has occurred, **When** the error is reported, **Then** it does not attempt to fetch the ruleset, does not fall back to the built-in default ruleset silently, and does not partially apply any other @@ -196,6 +260,24 @@ attempting an Entra ID flow or falling back silently. with an error naming the failure category, distinct from (and reported before) a grade-threshold failure, since grading cannot meaningfully occur without the ruleset. +- What happens when the resolved authorisation type is `none` (explicitly via + `--auth-type none`, or by default when the option is omitted and nothing is + persisted) but a `GITHUB_TOKEN` environment variable is set, or a token is + otherwise configured? The CLI MUST NOT use that token for a remote ruleset fetch — + `none` means no authorisation is attempted regardless of what credentials are + ambiently available. This avoids surprising behaviour where an unrelated + environment variable silently changes how a request is authenticated. +- What happens when an authorisation-related command-line option (e.g. `--token`, + or `--auth-type` set to a value other than the resolved default) is supplied + together with `--auth-type none` (explicit or default)? The CLI MUST print a + warning for each such ignored option, explaining that it is being ignored because + the authorisation type is `none`, and MUST continue the invocation rather than + erroring — since this is an ambiguous-but-recoverable input, not a fatal one. +- What happens when an authorisation-related command-line option (e.g. `--token`, + `--auth-type`) is supplied together with a ruleset that resolves to a local file + path rather than a URL? The CLI MUST print a warning for each such ignored option, + explaining that authorisation does not apply to local rulesets, and MUST continue + grading using the local file rather than erroring. ## Requirements *(mandatory)* @@ -217,7 +299,8 @@ attempting an Entra ID flow or falling back silently. to a file within a private GitHub repository, using the existing `--ruleset` option already used for local paths and public URLs. - **FR-004**: The CLI MUST support supplying a GitHub PAT to authenticate a private - ruleset fetch, via (in order of precedence): (1) a command-line token option, (2) + ruleset fetch, used only when the resolved authorisation type (FR-017) is + `github-pat`, via (in order of precedence): (1) a command-line token option, (2) the `GITHUB_TOKEN` environment variable, (3) auth configuration persisted at workspace or global scope. The first configured source in this order is used. - **FR-005**: The CLI MUST gain persistent ruleset configuration commands/options @@ -263,6 +346,44 @@ attempting an Entra ID flow or falling back silently. unsupported-authentication-type error, MUST NOT attempt the Entra ID authentication flow, and MUST NOT silently fall back to the built-in default ruleset. +- **FR-017**: The CLI MUST expose an optional `--auth-type ` command-line + option, equivalent to the `auth.type` field of persisted ruleset configuration, + for selecting the authorisation type used to resolve and fetch a ruleset. The only + documented accepted value besides the default is `github-pat`; `none` is also + accepted and is the default behaviour when the option is omitted and no auth type + is resolved from persisted configuration — equivalent to `auth.type` being absent + from the configuration file. (`entra-id` is also recognised, but solely so it can + be rejected per FR-016/FR-015; it is not a documented value.) +- **FR-018**: The resolved authorisation type — from `--auth-type`, from persisted + workspace/global configuration, or defaulted to `none` — MUST strictly govern CLI + behaviour when fetching a remote ruleset (one supplied as a URL), regardless of + source. In particular, when the resolved type is `none`, the CLI MUST NOT attempt + any authentication step for that fetch, MUST NOT consult the `GITHUB_TOKEN` + environment variable or any stored token, even if one is present, and MUST treat + the request as unauthenticated. +- **FR-019**: The resolved authorisation type MUST be ignored entirely when the + ruleset source is local (a file path rather than a URL); local ruleset reads MUST + NOT be gated by, or altered by, any authorisation type. +- **FR-020**: When the resolved authorisation type is `none` (explicitly supplied or + defaulted) and one or more authorisation-related command-line options (e.g. + `--token`) are also supplied, the CLI MUST print a warning for each such option + stating that it is being ignored and explaining that no authorisation is performed + when the type is `none`, then continue the invocation rather than exiting with an + error. +- **FR-021**: When the resolved ruleset source is local and one or more + authorisation-related command-line options (e.g. `--token`, `--auth-type`) are + supplied, the CLI MUST print a warning for each such option stating that it is + being ignored and explaining that authorisation does not apply to local rulesets, + then continue the invocation rather than exiting with an error. +- **FR-022**: The `api-grade-core` refactor (FR-001) MUST NOT change the signature, + behavior, or removal status of any symbol currently exported from + `api-grade-core` and imported by `backstage-plugin-api-grade-backend` or + `backstage-plugin-api-grade`; new exports MAY be added, but existing ones MUST + remain source- and behavior-compatible. +- **FR-023**: The `backstage-plugin-api-grade` and `backstage-plugin-api-grade-backend` + packages' existing automated test suites MUST pass unmodified (assertion-for- + assertion) after the core-package refactor, mirroring the no-regression + requirement already placed on the MCP server (FR-002). ### Key Entities @@ -281,6 +402,11 @@ attempting an Entra ID flow or falling back silently. network-unreachable, or config-invalid — the shared, core-defined categorisation of why a ruleset fetch did not succeed, consumed identically by CLI error reporting and MCP error responses. +- **Authorisation Type**: The resolved value of `auth.type` (`none`, `github-pat`, + or `entra-id`) governing whether and how a *remote* ruleset fetch is + authenticated, resolvable from (in order) the `--auth-type` command-line option, + persisted workspace/global configuration, or the `none` default. Always ignored + for local (file-path) ruleset sources. ## Success Criteria *(mandatory)* @@ -311,6 +437,19 @@ attempting an Entra ID flow or falling back silently. configuration (via config file or command-line input) exit non-zero with an unsupported-authentication-type error, verified across the CLI's automated test suite, with no Entra ID flow attempted and no silent fallback. +- **SC-008**: 100% of CLI invocations where the resolved authorisation type is + `none` never send `GITHUB_TOKEN` or any other resolved token as a credential + during a remote ruleset fetch, verified across the CLI's automated test suite, + even when `GITHUB_TOKEN` is set in the environment. +- **SC-009**: 100% of CLI invocations supplying an authorisation-related option + that does not apply — either because the resolved authorisation type is `none` or + because the ruleset source is local — print an explanatory warning for each such + option and still complete the invocation (no non-zero exit caused solely by the + ignored option), verified across the CLI's automated test suite. +- **SC-010**: 100% of the existing `backstage-plugin-api-grade` and + `backstage-plugin-api-grade-backend` automated tests pass unmodified, and both + packages build successfully, after the core-package refactor — confirming zero + functionality change for existing Backstage plugin consumers. ## Assumptions @@ -337,3 +476,13 @@ attempting an Entra ID flow or falling back silently. test suite; any test gaps in that suite are out of scope to backfill as part of this feature, though new shared-core tests are expected to cover the extracted logic. +- `--auth-type` is a CLI-only command-line option (mirroring the persisted + `auth.type` field) and is not itself a new field added to `api-grade-core`'s + `AuthConfig` type, which already has a `type` discriminator (FR-001/data-model); + the CLI option simply lets a user set/override that discriminator's value + per-invocation, the same way `--ruleset` overrides a persisted `rulesetPath`. +- This feature introduces no new functionality, configuration option, or behavior + for the `backstage-plugin-api-grade` / `backstage-plugin-api-grade-backend` + packages; "no behavioral change" for those packages is verified the same way as + for the MCP server — via their existing automated test suites passing unmodified + — and any test gaps in those suites are likewise out of scope to backfill here. From 939a40e97b151b96a846f12fa60cf95256cd53ea Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 15:05:56 +1000 Subject: [PATCH 06/10] Decomposed plan into tasks --- .../contracts/cli-options.md | 9 +- specs/008-cli-github-pat/research.md | 16 +- specs/008-cli-github-pat/spec.md | 49 +++- specs/008-cli-github-pat/tasks.md | 269 ++++++++++++++++++ 4 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 specs/008-cli-github-pat/tasks.md diff --git a/specs/008-cli-github-pat/contracts/cli-options.md b/specs/008-cli-github-pat/contracts/cli-options.md index 9c5d007..5651df1 100644 --- a/specs/008-cli-github-pat/contracts/cli-options.md +++ b/specs/008-cli-github-pat/contracts/cli-options.md @@ -124,12 +124,15 @@ rejection case. No `recoveryOptions` or `instructions` fields are present (FR-00 |---|---|---|---| | `--scope ` | string | yes | Which persisted config file to write. | | `--ruleset ` | string | no | Path or URL to set as the default. Omit to clear the default at that scope. | -| `--auth-type ` | string | no | Authorisation type to persist alongside the ruleset, stored as `auth.type`. Defaults to `github-pat` if omitted but `--token` is supplied; defaults to clearing `auth` (equivalent to `none`) if both are omitted. Passing `none` explicitly clears any persisted token at that scope. Does not accept `entra-id` (FR-015) — supplying it is rejected the same way as on the grading command (FR-016). | -| `--token ` | string | no | GitHub PAT to persist alongside the ruleset (stored under `auth.githubToken`). Only meaningful when the resolved `--auth-type` is `github-pat`; ignored with a warning (FR-020) if combined with `--auth-type none`. | +| `--auth-type ` | string | no | Authorisation type to persist alongside the ruleset, stored as `auth.type`. Defaults to `none` (equivalent to clearing/omitting `auth`) if omitted — including when `--token` is also supplied; `--token` never implicitly upgrades the persisted type to `github-pat` (mirrors the grade command's own `none`-default rule, FR-017/FR-020). Passing `none` explicitly clears any persisted token at that scope. Does not accept `entra-id` (FR-015) — supplying it is rejected the same way as on the grading command (FR-016). | +| `--token ` | string | no | GitHub PAT to persist alongside the ruleset (stored under `auth.githubToken`). Only meaningful, and only persisted, when `--auth-type github-pat` is also explicitly supplied; if `--auth-type` is omitted or `none`, the resolved type is `none`, the token is NOT persisted, and the CLI prints an FR-020 ignored-option warning instead. | Writes via core's `saveWorkspaceConfig`/`saveGlobalConfig` to the same file the MCP server's `set-ruleset-config` tool writes — both surfaces interoperate on the same -file. Does not expose `entra-id` as a settable `--auth-type` value (FR-015). +file. Does not expose `entra-id` as a settable `--auth-type` value (FR-015). Any +value other than `none`, `github-pat`, or `entra-id` is a `config-invalid` failure — +the command exits non-zero with an error naming the invalid value, without writing +the config file (FR-017). On success, prints a one-line confirmation (human) or `{ scope, rulesetPath, configFile }` (JSON) — never the token value. diff --git a/specs/008-cli-github-pat/research.md b/specs/008-cli-github-pat/research.md index 4c083f9..5948cdc 100644 --- a/specs/008-cli-github-pat/research.md +++ b/specs/008-cli-github-pat/research.md @@ -66,12 +66,16 @@ - **Decision**: Add a `config` subcommand to the existing `commander` program in `src/cli/index.ts`, with two further subcommands: `config set-ruleset` and `config get-ruleset`, implemented in a new `src/cli/ruleset-config-cli.ts`. `set-ruleset` - accepts `--scope `, `--ruleset `, and `--token - `; it writes via core's `saveWorkspaceConfig`/`saveGlobalConfig` using the same - `RulesetConfig`/`AuthConfig` JSON shape the MCP server already persists and reads. - `get-ruleset` prints the effective resolution plus per-scope values, with secrets - redacted (mirroring MCP's `sanitizeAuth` pattern — show `tokenSource`, never the - token itself). + accepts `--scope `, `--ruleset `, `--auth-type + `, and `--token `; it writes via core's + `saveWorkspaceConfig`/`saveGlobalConfig` using the same `RulesetConfig`/`AuthConfig` + JSON shape the MCP server already persists and reads. Per the spec's Clarifications + (2026-06-21, Q1), `--token` supplied without an explicit `--auth-type github-pat` + does NOT implicitly persist `auth.type: "github-pat"` — it resolves to `none` (same + default as the grade command), the token is not written to the config file, and an + FR-020 ignored-option warning is printed. `get-ruleset` prints the effective + resolution plus per-scope values, with secrets redacted (mirroring MCP's + `sanitizeAuth` pattern — show `tokenSource`, never the token itself). - **Rationale**: Directly fulfills FR-005 (persistent config commands/options at workspace/global scope) and SC-002, reusing the exact persisted file format/location documented in the spec's Assumptions, so a workspace already configured via the MCP diff --git a/specs/008-cli-github-pat/spec.md b/specs/008-cli-github-pat/spec.md index fca5a93..4ec8c02 100644 --- a/specs/008-cli-github-pat/spec.md +++ b/specs/008-cli-github-pat/spec.md @@ -8,6 +8,13 @@ **Input**: User description: "Add CLI support for rulesets hosted on GitHub private repos (via PAT)." Refined: the GitHub PAT ruleset-fetching and multi-level persistent ruleset configuration capability already exists in `api-grade-mcp`, is well tested, and must be extracted into `api-grade-core` so both the CLI and the MCP server consume one shared implementation. The MCP server's behavior (including its error handling, response shapes, and messages) must remain unchanged. The CLI gains new command-line options and persistent configuration capabilities to use the shared implementation. The existing Microsoft Entra ID authentication capability is extracted into core alongside GitHub PAT (same no-regression requirement for the MCP server), but is deliberately kept inaccessible and undocumented at the CLI surface in this feature — laying groundwork for a planned future CLI feature rather than shipping Entra ID support to CLI users now. The `backstage-plugin-api-grade` and `backstage-plugin-api-grade-backend` packages, which also depend on `api-grade-core` (the backend plugin imports it directly), must continue to function exactly as before: this feature changes `api-grade-core`'s internal structure and adds new exports, but introduces no functionality change for existing Backstage plugin consumers. +## Clarifications + +### Session 2026-06-21 + +- Q: `config set-ruleset --token ` without an explicit `--auth-type` — should it implicitly persist `auth.type: "github-pat"`, or follow the grade command's own `none`-default rule (token rejected/ignored with a warning, not silently stored)? → A: Same as grade command — `--token` alone never implies `github-pat`; without `--auth-type github-pat` the token is not persisted, and a warning is printed explaining it was ignored. +- Q: What happens when `--auth-type` is supplied with a value other than `none`, `github-pat`, or `entra-id` (e.g. a typo)? → A: Treated as a `config-invalid` failure — the CLI exits non-zero with an error naming the invalid value, reusing the existing `config-invalid` failure classification (FR-008). + ## User Scenarios & Testing *(mandatory)* ### User Story 1 - Grade using a private-repo ruleset from the CLI (Priority: P1) @@ -225,6 +232,11 @@ attempting an Entra ID flow or falling back silently. reported, **Then** it does not attempt to fetch the ruleset, does not fall back to the built-in default ruleset silently, and does not partially apply any other configured options. +4. **Given** a workspace or global config file with `auth.type: "entra-id"` set as + the default ruleset's auth configuration, **When** the CLI is run with `--ruleset` + pointing to a local file path, **Then** the CLI does NOT reject the invocation for + the unsupported auth type; it prints an ignored-option warning (per FR-021) and + proceeds to grade the local file normally. ### Edge Cases @@ -250,11 +262,16 @@ attempting an Entra ID flow or falling back silently. extracted into core (same as GitHub PAT) so the MCP server keeps working unchanged, but it is not wired up to any CLI command-line option or made part of CLI documentation. If a config file or command-line input nonetheless specifies - Entra ID as the auth type for a CLI invocation, the CLI MUST reject it with an - explicit "unsupported authentication type" error and exit non-zero, rather than - attempting the flow or ignoring the field. This groundwork is intended to make a - planned future CLI feature (full Entra ID support, Feature 10) easier to deliver - without another core refactor. + Entra ID as the auth type for a CLI invocation **against a remote (URL) ruleset**, + the CLI MUST reject it with an explicit "unsupported authentication type" error and + exit non-zero, rather than attempting the flow or ignoring the field. **If the + resolved ruleset source is local (a file path), FR-019's local-source rule takes + precedence: the CLI MUST NOT reject the invocation for an `entra-id` auth type in + this case — it MUST instead print an FR-021 ignored-option warning (as it would for + any other auth type) and proceed to grade the local file**, since authorisation + never applies to local reads regardless of which type is configured. This groundwork + is intended to make a planned future CLI feature (full Entra ID support, Feature 10) + easier to deliver without another core refactor. - How does the CLI behave when a configured default ruleset cannot be fetched (auth, access, or network failure)? The CLI MUST fail the invocation non-zero with an error naming the failure category, distinct from (and reported before) a @@ -278,6 +295,11 @@ attempting an Entra ID flow or falling back silently. path rather than a URL? The CLI MUST print a warning for each such ignored option, explaining that authorisation does not apply to local rulesets, and MUST continue grading using the local file rather than erroring. +- What happens when `--auth-type` is supplied with a value other than `none`, + `github-pat`, or `entra-id` (e.g. a typo such as `github_pat`)? The CLI MUST treat + this as a `config-invalid` failure and exit non-zero with an error naming the + invalid value, rather than silently defaulting to `none` or attempting to use the + value as-is. ## Requirements *(mandatory)* @@ -308,7 +330,12 @@ attempting an Entra ID flow or falling back silently. scope and at global scope, with workspace taking precedence over global, and an explicit per-invocation `--ruleset` taking precedence over both — consistent with the precedence rules already implemented for the MCP server's session, workspace, - and global scopes. + and global scopes. When the persistent-configuration command is given `--token` + without an explicit `--auth-type github-pat`, it MUST NOT implicitly persist + `auth.type: "github-pat"` — it follows the same `none`-default rule as the grade + command (FR-017/FR-020): the token is not persisted, and a warning is printed + explaining that `--token` was ignored because no `--auth-type github-pat` was + supplied. - **FR-006**: The CLI MUST send the resolved token as a Bearer token in the `Authorization` HTTP header when fetching a ruleset from a GitHub host, using the shared core implementation rather than a separate CLI-specific mechanism. @@ -345,7 +372,9 @@ attempting an Entra ID flow or falling back silently. configuration, the CLI MUST exit non-zero with an explicit unsupported-authentication-type error, MUST NOT attempt the Entra ID authentication flow, and MUST NOT silently fall back to the built-in default - ruleset. + ruleset. This rejection applies only when the resolved ruleset source is remote (a + URL); per FR-019, a local ruleset source suppresses this rejection in favor of the + FR-021 ignored-option warning. - **FR-017**: The CLI MUST expose an optional `--auth-type ` command-line option, equivalent to the `auth.type` field of persisted ruleset configuration, for selecting the authorisation type used to resolve and fetch a ruleset. The only @@ -353,7 +382,11 @@ attempting an Entra ID flow or falling back silently. accepted and is the default behaviour when the option is omitted and no auth type is resolved from persisted configuration — equivalent to `auth.type` being absent from the configuration file. (`entra-id` is also recognised, but solely so it can - be rejected per FR-016/FR-015; it is not a documented value.) + be rejected per FR-016/FR-015; it is not a documented value.) Any other value + supplied to `--auth-type` MUST be treated as a `config-invalid` failure: the CLI + exits non-zero with an error naming the invalid value, using the same + `config-invalid` classification as other malformed-auth-configuration cases + (FR-008). - **FR-018**: The resolved authorisation type — from `--auth-type`, from persisted workspace/global configuration, or defaulted to `none` — MUST strictly govern CLI behaviour when fetching a remote ruleset (one supplied as a URL), regardless of diff --git a/specs/008-cli-github-pat/tasks.md b/specs/008-cli-github-pat/tasks.md new file mode 100644 index 0000000..0d758b8 --- /dev/null +++ b/specs/008-cli-github-pat/tasks.md @@ -0,0 +1,269 @@ +--- + +description: "Task list for Shared GitHub PAT Ruleset Support for the CLI" +--- + +# Tasks: Shared GitHub PAT Ruleset Support for the CLI + +**Input**: Design documents from `/specs/008-cli-github-pat/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/cli-options.md, quickstart.md + +**Tests**: Included — Constitution Principle IV (Test-Driven Quality) requires tests written alongside implementation, and plan.md's Testing section names the specific new/moved test files below. + +**Organization**: Tasks are grouped by user story (per spec.md priorities) to enable independent implementation and testing of each story. User Stories 3 and 4 are pure regression-verification stories (no new production code; they verify the Foundational phase introduced no behavioral drift). + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1–US5) +- Setup/Foundational/Polish tasks carry no story label + +## Path Conventions + +Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src/cli`, root `tests/`, plus each package's own `tests/` directory. Paths below are exact, per plan.md's Project Structure. + +--- + +## Phase 1: Setup + +**Purpose**: Prepare the core package to receive the moved Entra ID logic before any extraction happens. + +- [ ] T001 Add `@azure/msal-node` (`^2.16.2`, matching `packages/api-grade-mcp/package.json`'s current version) to the `dependencies` of `packages/api-grade-core/package.json`, since `auth/entra.ts` moves into core in the next phase and FR-014 requires core to declare its own runtime dependencies rather than relying on a consumer's + +--- + +## Phase 2: Foundational (Core Extraction — Blocking Prerequisite for ALL User Stories) + +**Purpose**: Extract the GitHub PAT/Entra ID auth, fetch-failure classification, and multi-level config-resolution logic from `api-grade-mcp` into `api-grade-core` (FR-001), then refactor `api-grade-mcp` to consume it with zero observable behavior change (FR-002). Existing MCP unit test files (`github.test.ts`, `ruleset-config.test.ts`, `resolve-ruleset.test.ts`) import the moved modules by their original relative paths — those paths MUST keep resolving via thin re-export shims, so the files can remain byte-for-byte unmodified (SC-003). + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete and T024/T025 pass. + +- [ ] T002 Extend `packages/api-grade-core/src/types.ts` with `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState` — copied verbatim (no field added/removed/renamed) from `packages/api-grade-mcp/src/types.ts`. Add only; do not touch any existing core type. +- [ ] T003 [P] Create `packages/api-grade-core/src/auth/github.ts` containing `fetchRulesetContent`, `fetchRulesetWithGithubPat`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS` — moved verbatim from `packages/api-grade-mcp/src/auth/github.ts` +- [ ] T004 [P] Create `packages/api-grade-core/src/auth/entra.ts` containing `acquireEntraToken`, `EntraAuthRequired` — moved verbatim from `packages/api-grade-mcp/src/auth/entra.ts` +- [ ] T005 [P] Create `packages/api-grade-core/src/config/ruleset-config.ts` containing `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError` — moved verbatim from `packages/api-grade-mcp/src/config/ruleset-config.ts`, importing `RulesetConfig` from `../types.js` (T002) +- [ ] T006 [P] Create `packages/api-grade-core/src/config/resolve-ruleset.ts` containing `resolveRuleset` — moved verbatim from `packages/api-grade-mcp/src/config/resolve-ruleset.ts`, importing types from `../types.js` (T002) +- [ ] T007 Extend `packages/api-grade-core/src/index.ts` to export everything added in T002–T006 (types: `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState`; values: `fetchRulesetContent`, `fetchRulesetWithGithubPat`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS`, `acquireEntraToken`, `EntraAuthRequired`, `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError`, `resolveRuleset`) — append only; no existing export line is modified or removed (FR-022) (depends on T002–T006) +- [ ] T008 [P] Replace the body of `packages/api-grade-mcp/src/auth/github.ts` with a re-export shim (`export { fetchRulesetContent, fetchRulesetWithGithubPat, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS, RETRY_FETCH_TIMEOUT_MS } from '@dawmatt/api-grade-core';`) so `packages/api-grade-mcp/tests/unit/github.test.ts`'s existing `../../src/auth/github.js` import keeps resolving unmodified (depends on T007) +- [ ] T009 [P] Replace the body of `packages/api-grade-mcp/src/auth/entra.ts` with a re-export shim (`export { acquireEntraToken, EntraAuthRequired } from '@dawmatt/api-grade-core';`) (depends on T007) +- [ ] T010 [P] Replace the body of `packages/api-grade-mcp/src/config/ruleset-config.ts` with a re-export shim for `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError` from `@dawmatt/api-grade-core`, so `packages/api-grade-mcp/tests/integration/set-ruleset-config.test.ts` and `get-ruleset-config.test.ts`'s existing imports keep resolving unmodified (depends on T007) +- [ ] T011 [P] Replace the body of `packages/api-grade-mcp/src/config/resolve-ruleset.ts` with a re-export shim for `resolveRuleset` from `@dawmatt/api-grade-core`, so `packages/api-grade-mcp/tests/unit/resolve-ruleset.test.ts`'s existing import keeps resolving unmodified (depends on T007) +- [ ] T012 Trim `packages/api-grade-mcp/src/types.ts` to keep only `RecoveryOptionId` and `RecoveryOption`, adding re-exports of `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState` from `@dawmatt/api-grade-core`, so existing unmodified imports of these types from `../types.js` (in `packages/api-grade-mcp/tests/unit/ruleset-config.test.ts` and `resolve-ruleset.test.ts`) keep resolving (depends on T007) +- [ ] T013 [P] Update `packages/api-grade-mcp/src/tools/grade.ts` to import `fetchRulesetContent`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS`, `EntraAuthRequired`, `acquireEntraToken`, `resolveRuleset`, `loadWorkspaceConfig`, `loadGlobalConfig`, and the `SessionState`/`AuthConfig` types directly from `@dawmatt/api-grade-core` instead of relative `../auth/...`/`../config/...`/`../types.js` paths — no change to tool logic, schema, or output shape (depends on T007) +- [ ] T014 [P] Update `packages/api-grade-mcp/src/tools/grade-detailed.ts` the same way as T013 (depends on T007) +- [ ] T015 [P] Update `packages/api-grade-mcp/src/tools/quick-fixes-only.ts` the same way as T013 (depends on T007) +- [ ] T016 [P] Update `packages/api-grade-mcp/src/tools/assert-grade.ts` the same way as T013 (depends on T007) +- [ ] T017 [P] Update `packages/api-grade-mcp/src/tools/set-ruleset-config.ts` the same way as T013 (depends on T007) +- [ ] T018 [P] Update `packages/api-grade-mcp/src/tools/get-ruleset-config.ts` the same way as T013 (depends on T007) +- [ ] T019 [P] Create `packages/api-grade-core/tests/unit/auth-github.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/github.test.ts` to import from core's own `../../src/auth/github.js` (leave the MCP original file byte-for-byte unmodified); retain any existing no-ref/default-branch test case from the original file to preserve FR-013 coverage (depends on T003) +- [ ] T020 [P] Create `packages/api-grade-core/tests/unit/ruleset-config.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/ruleset-config.test.ts` to import from core's own `../../src/config/ruleset-config.js` and `../../src/types.js` (depends on T005) +- [ ] T021 [P] Create `packages/api-grade-core/tests/unit/resolve-ruleset.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/resolve-ruleset.test.ts` to import from core's own `../../src/config/resolve-ruleset.js` and `../../src/types.js` (depends on T006) +- [ ] T022 Remove the now-unused `@azure/msal-node` dependency from `packages/api-grade-mcp/package.json` (entra.ts now re-exports from core, which declares the dependency itself per T001); run install and confirm it still resolves (depends on T009) +- [ ] T023 Run `npm run build` and `npm run typecheck` for `packages/api-grade-core` and `packages/api-grade-mcp`; fix any compile error surfaced by the restructuring without editing any test assertion; spot-check `packages/api-grade-core/src/index.ts`'s exports for any leaked MCP-only (`Recovery*`) or CLI-only symbol per FR-014 (depends on T008–T022) +- [ ] T024 Run `npm test --workspace=packages/api-grade-mcp` and confirm 100% of existing tests pass with zero edits to any test file (FR-002/SC-003 gate) (depends on T023) +- [ ] T025 Run `npm test --workspace=packages/api-grade-core` and confirm the new tests from T019–T021 pass (depends on T023) + +**Checkpoint**: Core now exposes the shared auth/config implementation; MCP server behavior is provably unchanged. CLI implementation can begin. + +--- + +## Phase 3: User Story 1 - Grade using a private-repo ruleset from the CLI (Priority: P1) 🎯 MVP + +**Goal**: `api-grade --ruleset --auth-type github-pat --token ` (or `GITHUB_TOKEN`) fetches and grades against a private GitHub-hosted ruleset, with correct ignored-option warnings and failure classification when auth type is `none` or the ruleset is local. + +**Independent Test**: Create a private GitHub repo with a minimal Spectral ruleset, generate a scoped PAT, run the CLI with the ruleset URL + `--auth-type github-pat` + token, confirm grading succeeds using that ruleset. + +### Tests for User Story 1 + +> Write these tests FIRST, ensure they FAIL before implementation. + +- [ ] T026 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--ruleset --auth-type github-pat --token ` against a local stub GitHub-like HTTP server fetches the ruleset and grades successfully — covers Acceptance Scenario 1 +- [ ] T027 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: the same private-ruleset auth path graded against an AsyncAPI fixture (`tests/fixtures/asyncapi/streetlights-api.yaml`) succeeds identically to the OpenAPI case — covers FR-011 (multi-format uniformity) +- [ ] T028 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--auth-type github-pat` with no token (or a token the stub server rejects with 401/403) exits 1 with an authentication-required/auth-failed message, and the supplied/missing token value never appears in stdout or stderr — covers Acceptance Scenario 2 & FR-007/SC-005 +- [ ] T029 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `GITHUB_TOKEN` env var is used automatically when `--auth-type github-pat` is set and no `--token` is supplied — covers Acceptance Scenario 3 +- [ ] T030 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: no `--auth-type` (defaults to `none`) with `--token` supplied prints `Warning: --token is ignored because the authorisation type is 'none'...` and the fetch proceeds unauthenticated and fails against the private stub — covers Acceptance Scenario 4 & FR-020/SC-009 +- [ ] T031 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--ruleset ./local-file.yaml --auth-type github-pat --token ` prints one warning per ignored option ("ruleset is a local file...") and grades successfully using the local file — covers Acceptance Scenario 5 & FR-021/SC-009 + +### Implementation for User Story 1 + +- [ ] T032 [US1] Add `--auth-type ` and `--token ` option declarations to the `api-grade ` command in `src/cli/index.ts` (depends on Foundational) +- [ ] T033 [US1] Implement the auth-type resolution step in `src/cli/index.ts`: `--auth-type` CLI option → resolved scope's `auth?.type` → `'none'` default; computed unconditionally per FR-017 (even for a local ruleset, since the warning logic needs it) (depends on T032, Foundational) +- [ ] T034 [US1] Implement ruleset path resolution in `src/cli/index.ts` using core's `resolveRuleset(rulesetOption, sessionState, workspaceConfig, globalConfig)` with a fresh inert `SessionState` (`{ defaultRuleset: null, sessionRulesetOverride: null }`) constructed per invocation (per research.md R2) (depends on Foundational) +- [ ] T035 [US1] Implement token resolution in `src/cli/index.ts`, gated to run only when the resolved auth type (T033) is `'github-pat'`: `--token` → `GITHUB_TOKEN` env var → resolved scope's `auth.githubToken` (FR-004/FR-018) (depends on T033) +- [ ] T036 [US1] Implement the ignored-option warning helper in `src/cli/index.ts`: one `console.warn` line per ignored option for (a) auth type `none` with `--token` supplied (FR-020) and (b) local ruleset source with `--auth-type`/`--token` supplied (FR-021), using the local-ruleset wording when both conditions apply (per contracts/cli-options.md) (depends on T033, T034, T035) +- [ ] T037 [US1] Wire the remote-fetch path into the grade action handler in `src/cli/index.ts`: when the resolved ruleset path is an http(s) URL, call core's `fetchRulesetContent` with the resolved token (`'github-pat'`) or no token (`'none'`), write the fetched content to a temp file (mirroring MCP `grade.ts`'s temp-file pattern), and pass that path to `GradeEngine.grade()` (FR-006) (depends on T034, T035) +- [ ] T038 [US1] Implement CLI fetch-failure reporting in `src/cli/index.ts`: catch `RulesetAuthError` (and a CLI-local `config-invalid` case for an unusable `github-pat` token), map to a human stderr message (`--format human`) or `{ error, failureReason, rulesetUrl, scope, message }` JSON object on stdout (`--format json`) per contracts/cli-options.md's `error` code mapping, then `process.exit(1)` with no grading and no fallback to the built-in ruleset (FR-008/FR-009/FR-010) (depends on T037) +- [ ] T039 [US1] Implement the unsupported-auth-type rejection in `src/cli/index.ts`: if the resolved auth type (T033) is `'entra-id'`, print the unsupported-authentication-type stderr error and `process.exit(1)` before any fetch attempt or other option is applied (FR-015/FR-016) (depends on T033) +- [ ] T040 [US1] Audit every `console.log`/`console.error`/`console.warn` call site added in T032–T039 to confirm no resolved token value or stored secret field is ever printed, including under `--verbose` (FR-007/SC-005) + +**Checkpoint**: User Story 1 is fully functional and independently testable — the CLI grades against a private GitHub-hosted ruleset with correct auth-type gating, warnings, and failure reporting. + +--- + +## Phase 4: User Story 2 - Configure a persistent default ruleset for repeated CLI/CI runs (Priority: P2) + +**Goal**: `api-grade config set-ruleset`/`config get-ruleset` let a default ruleset+auth be configured once at workspace or global scope and used by every subsequent invocation, with the same precedence already proven for the MCP server. + +**Independent Test**: Configure a default ruleset+token at workspace scope, run the CLI repeatedly with no `--ruleset`, confirm every run uses it; confirm global-scope fallback when no workspace default exists. + +### Tests for User Story 2 + +- [ ] T041 [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config set-ruleset --scope workspace --ruleset --auth-type github-pat --token ` writes `.api-grade/config.json` with the expected `RulesetConfig`/`AuthConfig` shape, and omitting `--ruleset` clears the default at that scope +- [ ] T041a [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config set-ruleset --scope workspace --ruleset --token ` (no `--auth-type`) does NOT persist `auth.type: "github-pat"` or the token — the written config's `auth` is absent/`null` (equivalent to `none`) — and the command prints an FR-020 ignored-option warning for `--token`; same behavior for `--auth-type none --token ` explicitly — covers spec Clarifications Q1 (2026-06-21) +- [ ] T042 [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config get-ruleset` reports the effective scope/path/auth type plus per-scope values, redacting token values to `(token configured)`/`(no token)`/`(from GITHUB_TOKEN)` in both human and JSON output — never the raw token +- [ ] T043 [P] [US2] Integration test in `tests/integration/cli-github-pat.test.ts`: grading with no `--ruleset` uses a workspace-configured default; falls back to a global default when no workspace config exists; an explicit per-invocation `--ruleset` overrides both — covers Acceptance Scenarios 1–4 +- [ ] T044 [P] [US2] Integration test in `tests/integration/cli-github-pat.test.ts`: `GITHUB_TOKEN` is used automatically for a configured default when the resolved auth type is `github-pat` and no token-related option/stored token exists; a persisted `auth.type: "github-pat"` with a stored token behaves as if `--auth-type github-pat` were passed explicitly; a default with no `auth` field resolves to `none` — covers Acceptance Scenarios 5–7 + +### Implementation for User Story 2 + +- [ ] T045 [US2] Create `src/cli/ruleset-config-cli.ts` implementing `config set-ruleset`: `--scope ` (required), `--ruleset ` (optional, omit clears), `--auth-type ` (optional; any other value, e.g. a typo, is a `config-invalid` failure per FR-017/contracts/cli-options.md), `--token ` (optional); writes via core's `saveWorkspaceConfig`/`saveGlobalConfig`; rejects `--auth-type entra-id` the same way as the grade command (FR-005/FR-015). Per spec Clarifications Q1 (2026-06-21): `--token` supplied without `--auth-type github-pat` MUST NOT persist `auth.type: "github-pat"` — the resolved type is `none`, the token is not written, and an FR-020 ignored-option warning is printed instead (depends on Foundational) +- [ ] T046 [US2] Implement `config get-ruleset` in `src/cli/ruleset-config-cli.ts`: loads workspace+global config via core's loaders, resolves the effective ruleset via `resolveRuleset` with an inert `SessionState`, and prints human or JSON output per contracts/cli-options.md (token presence only, never the value) (depends on T045) +- [ ] T047 [US2] Register the `config` subcommand (`set-ruleset`, `get-ruleset`) on the main `commander` program in `src/cli/index.ts` (depends on T045, T046) +- [ ] T048 [US2] Extract the auth-type/token/ignored-warning resolution logic built in User Story 1 (T033/T035/T036) into a shared helper module consumed by the grade action handler, `config set-ruleset`, and `config get-ruleset`, so the `none`-default/ignored-warning rule (including Q1's `config set-ruleset --token` case) can never diverge across the three surfaces (depends on T036, T045, T046) + +**Checkpoint**: User Stories 1 and 2 both work independently — persistent workspace/global ruleset+auth defaults are configurable and consumed by every subsequent grading invocation. + +--- + +## Phase 5: User Story 3 - MCP behavior is unaffected by the refactor (Priority: P1) + +**Goal**: Confirm, after all CLI work (US1/US2) has landed on top of the Foundational extraction, that the MCP server's tool contracts and error responses remain byte-identical to pre-refactor behavior. + +**Independent Test**: Run the MCP server's existing automated test suite, unmodified, and confirm 100% pass. + +- [ ] T049 [US3] Re-run `npm test --workspace=packages/api-grade-mcp` after Phases 3–4 land, confirming zero MCP test file edits and 100% pass rate (SC-003) — guards against any later CLI-focused task accidentally touching shared core behavior +- [ ] T050 [P] [US3] Run `git diff --stat` for `packages/api-grade-mcp/tests/` against the pre-feature branch point and confirm no MCP test file was added, removed, or edited by this feature — covers Acceptance Scenario 1 +- [ ] T051 [P] [US3] Using the existing MCP integration tests, spot-check one `auth-failed`, one `not-found`, and one `network-unreachable` `grade-api` response to confirm error code, message text, and `recoveryOptions` payload are byte-identical to pre-refactor behavior — covers Acceptance Scenario 2 + +**Checkpoint**: MCP server's tool contracts and error responses are confirmed unchanged after the full feature lands. + +--- + +## Phase 6: User Story 4 - Backstage plugin packages are unaffected by the refactor (Priority: P1) + +**Goal**: Confirm the core-package refactor introduces zero behavioral or build change for `backstage-plugin-api-grade` and `backstage-plugin-api-grade-backend`. + +**Independent Test**: Run both packages' existing build and test suites, unmodified, against the post-refactor core and confirm both succeed. + +- [ ] T052 [US4] Run `npm run build --workspace=packages/backstage-plugin-api-grade-backend --workspace=packages/backstage-plugin-api-grade` and confirm both build successfully against the post-refactor core (FR-022/SC-010) +- [ ] T053 [P] [US4] Run `npm test --workspace=packages/backstage-plugin-api-grade-backend --workspace=packages/backstage-plugin-api-grade` and confirm 100% pass with zero test-file edits (FR-023/SC-010) +- [ ] T054 [P] [US4] Run `grep -rn "api-grade-core" packages/backstage-plugin-api-grade-backend/src packages/backstage-plugin-api-grade/src` and confirm the only imported symbols (`GradeEngine`, `GradeResult`, `LetterGrade`, `GradeLabel`, `DiagnosticSummary`, `Diagnostic`) remain exported from `packages/api-grade-core/src/index.ts` with unchanged signatures — covers Acceptance Scenario 1 + +**Checkpoint**: Backstage plugin packages confirmed unaffected; SC-010 satisfied. + +--- + +## Phase 7: User Story 5 - CLI rejects Entra ID authentication explicitly (Priority: P3) + +**Goal**: Any attempt to use Entra ID auth from the CLI — via a config file or `--auth-type entra-id` — is rejected with a clear, non-zero-exit error, never attempted or silently ignored. + +**Independent Test**: Set `auth.type: "entra-id"` in a workspace/global config (or pass `--auth-type entra-id`), run the CLI, confirm a non-zero exit with an explicit unsupported-authentication-type error. + +### Tests for User Story 5 + +- [ ] T055 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: a workspace/global config with `auth.type: "entra-id"` and no `--ruleset` override causes the CLI to exit non-zero with the unsupported-authentication-type error — covers Acceptance Scenario 1 +- [ ] T056 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: `--auth-type entra-id` on the grade command exits non-zero with the same error, with no device-code/token flow attempted — covers Acceptance Scenario 2 +- [ ] T057 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: the entra-id rejection occurs before any fetch attempt, with no fallback to the built-in ruleset and no partial application of any other supplied option — covers Acceptance Scenario 3 & SC-007 + +### Implementation for User Story 5 + +- [ ] T057a [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: a workspace/global config with `auth.type: "entra-id"` combined with a local `--ruleset` file path does NOT trigger the unsupported-auth-type rejection; it prints the FR-021 ignored-option warning and grades the local file successfully — covers US5 Acceptance Scenario 4 +- [ ] T058 [US5] Extend the shared rejection check (built in T039, centralized in T048) so it also (a) is reached via `config get-ruleset`'s resolution path — informational-only there per contracts/cli-options.md (no non-zero exit on that read-only path), and (b) is bypassed (in favor of the FR-021 local-ruleset warning) when the resolved ruleset source is local — covering every config-sourced route to `entra-id` without contradicting FR-019 (FR-016/FR-019) +- [ ] T059 [US5] Confirm `--auth-type entra-id` is recognised by commander's option parser but does not appear in `--help` output text or any CLI documentation (FR-015/FR-017) + +**Checkpoint**: All five user stories are independently functional; SC-007 is satisfied across both config-file-sourced and CLI-option-sourced Entra ID attempts. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +- [ ] T060 [P] Update `docs/cli` and the root `README.md` with `--auth-type`, `--token`, `GITHUB_TOKEN`, `config set-ruleset`/`config get-ruleset` usage and containerised execution instructions (`-e GITHUB_TOKEN`, bind-mounting `.api-grade`/`~/.api-grade`), per FR-012 and quickstart.md +- [ ] T061 [P] Update CLI `--help` text for the new options/subcommands, excluding any mention of `entra-id` (FR-015) +- [ ] T062 Manually run through `specs/008-cli-github-pat/quickstart.md` scenarios 1–8 against the built CLI and confirm every documented example behaves as written +- [ ] T063 Run the full quality gate (`npm run lint && npm run typecheck && npm test && npm run build`, across all workspaces) and fix any failure before considering the feature complete, per the Constitution's `/speckit-implement` gate requirement +- [ ] T063a [P] Run `grep -rn "fetchRulesetContent\|fetchRulesetWithGithubPat\|acquireEntraToken\|resolveRuleset\b" packages/api-grade-mcp/src` and confirm every match is inside a re-export shim (T008-T011), not a reimplementation — final SC-006 sign-off + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately. +- **Foundational (Phase 2)**: Depends on Setup (T001) — BLOCKS all user stories. T024 (MCP suite unmodified pass) is the hard gate before any CLI work begins. +- **User Story 1 (Phase 3)**: Depends on Foundational completion only. +- **User Story 2 (Phase 4)**: Depends on Foundational completion; T048 also depends on US1's T033/T035/T036, so in practice runs after Phase 3. +- **User Story 3 (Phase 5)**: Depends on Foundational (T024 already proved this); Phase 5's tasks formalize the check *after* Phases 3–4 to guard against later regressions. +- **User Story 4 (Phase 6)**: Depends only on Foundational completion (Backstage packages are never touched); can run any time after Phase 2, in parallel with Phases 3–5. +- **User Story 5 (Phase 7)**: Depends on US1's T039 and US2's T048 (the shared rejection/resolution helper it extends). +- **Polish (Phase 8)**: Depends on all desired user stories being complete. + +### Parallel Opportunities + +- T003–T006 (module moves into core) run in parallel — different files. +- T008–T011 (MCP re-export shims) run in parallel once T007 lands. +- T013–T018 (MCP tool import updates) run in parallel once T007 lands. +- T019–T021 (new core unit tests) run in parallel, each gated only on its corresponding move task. +- All US1 test tasks (T026–T031) run in parallel; all US2 test tasks (T041, T041a–T044) run in parallel; all US5 test tasks (T055–T057, T057a) run in parallel. +- Phase 6 (User Story 4) can run any time after Phase 2 completes, in parallel with Phases 3, 4, 5, and 7 — it touches no shared file. +- T060/T061 (Polish docs) run in parallel. + +--- + +## Parallel Example: Foundational Phase + +```bash +# Launch all four module moves into core together: +Task: "Create packages/api-grade-core/src/auth/github.ts (moved verbatim)" +Task: "Create packages/api-grade-core/src/auth/entra.ts (moved verbatim)" +Task: "Create packages/api-grade-core/src/config/ruleset-config.ts (moved verbatim)" +Task: "Create packages/api-grade-core/src/config/resolve-ruleset.ts (moved verbatim)" +``` + +## Parallel Example: User Story 1 Tests + +```bash +Task: "Integration test: --auth-type github-pat + --token fetches and grades successfully" +Task: "Integration test: same path against an AsyncAPI fixture (FR-011)" +Task: "Integration test: missing/invalid token exits 1, never leaks the token" +Task: "Integration test: GITHUB_TOKEN env var used automatically" +Task: "Integration test: auth-type none default + --token prints ignored-option warning" +Task: "Integration test: local ruleset + auth options prints warnings, grades the local file" +``` + +--- + +## Implementation Strategy + +### MVP First (Foundational + User Story 1) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational — CRITICAL, includes the hard MCP-no-regression gate (T024). +3. Complete Phase 3: User Story 1. +4. **STOP and VALIDATE**: a developer with a PAT can grade against a private-repo ruleset from the CLI (SC-001). + +### Incremental Delivery + +1. Setup + Foundational → core extraction proven safe for MCP (T024/T025). +2. User Story 1 → CLI private-ruleset grading works (MVP, SC-001). +3. User Story 2 → persistent workspace/global defaults remove per-invocation friction (SC-002). +4. User Stories 3 & 4 → formal regression sign-off for MCP and Backstage (SC-003, SC-010) — can be run any time after Phase 2, but are listed last since they gate the overall feature's completeness, not its MVP. +5. User Story 5 → Entra ID explicit-rejection guardrail (SC-007), lowest priority (P3), shippable last without blocking US1/US2's value. +6. Polish → docs, `--help`, quickstart validation, full quality gate. + +### Parallel Team Strategy + +With multiple developers, after Foundational (Phase 2) completes: +- Developer A: User Story 1 (Phase 3), then User Story 5 (Phase 7, depends on US1's rejection hook). +- Developer B: User Story 2 (Phase 4, starts once US1's T033/T035/T036 land), then helps with Phase 8 Polish. +- Developer C: User Story 4 (Phase 6) immediately after Phase 2 — fully independent of CLI work — then User Story 3 (Phase 5) once Phases 3–4 land. + +--- + +## Notes + +- [P] tasks = different files, no dependencies on incomplete same-phase work. +- [Story] label maps each task to its user story for traceability. +- T024 (MCP suite, zero edits) is the single most important gate in this feature — every later phase assumes it passed. +- Commit after each task or logical group; do not edit any file under `packages/api-grade-mcp/tests/` while implementing this feature (Phases 2–8) — if a task seems to require it, the task is mis-scoped. From 3b262dc3ca06ed56d8c3e5c490665d943efec9cd Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 19:13:05 +1000 Subject: [PATCH 07/10] Refactoring implemented --- docs/cli/README.md | 3 +- docs/cli/commands.md | 89 ++++++- package-lock.json | 62 ++--- packages/api-grade-core/package.json | 1 + packages/api-grade-core/src/auth/entra.ts | 62 +++++ packages/api-grade-core/src/auth/github.ts | 51 ++++ .../src/config/resolve-ruleset.ts | 42 ++++ .../src/config/ruleset-config.ts | 57 +++++ packages/api-grade-core/src/index.ts | 30 +++ packages/api-grade-core/src/types.ts | 25 ++ .../tests/unit/auth-github.test.ts | 103 +++++++++ .../tests/unit/resolve-ruleset.test.ts | 70 ++++++ .../tests/unit/ruleset-config.test.ts | 87 +++++++ packages/api-grade-mcp/package.json | 1 - packages/api-grade-mcp/src/auth/entra.ts | 63 +---- packages/api-grade-mcp/src/auth/github.ts | 52 +---- .../src/config/resolve-ruleset.ts | 43 +--- .../src/config/ruleset-config.ts | 66 +----- .../api-grade-mcp/src/tools/assert-grade.ts | 21 +- .../src/tools/get-ruleset-config.ts | 11 +- .../api-grade-mcp/src/tools/grade-detailed.ts | 19 +- packages/api-grade-mcp/src/tools/grade.ts | 19 +- .../src/tools/quick-fixes-only.ts | 19 +- .../src/tools/set-ruleset-config.ts | 4 +- packages/api-grade-mcp/src/types.ts | 31 +-- src/cli/index.ts | 74 +++++- src/cli/ruleset-config-cli.ts | 167 ++++++++++++++ src/cli/ruleset-fetch-errors.ts | 49 ++++ src/cli/ruleset-fetch.ts | 43 ++++ src/cli/ruleset-resolution.ts | 106 +++++++++ tests/integration/cli-github-pat.test.ts | 218 ++++++++++++++++++ tests/unit/cli-ruleset-config.test.ts | 165 +++++++++++++ tests/unit/cli-ruleset-resolution.test.ts | 218 ++++++++++++++++++ tests/unit/ruleset-config-cli.test.ts | 145 ++++++++++++ yarn.lock | 9 +- 35 files changed, 1919 insertions(+), 306 deletions(-) create mode 100644 packages/api-grade-core/src/auth/entra.ts create mode 100644 packages/api-grade-core/src/auth/github.ts create mode 100644 packages/api-grade-core/src/config/resolve-ruleset.ts create mode 100644 packages/api-grade-core/src/config/ruleset-config.ts create mode 100644 packages/api-grade-core/tests/unit/auth-github.test.ts create mode 100644 packages/api-grade-core/tests/unit/resolve-ruleset.test.ts create mode 100644 packages/api-grade-core/tests/unit/ruleset-config.test.ts create mode 100644 src/cli/ruleset-config-cli.ts create mode 100644 src/cli/ruleset-fetch-errors.ts create mode 100644 src/cli/ruleset-fetch.ts create mode 100644 src/cli/ruleset-resolution.ts create mode 100644 tests/integration/cli-github-pat.test.ts create mode 100644 tests/unit/cli-ruleset-config.test.ts create mode 100644 tests/unit/cli-ruleset-resolution.test.ts create mode 100644 tests/unit/ruleset-config-cli.test.ts diff --git a/docs/cli/README.md b/docs/cli/README.md index 74a1e80..e13c8cc 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -24,7 +24,8 @@ The CLI supports **OpenAPI 2/3** and **AsyncAPI 2/3**, custom Spectral rulesets, - Full diagnostic list (errors first, then warnings) - OpenAPI 2/3 and AsyncAPI 2/3 support from one command - CI/CD gate via `--min-grade ` -- Custom Spectral-compatible rulesets via `--ruleset` +- Custom Spectral-compatible rulesets via `--ruleset`, including private GitHub-hosted rulesets via `--auth-type github-pat`/`--token`/`GITHUB_TOKEN` +- Persistent workspace/global ruleset+auth defaults via `config set-ruleset`/`config get-ruleset` - Machine-readable JSON output via `--format json` - Docker support for containerised pipelines - Cross-platform: macOS, Linux, and Windows diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 9e13013..94775a0 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -21,7 +21,9 @@ api-grade [options] | Flag | Description | |------|-------------| | `--min-grade ` | Exit with code 1 if the grade is below this threshold (A, B, C, D, or F) | -| `--ruleset ` | Path to a custom Spectral-compatible ruleset file | +| `--ruleset ` | Path to a custom Spectral-compatible ruleset file, or a URL into a private GitHub repository | +| `--auth-type ` | Authorisation type for fetching a remote ruleset: `none` (default) or `github-pat` | +| `--token ` | GitHub Personal Access Token used to authenticate a remote ruleset fetch (only consulted when `--auth-type github-pat`) | | `--format ` | Output format: `human` (default) or `json` | | `--top ` | Show only the top N diagnostics (useful for large specs) | | `--verbose` | Print the full error stack when a runtime error occurs | @@ -89,6 +91,73 @@ api-grade openapi.yaml --ruleset ./my-rules.yaml --verbose docker run --rm -v "$(pwd):/work" api-grade /work/openapi.yaml ``` +**Grade against a private GitHub-hosted ruleset using a Personal Access Token:** + +```bash +api-grade openapi.yaml \ + --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --auth-type github-pat \ + --token ghp_xxxxxxxxxxxxxxxxxxxx +``` + +Or via the `GITHUB_TOKEN` environment variable instead of `--token` (still requires +`--auth-type github-pat` — the token is never consulted unless the authorisation type +resolves to `github-pat`): + +```bash +export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx +api-grade openapi.yaml \ + --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --auth-type github-pat +``` + +--- + +## Persistent Ruleset Configuration (`config` subcommand) + +`api-grade config set-ruleset` / `api-grade config get-ruleset` let you configure a +default ruleset (and optional GitHub PAT auth) once, at workspace or global scope, +so every subsequent invocation in that workspace — including CI runs — uses it +automatically without repeating `--ruleset`/`--auth-type`/`--token` on every command. + +| Flag (`config set-ruleset`) | Required | Description | +|---|---|---| +| `--scope ` | yes | Which persisted config file to write: `.api-grade/config.json` (workspace) or `~/.api-grade/config.json` (global) | +| `--ruleset ` | no | Path or URL to set as the default; omit to clear the default at that scope | +| `--auth-type ` | no | Authorisation type to persist alongside the ruleset (defaults to `none`) | +| `--token ` | no | GitHub PAT to persist; only persisted when `--auth-type github-pat` is also explicitly supplied | +| `--format ` | no | Output format: `human` (default) or `json` | + +```bash +api-grade config set-ruleset \ + --scope workspace \ + --ruleset https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml \ + --auth-type github-pat \ + --token ghp_xxxxxxxxxxxxxxxxxxxx +``` + +Every subsequent invocation in this workspace uses it automatically: + +```bash +api-grade openapi.yaml --min-grade B --format json +``` + +Check what's configured (never prints the token value — only `(token configured)`, +`(no token)`, or `(from GITHUB_TOKEN)`): + +```bash +api-grade config get-ruleset +``` + +Workspace-scoped config always takes precedence over global config. Both surfaces +read/write the exact same file format as the `api-grade-mcp` server's +`set-ruleset-config`/`get-ruleset-config` tools — a workspace configured via one is +immediately usable by the other. + +> **Note:** Microsoft Entra ID authentication (used by the MCP server) is not +> supported by the CLI. If a shared config file specifies `auth.type: "entra-id"`, +> the CLI exits with a clear error rather than attempting it. + --- ## Configuration File @@ -193,6 +262,24 @@ Pass any flag as you would with the local CLI: docker run --rm -v "$(pwd):/work" api-grade /work/openapi.yaml --min-grade B --format json ``` +**Containerised CI run against a private-repo ruleset**, supplying the token via +environment variable (never persisted to disk) and bind-mounting the workspace and +home directory so persisted `config set-ruleset` defaults are visible inside the +container: + +```bash +docker run --rm \ + -e GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx \ + -v "$PWD":/workspace \ + -v "$HOME/.api-grade":/root/.api-grade \ + -w /workspace \ + dawmatt/api-grade:latest \ + openapi.yaml --min-grade B +``` + +The `-v "$PWD":/workspace` mount makes `.api-grade/config.json` (workspace scope) +visible; `-v "$HOME/.api-grade":/root/.api-grade` makes the global scope visible. + --- ## Further Reading diff --git a/package-lock.json b/package-lock.json index 17761bf..81a5aee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dawmatt/api-grade", - "version": "0.1.20", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dawmatt/api-grade", - "version": "0.1.20", + "version": "0.2.1", "license": "MIT", "workspaces": [ "packages/*" @@ -23366,9 +23366,10 @@ }, "packages/api-grade-core": { "name": "@dawmatt/api-grade-core", - "version": "0.1.20", + "version": "0.2.1", "license": "MIT", "dependencies": { + "@azure/msal-node": "^2.16.2", "@stoplight/spectral-core": "^1.23.0", "@stoplight/spectral-formats": "^1.8.3", "@stoplight/spectral-parsers": "^1.0.5", @@ -23387,30 +23388,7 @@ "node": ">=20.0.0" } }, - "packages/api-grade-mcp": { - "name": "@dawmatt/api-grade-mcp", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "@azure/msal-node": "^2.16.2", - "@dawmatt/api-grade-core": "*", - "@modelcontextprotocol/sdk": "^1.0.0", - "zod": "^3.22.0" - }, - "bin": { - "api-grade-mcp": "dist/index.js" - }, - "devDependencies": { - "@types/node": "^20.12.0", - "@vitest/coverage-v8": "^1.6.0", - "typescript": "^5.4.5", - "vitest": "^1.6.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/api-grade-mcp/node_modules/@azure/msal-common": { + "packages/api-grade-core/node_modules/@azure/msal-common": { "version": "14.16.1", "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", @@ -23419,7 +23397,7 @@ "node": ">=0.8.0" } }, - "packages/api-grade-mcp/node_modules/@azure/msal-node": { + "packages/api-grade-core/node_modules/@azure/msal-node": { "version": "2.16.3", "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz", "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", @@ -23433,7 +23411,7 @@ "node": ">=16" } }, - "packages/api-grade-mcp/node_modules/uuid": { + "packages/api-grade-core/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", @@ -23443,9 +23421,31 @@ "uuid": "dist/bin/uuid" } }, + "packages/api-grade-mcp": { + "name": "@dawmatt/api-grade-mcp", + "version": "0.2.1", + "license": "MIT", + "dependencies": { + "@dawmatt/api-grade-core": "*", + "@modelcontextprotocol/sdk": "^1.0.0", + "zod": "^3.22.0" + }, + "bin": { + "api-grade-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "@vitest/coverage-v8": "^1.6.0", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "packages/backstage-plugin-api-grade": { "name": "@dawmatt/backstage-plugin-api-grade", - "version": "0.1.20", + "version": "0.2.1", "license": "MIT", "dependencies": { "@dawmatt/api-grade-core": "*" @@ -23480,7 +23480,7 @@ }, "packages/backstage-plugin-api-grade-backend": { "name": "@dawmatt/backstage-plugin-api-grade-backend", - "version": "0.1.20", + "version": "0.2.1", "license": "MIT", "dependencies": { "@dawmatt/api-grade-core": "*", diff --git a/packages/api-grade-core/package.json b/packages/api-grade-core/package.json index 0e3693a..b4a168e 100644 --- a/packages/api-grade-core/package.json +++ b/packages/api-grade-core/package.json @@ -38,6 +38,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@azure/msal-node": "^2.16.2", "@stoplight/spectral-core": "^1.23.0", "@stoplight/spectral-formats": "^1.8.3", "@stoplight/spectral-parsers": "^1.0.5", diff --git a/packages/api-grade-core/src/auth/entra.ts b/packages/api-grade-core/src/auth/entra.ts new file mode 100644 index 0000000..581b856 --- /dev/null +++ b/packages/api-grade-core/src/auth/entra.ts @@ -0,0 +1,62 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +const ENTRA_CACHE_PATH = join(homedir(), '.api-grade', 'entra-token-cache.json'); + +export class EntraAuthRequired extends Error { + constructor( + public readonly userCode: string, + public readonly verificationUri: string, + public readonly expiresIn: number + ) { + super(`Entra ID authentication required. Visit ${verificationUri} and enter code ${userCode}.`); + this.name = 'EntraAuthRequired'; + } +} + +export async function acquireEntraToken(tenantId: string, clientId: string): Promise { + const { PublicClientApplication } = await import('@azure/msal-node'); + + const cachePlugin = { + async beforeCacheAccess(ctx: { tokenCache: { deserialize: (d: string) => void } }) { + const data = await readFile(ENTRA_CACHE_PATH, 'utf-8').catch(() => ''); + if (data) ctx.tokenCache.deserialize(data); + }, + async afterCacheAccess(ctx: { cacheHasChanged: boolean; tokenCache: { serialize: () => string } }) { + if (ctx.cacheHasChanged) { + await mkdir(dirname(ENTRA_CACHE_PATH), { recursive: true }); + await writeFile(ENTRA_CACHE_PATH, ctx.tokenCache.serialize(), 'utf-8'); + } + }, + }; + + const pca = new PublicClientApplication({ + auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}` }, + cache: { cachePlugin }, + }); + + const scopes = [`api://${clientId}/.default`]; + + const accounts = await pca.getTokenCache().getAllAccounts(); + if (accounts.length > 0) { + try { + const result = await pca.acquireTokenSilent({ scopes, account: accounts[0] }); + if (result?.accessToken) return result.accessToken; + } catch { + // fall through to device-code flow + } + } + + const result = await pca.acquireTokenByDeviceCode({ + scopes, + deviceCodeCallback: (response) => { + throw new EntraAuthRequired(response.userCode, response.verificationUri, response.expiresIn); + }, + }); + + if (!result?.accessToken) { + throw new Error('Entra ID token acquisition failed'); + } + return result.accessToken; +} diff --git a/packages/api-grade-core/src/auth/github.ts b/packages/api-grade-core/src/auth/github.ts new file mode 100644 index 0000000..1a48057 --- /dev/null +++ b/packages/api-grade-core/src/auth/github.ts @@ -0,0 +1,51 @@ +export const INITIAL_FETCH_TIMEOUT_MS = 5_000; +export const RETRY_FETCH_TIMEOUT_MS = 30_000; + +export class RulesetAuthError extends Error { + constructor( + public readonly reason: 'auth-failed' | 'not-found' | 'network-unreachable', + public readonly url: string + ) { + super(`Failed to fetch ruleset from ${url}: ${reason}`); + this.name = 'RulesetAuthError'; + } +} + +export async function fetchRulesetContent( + url: string, + token: string | undefined, + timeoutMs: number +): Promise { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + const headers: Record = token ? { Authorization: `Bearer ${token}` } : {}; + try { + const res = await fetch(url, { headers, signal: controller.signal }); + if (res.status === 401 || res.status === 403) { + throw new RulesetAuthError('auth-failed', url); + } + if (res.status === 404) { + throw new RulesetAuthError('not-found', url); + } + if (!res.ok) { + throw new RulesetAuthError('network-unreachable', url); + } + return res.text(); + } catch (e) { + if (e instanceof RulesetAuthError) throw e; + if (e instanceof Error && e.name === 'AbortError') { + throw new RulesetAuthError('network-unreachable', url); + } + throw new RulesetAuthError('network-unreachable', url); + } finally { + clearTimeout(id); + } +} + +export async function fetchRulesetWithGithubPat( + url: string, + token: string, + timeoutMs: number +): Promise { + return fetchRulesetContent(url, token, timeoutMs); +} diff --git a/packages/api-grade-core/src/config/resolve-ruleset.ts b/packages/api-grade-core/src/config/resolve-ruleset.ts new file mode 100644 index 0000000..c31fcc7 --- /dev/null +++ b/packages/api-grade-core/src/config/resolve-ruleset.ts @@ -0,0 +1,42 @@ +import type { RulesetConfig, RulesetResolution, SessionState } from '../types.js'; + +export function resolveRuleset( + perRequestPath: string | undefined | null, + sessionState: SessionState, + workspaceConfig: RulesetConfig | null, + globalConfig: RulesetConfig | null +): RulesetResolution { + if (perRequestPath != null && perRequestPath !== '') { + return { rulesetPath: perRequestPath, scope: 'per-request', auth: null }; + } + + if (sessionState.sessionRulesetOverride === 'builtin') { + return { rulesetPath: null, scope: 'built-in', auth: null }; + } + + if (sessionState.defaultRuleset?.rulesetPath != null) { + return { + rulesetPath: sessionState.defaultRuleset.rulesetPath, + scope: 'session', + auth: sessionState.defaultRuleset.auth, + }; + } + + if (workspaceConfig?.rulesetPath != null) { + return { + rulesetPath: workspaceConfig.rulesetPath, + scope: 'workspace', + auth: workspaceConfig.auth, + }; + } + + if (globalConfig?.rulesetPath != null) { + return { + rulesetPath: globalConfig.rulesetPath, + scope: 'global', + auth: globalConfig.auth, + }; + } + + return { rulesetPath: null, scope: 'built-in', auth: null }; +} diff --git a/packages/api-grade-core/src/config/ruleset-config.ts b/packages/api-grade-core/src/config/ruleset-config.ts new file mode 100644 index 0000000..abd8ec8 --- /dev/null +++ b/packages/api-grade-core/src/config/ruleset-config.ts @@ -0,0 +1,57 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import type { RulesetConfig } from '../types.js'; + +export class ConfigWriteError extends Error { + readonly code = 'CONFIG_WRITE_ERROR'; + constructor(message: string, readonly cause?: unknown) { + super(message); + this.name = 'ConfigWriteError'; + } +} + +export function getWorkspaceConfigPath(): string { + return join(process.cwd(), '.api-grade', 'config.json'); +} + +export function getGlobalConfigPath(): string { + return join(homedir(), '.api-grade', 'config.json'); +} + +async function loadConfig(filePath: string): Promise { + try { + const data = await readFile(filePath, 'utf-8'); + return JSON.parse(data) as RulesetConfig; + } catch { + return null; + } +} + +async function saveConfig(filePath: string, config: RulesetConfig): Promise { + try { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8'); + } catch (err) { + throw new ConfigWriteError( + `Could not write config to ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + err + ); + } +} + +export async function loadWorkspaceConfig(): Promise { + return loadConfig(getWorkspaceConfigPath()); +} + +export async function loadGlobalConfig(): Promise { + return loadConfig(getGlobalConfigPath()); +} + +export async function saveWorkspaceConfig(config: RulesetConfig): Promise { + return saveConfig(getWorkspaceConfigPath(), config); +} + +export async function saveGlobalConfig(config: RulesetConfig): Promise { + return saveConfig(getGlobalConfigPath(), config); +} diff --git a/packages/api-grade-core/src/index.ts b/packages/api-grade-core/src/index.ts index d7799d7..fd3d233 100644 --- a/packages/api-grade-core/src/index.ts +++ b/packages/api-grade-core/src/index.ts @@ -18,3 +18,33 @@ export type { LetterGrade, RuleMetadata, } from './types.js'; + +export type { + AuthConfig, + RulesetConfig, + RulesetScope, + RulesetResolution, + SessionState, +} from './types.js'; + +export { + fetchRulesetContent, + fetchRulesetWithGithubPat, + RulesetAuthError, + INITIAL_FETCH_TIMEOUT_MS, + RETRY_FETCH_TIMEOUT_MS, +} from './auth/github.js'; + +export { acquireEntraToken, EntraAuthRequired } from './auth/entra.js'; + +export { + getWorkspaceConfigPath, + getGlobalConfigPath, + loadWorkspaceConfig, + loadGlobalConfig, + saveWorkspaceConfig, + saveGlobalConfig, + ConfigWriteError, +} from './config/ruleset-config.js'; + +export { resolveRuleset } from './config/resolve-ruleset.js'; diff --git a/packages/api-grade-core/src/types.ts b/packages/api-grade-core/src/types.ts index e8b5310..95d051c 100644 --- a/packages/api-grade-core/src/types.ts +++ b/packages/api-grade-core/src/types.ts @@ -86,3 +86,28 @@ export function extractCategory(ruleId: string): string { const match = ruleId.match(/^([^_-]+)/); return match ? match[1] : ruleId; } + +export interface AuthConfig { + type: 'github-pat' | 'entra-id'; + githubToken?: string; + tenantId?: string; + clientId?: string; +} + +export interface RulesetConfig { + rulesetPath: string | null; + auth: AuthConfig | null; +} + +export type RulesetScope = 'per-request' | 'session' | 'workspace' | 'global' | 'built-in'; + +export interface RulesetResolution { + rulesetPath: string | null; + scope: RulesetScope; + auth: AuthConfig | null; +} + +export interface SessionState { + defaultRuleset: RulesetConfig | null; + sessionRulesetOverride: 'builtin' | null; +} diff --git a/packages/api-grade-core/tests/unit/auth-github.test.ts b/packages/api-grade-core/tests/unit/auth-github.test.ts new file mode 100644 index 0000000..e3d2a33 --- /dev/null +++ b/packages/api-grade-core/tests/unit/auth-github.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + fetchRulesetContent, + fetchRulesetWithGithubPat, + RulesetAuthError, + INITIAL_FETCH_TIMEOUT_MS, + RETRY_FETCH_TIMEOUT_MS, +} from '../../src/auth/github.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('INITIAL_FETCH_TIMEOUT_MS / RETRY_FETCH_TIMEOUT_MS', () => { + it('exports correct timeout constants', () => { + expect(INITIAL_FETCH_TIMEOUT_MS).toBe(5_000); + expect(RETRY_FETCH_TIMEOUT_MS).toBe(30_000); + }); +}); + +describe('RulesetAuthError', () => { + it('stores reason and url', () => { + const err = new RulesetAuthError('auth-failed', 'https://example.com/r.yaml'); + expect(err.reason).toBe('auth-failed'); + expect(err.url).toBe('https://example.com/r.yaml'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('RulesetAuthError'); + }); +}); + +describe('fetchRulesetContent', () => { + it('returns text content on 200 OK', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve('ruleset: content'), + })); + const result = await fetchRulesetContent('https://example.com/r.yaml', undefined, 5000); + expect(result).toBe('ruleset: content'); + }); + + it('sends Authorization header when token provided', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve('content'), + }); + vi.stubGlobal('fetch', mockFetch); + await fetchRulesetContent('https://example.com/r.yaml', 'my-token', 5000); + const [, options] = mockFetch.mock.calls[0]; + expect(options.headers['Authorization']).toBe('Bearer my-token'); + }); + + it('throws RulesetAuthError("auth-failed") on 401', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401 })); + await expect(fetchRulesetContent('https://example.com/r.yaml', 'bad', 5000)) + .rejects.toMatchObject({ reason: 'auth-failed' }); + }); + + it('throws RulesetAuthError("auth-failed") on 403', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 403 })); + await expect(fetchRulesetContent('https://example.com/r.yaml', 'bad', 5000)) + .rejects.toMatchObject({ reason: 'auth-failed' }); + }); + + it('throws RulesetAuthError("network-unreachable") on non-401/403/404 failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 500 })); + await expect(fetchRulesetContent('https://example.com/r.yaml', undefined, 5000)) + .rejects.toMatchObject({ reason: 'network-unreachable' }); + }); + + it('throws RulesetAuthError("not-found") on 404', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 404 })); + await expect(fetchRulesetContent('https://example.com/r.yaml', undefined, 5000)) + .rejects.toMatchObject({ reason: 'not-found' }); + }); + + it('throws RulesetAuthError("network-unreachable") on AbortError (timeout)', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(abortError)); + await expect(fetchRulesetContent('https://example.com/r.yaml', undefined, 5000)) + .rejects.toMatchObject({ reason: 'network-unreachable' }); + }); + + it('throws RulesetAuthError("network-unreachable") on other network errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new Error('ECONNREFUSED'))); + await expect(fetchRulesetContent('https://example.com/r.yaml', undefined, 5000)) + .rejects.toBeInstanceOf(RulesetAuthError); + }); +}); + +describe('fetchRulesetWithGithubPat', () => { + it('delegates to fetchRulesetContent with token', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve('pat-content'), + })); + const result = await fetchRulesetWithGithubPat('https://example.com/r.yaml', 'pat-token', 5000); + expect(result).toBe('pat-content'); + }); +}); diff --git a/packages/api-grade-core/tests/unit/resolve-ruleset.test.ts b/packages/api-grade-core/tests/unit/resolve-ruleset.test.ts new file mode 100644 index 0000000..fa3f1e9 --- /dev/null +++ b/packages/api-grade-core/tests/unit/resolve-ruleset.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { resolveRuleset } from '../../src/config/resolve-ruleset.js'; +import type { SessionState, RulesetConfig } from '../../src/types.js'; + +const EMPTY_SESSION: SessionState = { defaultRuleset: null, sessionRulesetOverride: null }; + +const WORKSPACE_CONFIG: RulesetConfig = { + rulesetPath: 'https://workspace.example.com/ruleset.yaml', + auth: { type: 'github-pat' }, +}; + +const GLOBAL_CONFIG: RulesetConfig = { + rulesetPath: 'https://global.example.com/ruleset.yaml', + auth: null, +}; + +const SESSION_RULESET: RulesetConfig = { + rulesetPath: 'https://session.example.com/ruleset.yaml', + auth: null, +}; + +describe('resolveRuleset() - precedence chain', () => { + it('per-request path wins over all other scopes', () => { + const session: SessionState = { defaultRuleset: SESSION_RULESET, sessionRulesetOverride: null }; + const result = resolveRuleset('/per-request/ruleset.yaml', session, WORKSPACE_CONFIG, GLOBAL_CONFIG); + expect(result.scope).toBe('per-request'); + expect(result.rulesetPath).toBe('/per-request/ruleset.yaml'); + expect(result.auth).toBeNull(); + }); + + it('sessionRulesetOverride: "builtin" short-circuits to built-in immediately', () => { + const session: SessionState = { defaultRuleset: SESSION_RULESET, sessionRulesetOverride: 'builtin' }; + const result = resolveRuleset(null, session, WORKSPACE_CONFIG, GLOBAL_CONFIG); + expect(result.scope).toBe('built-in'); + expect(result.rulesetPath).toBeNull(); + }); + + it('session default wins over workspace and global', () => { + const session: SessionState = { defaultRuleset: SESSION_RULESET, sessionRulesetOverride: null }; + const result = resolveRuleset(null, session, WORKSPACE_CONFIG, GLOBAL_CONFIG); + expect(result.scope).toBe('session'); + expect(result.rulesetPath).toBe(SESSION_RULESET.rulesetPath); + }); + + it('workspace wins over global when no session default', () => { + const result = resolveRuleset(null, EMPTY_SESSION, WORKSPACE_CONFIG, GLOBAL_CONFIG); + expect(result.scope).toBe('workspace'); + expect(result.rulesetPath).toBe(WORKSPACE_CONFIG.rulesetPath); + expect(result.auth).toEqual(WORKSPACE_CONFIG.auth); + }); + + it('global wins over built-in when no session or workspace default', () => { + const result = resolveRuleset(null, EMPTY_SESSION, null, GLOBAL_CONFIG); + expect(result.scope).toBe('global'); + expect(result.rulesetPath).toBe(GLOBAL_CONFIG.rulesetPath); + }); + + it('all null → built-in', () => { + const result = resolveRuleset(null, EMPTY_SESSION, null, null); + expect(result.scope).toBe('built-in'); + expect(result.rulesetPath).toBeNull(); + expect(result.auth).toBeNull(); + }); + + it('undefined per-request path does not win over session default', () => { + const session: SessionState = { defaultRuleset: SESSION_RULESET, sessionRulesetOverride: null }; + const result = resolveRuleset(undefined, session, null, null); + expect(result.scope).toBe('session'); + }); +}); diff --git a/packages/api-grade-core/tests/unit/ruleset-config.test.ts b/packages/api-grade-core/tests/unit/ruleset-config.test.ts new file mode 100644 index 0000000..31fb1ff --- /dev/null +++ b/packages/api-grade-core/tests/unit/ruleset-config.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { readFile, rm } from 'node:fs/promises'; +import { + loadWorkspaceConfig, + loadGlobalConfig, + saveWorkspaceConfig, + saveGlobalConfig, + getWorkspaceConfigPath, + getGlobalConfigPath, + ConfigWriteError, +} from '../../src/config/ruleset-config.js'; +import type { RulesetConfig } from '../../src/types.js'; + +const workspacePath = getWorkspaceConfigPath(); +const globalPath = getGlobalConfigPath(); + +const TEST_CONFIG: RulesetConfig = { + rulesetPath: 'https://example.com/ruleset.yaml', + auth: { type: 'github-pat' }, +}; + +afterEach(async () => { + try { await rm(workspacePath); } catch { /* not created */ } +}); + +describe('loadWorkspaceConfig()', () => { + it('returns null when no config file exists', async () => { + try { await rm(workspacePath); } catch { /* ok */ } + const result = await loadWorkspaceConfig(); + expect(result).toBeNull(); + }); + + it('returns the stored config after a write', async () => { + await saveWorkspaceConfig(TEST_CONFIG); + const result = await loadWorkspaceConfig(); + expect(result).toEqual(TEST_CONFIG); + }); +}); + +describe('loadGlobalConfig()', () => { + let savedContent: string | null = null; + + afterEach(async () => { + if (savedContent !== null) { + const { writeFile } = await import('node:fs/promises'); + await writeFile(globalPath, savedContent, 'utf-8'); + savedContent = null; + } else { + try { await rm(globalPath); } catch { /* not created */ } + } + }); + + it('returns null when no global config file exists', async () => { + try { + savedContent = await readFile(globalPath, 'utf-8'); + await rm(globalPath); + } catch { /* file doesn't exist, savedContent stays null */ } + const result = await loadGlobalConfig(); + expect(result).toBeNull(); + }); + + it('returns the stored config after a write', async () => { + try { savedContent = await readFile(globalPath, 'utf-8'); } catch { /* ok */ } + await saveGlobalConfig(TEST_CONFIG); + const result = await loadGlobalConfig(); + expect(result).toEqual(TEST_CONFIG); + }); +}); + +describe('saveWorkspaceConfig()', () => { + it('creates parent directories and writes config', async () => { + await saveWorkspaceConfig(TEST_CONFIG); + const raw = await readFile(workspacePath, 'utf-8'); + expect(JSON.parse(raw)).toEqual(TEST_CONFIG); + }); +}); + +describe('ConfigWriteError', () => { + it('is thrown when the write path is invalid', async () => { + vi.spyOn(process, 'cwd').mockReturnValue('/dev/null'); + try { + await expect(saveWorkspaceConfig({ rulesetPath: null, auth: null })).rejects.toBeInstanceOf(ConfigWriteError); + } finally { + vi.restoreAllMocks(); + } + }); +}); diff --git a/packages/api-grade-mcp/package.json b/packages/api-grade-mcp/package.json index 52ed53c..8fe2c19 100644 --- a/packages/api-grade-mcp/package.json +++ b/packages/api-grade-mcp/package.json @@ -42,7 +42,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@azure/msal-node": "^2.16.2", "@dawmatt/api-grade-core": "*", "@modelcontextprotocol/sdk": "^1.0.0", "zod": "^3.22.0" diff --git a/packages/api-grade-mcp/src/auth/entra.ts b/packages/api-grade-mcp/src/auth/entra.ts index 581b856..8a49fde 100644 --- a/packages/api-grade-mcp/src/auth/entra.ts +++ b/packages/api-grade-mcp/src/auth/entra.ts @@ -1,62 +1 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; - -const ENTRA_CACHE_PATH = join(homedir(), '.api-grade', 'entra-token-cache.json'); - -export class EntraAuthRequired extends Error { - constructor( - public readonly userCode: string, - public readonly verificationUri: string, - public readonly expiresIn: number - ) { - super(`Entra ID authentication required. Visit ${verificationUri} and enter code ${userCode}.`); - this.name = 'EntraAuthRequired'; - } -} - -export async function acquireEntraToken(tenantId: string, clientId: string): Promise { - const { PublicClientApplication } = await import('@azure/msal-node'); - - const cachePlugin = { - async beforeCacheAccess(ctx: { tokenCache: { deserialize: (d: string) => void } }) { - const data = await readFile(ENTRA_CACHE_PATH, 'utf-8').catch(() => ''); - if (data) ctx.tokenCache.deserialize(data); - }, - async afterCacheAccess(ctx: { cacheHasChanged: boolean; tokenCache: { serialize: () => string } }) { - if (ctx.cacheHasChanged) { - await mkdir(dirname(ENTRA_CACHE_PATH), { recursive: true }); - await writeFile(ENTRA_CACHE_PATH, ctx.tokenCache.serialize(), 'utf-8'); - } - }, - }; - - const pca = new PublicClientApplication({ - auth: { clientId, authority: `https://login.microsoftonline.com/${tenantId}` }, - cache: { cachePlugin }, - }); - - const scopes = [`api://${clientId}/.default`]; - - const accounts = await pca.getTokenCache().getAllAccounts(); - if (accounts.length > 0) { - try { - const result = await pca.acquireTokenSilent({ scopes, account: accounts[0] }); - if (result?.accessToken) return result.accessToken; - } catch { - // fall through to device-code flow - } - } - - const result = await pca.acquireTokenByDeviceCode({ - scopes, - deviceCodeCallback: (response) => { - throw new EntraAuthRequired(response.userCode, response.verificationUri, response.expiresIn); - }, - }); - - if (!result?.accessToken) { - throw new Error('Entra ID token acquisition failed'); - } - return result.accessToken; -} +export { acquireEntraToken, EntraAuthRequired } from '@dawmatt/api-grade-core'; diff --git a/packages/api-grade-mcp/src/auth/github.ts b/packages/api-grade-mcp/src/auth/github.ts index 1a48057..9b06e6c 100644 --- a/packages/api-grade-mcp/src/auth/github.ts +++ b/packages/api-grade-mcp/src/auth/github.ts @@ -1,51 +1 @@ -export const INITIAL_FETCH_TIMEOUT_MS = 5_000; -export const RETRY_FETCH_TIMEOUT_MS = 30_000; - -export class RulesetAuthError extends Error { - constructor( - public readonly reason: 'auth-failed' | 'not-found' | 'network-unreachable', - public readonly url: string - ) { - super(`Failed to fetch ruleset from ${url}: ${reason}`); - this.name = 'RulesetAuthError'; - } -} - -export async function fetchRulesetContent( - url: string, - token: string | undefined, - timeoutMs: number -): Promise { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), timeoutMs); - const headers: Record = token ? { Authorization: `Bearer ${token}` } : {}; - try { - const res = await fetch(url, { headers, signal: controller.signal }); - if (res.status === 401 || res.status === 403) { - throw new RulesetAuthError('auth-failed', url); - } - if (res.status === 404) { - throw new RulesetAuthError('not-found', url); - } - if (!res.ok) { - throw new RulesetAuthError('network-unreachable', url); - } - return res.text(); - } catch (e) { - if (e instanceof RulesetAuthError) throw e; - if (e instanceof Error && e.name === 'AbortError') { - throw new RulesetAuthError('network-unreachable', url); - } - throw new RulesetAuthError('network-unreachable', url); - } finally { - clearTimeout(id); - } -} - -export async function fetchRulesetWithGithubPat( - url: string, - token: string, - timeoutMs: number -): Promise { - return fetchRulesetContent(url, token, timeoutMs); -} +export { fetchRulesetContent, fetchRulesetWithGithubPat, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS, RETRY_FETCH_TIMEOUT_MS } from '@dawmatt/api-grade-core'; diff --git a/packages/api-grade-mcp/src/config/resolve-ruleset.ts b/packages/api-grade-mcp/src/config/resolve-ruleset.ts index c31fcc7..ed29492 100644 --- a/packages/api-grade-mcp/src/config/resolve-ruleset.ts +++ b/packages/api-grade-mcp/src/config/resolve-ruleset.ts @@ -1,42 +1 @@ -import type { RulesetConfig, RulesetResolution, SessionState } from '../types.js'; - -export function resolveRuleset( - perRequestPath: string | undefined | null, - sessionState: SessionState, - workspaceConfig: RulesetConfig | null, - globalConfig: RulesetConfig | null -): RulesetResolution { - if (perRequestPath != null && perRequestPath !== '') { - return { rulesetPath: perRequestPath, scope: 'per-request', auth: null }; - } - - if (sessionState.sessionRulesetOverride === 'builtin') { - return { rulesetPath: null, scope: 'built-in', auth: null }; - } - - if (sessionState.defaultRuleset?.rulesetPath != null) { - return { - rulesetPath: sessionState.defaultRuleset.rulesetPath, - scope: 'session', - auth: sessionState.defaultRuleset.auth, - }; - } - - if (workspaceConfig?.rulesetPath != null) { - return { - rulesetPath: workspaceConfig.rulesetPath, - scope: 'workspace', - auth: workspaceConfig.auth, - }; - } - - if (globalConfig?.rulesetPath != null) { - return { - rulesetPath: globalConfig.rulesetPath, - scope: 'global', - auth: globalConfig.auth, - }; - } - - return { rulesetPath: null, scope: 'built-in', auth: null }; -} +export { resolveRuleset } from '@dawmatt/api-grade-core'; diff --git a/packages/api-grade-mcp/src/config/ruleset-config.ts b/packages/api-grade-mcp/src/config/ruleset-config.ts index abd8ec8..bd04d5d 100644 --- a/packages/api-grade-mcp/src/config/ruleset-config.ts +++ b/packages/api-grade-mcp/src/config/ruleset-config.ts @@ -1,57 +1,9 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; -import type { RulesetConfig } from '../types.js'; - -export class ConfigWriteError extends Error { - readonly code = 'CONFIG_WRITE_ERROR'; - constructor(message: string, readonly cause?: unknown) { - super(message); - this.name = 'ConfigWriteError'; - } -} - -export function getWorkspaceConfigPath(): string { - return join(process.cwd(), '.api-grade', 'config.json'); -} - -export function getGlobalConfigPath(): string { - return join(homedir(), '.api-grade', 'config.json'); -} - -async function loadConfig(filePath: string): Promise { - try { - const data = await readFile(filePath, 'utf-8'); - return JSON.parse(data) as RulesetConfig; - } catch { - return null; - } -} - -async function saveConfig(filePath: string, config: RulesetConfig): Promise { - try { - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8'); - } catch (err) { - throw new ConfigWriteError( - `Could not write config to ${filePath}: ${err instanceof Error ? err.message : String(err)}`, - err - ); - } -} - -export async function loadWorkspaceConfig(): Promise { - return loadConfig(getWorkspaceConfigPath()); -} - -export async function loadGlobalConfig(): Promise { - return loadConfig(getGlobalConfigPath()); -} - -export async function saveWorkspaceConfig(config: RulesetConfig): Promise { - return saveConfig(getWorkspaceConfigPath(), config); -} - -export async function saveGlobalConfig(config: RulesetConfig): Promise { - return saveConfig(getGlobalConfigPath(), config); -} +export { + getWorkspaceConfigPath, + getGlobalConfigPath, + loadWorkspaceConfig, + loadGlobalConfig, + saveWorkspaceConfig, + saveGlobalConfig, + ConfigWriteError, +} from '@dawmatt/api-grade-core'; diff --git a/packages/api-grade-mcp/src/tools/assert-grade.ts b/packages/api-grade-mcp/src/tools/assert-grade.ts index ff6629d..17dacf0 100644 --- a/packages/api-grade-mcp/src/tools/assert-grade.ts +++ b/packages/api-grade-mcp/src/tools/assert-grade.ts @@ -2,14 +2,23 @@ import { statSync, writeFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { z } from 'zod'; -import { GradeEngine, gradeToNumber, LETTER_GRADE_ORDER } from '@dawmatt/api-grade-core'; +import { + GradeEngine, + gradeToNumber, + LETTER_GRADE_ORDER, + loadWorkspaceConfig, + loadGlobalConfig, + resolveRuleset, + fetchRulesetContent, + RulesetAuthError, + INITIAL_FETCH_TIMEOUT_MS, + RETRY_FETCH_TIMEOUT_MS, + EntraAuthRequired, + acquireEntraToken, +} from '@dawmatt/api-grade-core'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { mcpError, buildRulesetFetchFailureResponse, describeFetchFailureReason, ERROR_CODES } from '../utils/errors.js'; -import { loadWorkspaceConfig, loadGlobalConfig } from '../config/ruleset-config.js'; -import { resolveRuleset } from '../config/resolve-ruleset.js'; -import { fetchRulesetContent, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS, RETRY_FETCH_TIMEOUT_MS } from '../auth/github.js'; -import { EntraAuthRequired, acquireEntraToken } from '../auth/entra.js'; -import type { SessionState } from '../types.js'; +import type { SessionState } from '@dawmatt/api-grade-core'; export function registerAssertGradeTool(server: McpServer, sessionState: SessionState): void { server.tool( diff --git a/packages/api-grade-mcp/src/tools/get-ruleset-config.ts b/packages/api-grade-mcp/src/tools/get-ruleset-config.ts index 4fa3fb3..f6187d2 100644 --- a/packages/api-grade-mcp/src/tools/get-ruleset-config.ts +++ b/packages/api-grade-mcp/src/tools/get-ruleset-config.ts @@ -1,7 +1,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { loadWorkspaceConfig, loadGlobalConfig, getWorkspaceConfigPath, getGlobalConfigPath } from '../config/ruleset-config.js'; -import { resolveRuleset } from '../config/resolve-ruleset.js'; -import type { SessionState, AuthConfig } from '../types.js'; +import { + loadWorkspaceConfig, + loadGlobalConfig, + getWorkspaceConfigPath, + getGlobalConfigPath, + resolveRuleset, +} from '@dawmatt/api-grade-core'; +import type { SessionState, AuthConfig } from '@dawmatt/api-grade-core'; function sanitizeAuth(auth: AuthConfig | null | undefined, hasToken: boolean): Record | null { if (!auth) return null; diff --git a/packages/api-grade-mcp/src/tools/grade-detailed.ts b/packages/api-grade-mcp/src/tools/grade-detailed.ts index accc045..c80de69 100644 --- a/packages/api-grade-mcp/src/tools/grade-detailed.ts +++ b/packages/api-grade-mcp/src/tools/grade-detailed.ts @@ -2,14 +2,21 @@ import { statSync, writeFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { z } from 'zod'; -import { GradeEngine } from '@dawmatt/api-grade-core'; +import { + GradeEngine, + loadWorkspaceConfig, + loadGlobalConfig, + resolveRuleset, + fetchRulesetContent, + RulesetAuthError, + INITIAL_FETCH_TIMEOUT_MS, + RETRY_FETCH_TIMEOUT_MS, + EntraAuthRequired, + acquireEntraToken, +} from '@dawmatt/api-grade-core'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { mcpError, buildRulesetFetchFailureResponse, describeFetchFailureReason, ERROR_CODES } from '../utils/errors.js'; -import { loadWorkspaceConfig, loadGlobalConfig } from '../config/ruleset-config.js'; -import { resolveRuleset } from '../config/resolve-ruleset.js'; -import { fetchRulesetContent, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS, RETRY_FETCH_TIMEOUT_MS } from '../auth/github.js'; -import { EntraAuthRequired, acquireEntraToken } from '../auth/entra.js'; -import type { SessionState } from '../types.js'; +import type { SessionState } from '@dawmatt/api-grade-core'; const LARGE_SPEC_THRESHOLD_BYTES = 500_000; const MAX_DIAGNOSTICS = 100; diff --git a/packages/api-grade-mcp/src/tools/grade.ts b/packages/api-grade-mcp/src/tools/grade.ts index 1fad54e..172cadb 100644 --- a/packages/api-grade-mcp/src/tools/grade.ts +++ b/packages/api-grade-mcp/src/tools/grade.ts @@ -2,14 +2,21 @@ import { statSync, writeFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { z } from 'zod'; -import { GradeEngine } from '@dawmatt/api-grade-core'; +import { + GradeEngine, + loadWorkspaceConfig, + loadGlobalConfig, + resolveRuleset, + fetchRulesetContent, + RulesetAuthError, + INITIAL_FETCH_TIMEOUT_MS, + RETRY_FETCH_TIMEOUT_MS, + EntraAuthRequired, + acquireEntraToken, +} from '@dawmatt/api-grade-core'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { mcpError, buildRulesetFetchFailureResponse, describeFetchFailureReason, ERROR_CODES } from '../utils/errors.js'; -import { loadWorkspaceConfig, loadGlobalConfig } from '../config/ruleset-config.js'; -import { resolveRuleset } from '../config/resolve-ruleset.js'; -import { fetchRulesetContent, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS, RETRY_FETCH_TIMEOUT_MS } from '../auth/github.js'; -import { EntraAuthRequired, acquireEntraToken } from '../auth/entra.js'; -import type { SessionState } from '../types.js'; +import type { SessionState } from '@dawmatt/api-grade-core'; const LARGE_SPEC_THRESHOLD_BYTES = 500_000; diff --git a/packages/api-grade-mcp/src/tools/quick-fixes-only.ts b/packages/api-grade-mcp/src/tools/quick-fixes-only.ts index c54d62f..3f71a12 100644 --- a/packages/api-grade-mcp/src/tools/quick-fixes-only.ts +++ b/packages/api-grade-mcp/src/tools/quick-fixes-only.ts @@ -2,15 +2,22 @@ import { statSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { z } from 'zod'; -import { GradeEngine } from '@dawmatt/api-grade-core'; +import { + GradeEngine, + loadWorkspaceConfig, + loadGlobalConfig, + resolveRuleset, + fetchRulesetContent, + RulesetAuthError, + INITIAL_FETCH_TIMEOUT_MS, + RETRY_FETCH_TIMEOUT_MS, + EntraAuthRequired, + acquireEntraToken, +} from '@dawmatt/api-grade-core'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { mcpError, buildRulesetFetchFailureResponse, describeFetchFailureReason, ERROR_CODES } from '../utils/errors.js'; -import { loadWorkspaceConfig, loadGlobalConfig } from '../config/ruleset-config.js'; -import { resolveRuleset } from '../config/resolve-ruleset.js'; -import { fetchRulesetContent, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS, RETRY_FETCH_TIMEOUT_MS } from '../auth/github.js'; -import { EntraAuthRequired, acquireEntraToken } from '../auth/entra.js'; import { classifyViolation, buildQuickFix } from '../utils/classify.js'; -import type { SessionState } from '../types.js'; +import type { SessionState } from '@dawmatt/api-grade-core'; const LARGE_SPEC_THRESHOLD_BYTES = 500_000; diff --git a/packages/api-grade-mcp/src/tools/set-ruleset-config.ts b/packages/api-grade-mcp/src/tools/set-ruleset-config.ts index fe5f3b0..f305df9 100644 --- a/packages/api-grade-mcp/src/tools/set-ruleset-config.ts +++ b/packages/api-grade-mcp/src/tools/set-ruleset-config.ts @@ -7,8 +7,8 @@ import { getWorkspaceConfigPath, getGlobalConfigPath, ConfigWriteError, -} from '../config/ruleset-config.js'; -import type { SessionState, RulesetConfig, AuthConfig } from '../types.js'; +} from '@dawmatt/api-grade-core'; +import type { SessionState, RulesetConfig, AuthConfig } from '@dawmatt/api-grade-core'; export function registerSetRulesetConfigTool(server: McpServer, sessionState: SessionState): void { server.tool( diff --git a/packages/api-grade-mcp/src/types.ts b/packages/api-grade-mcp/src/types.ts index c69cac4..f075bac 100644 --- a/packages/api-grade-mcp/src/types.ts +++ b/packages/api-grade-mcp/src/types.ts @@ -1,27 +1,10 @@ -export interface AuthConfig { - type: 'github-pat' | 'entra-id'; - githubToken?: string; - tenantId?: string; - clientId?: string; -} - -export interface RulesetConfig { - rulesetPath: string | null; - auth: AuthConfig | null; -} - -export type RulesetScope = 'per-request' | 'session' | 'workspace' | 'global' | 'built-in'; - -export interface RulesetResolution { - rulesetPath: string | null; - scope: RulesetScope; - auth: AuthConfig | null; -} - -export interface SessionState { - defaultRuleset: RulesetConfig | null; - sessionRulesetOverride: 'builtin' | null; -} +export type { + AuthConfig, + RulesetConfig, + RulesetScope, + RulesetResolution, + SessionState, +} from '@dawmatt/api-grade-core'; export type RecoveryOptionId = 'retry' | 'use-builtin-once' | 'use-builtin-session' | 'cancel'; diff --git a/src/cli/index.ts b/src/cli/index.ts index fe4b0ec..66dcdf1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,8 +1,20 @@ #!/usr/bin/env node +import { unlinkSync } from 'node:fs'; import { Command } from 'commander'; import chalk from 'chalk'; -import { GradeEngine, formatHuman, formatJson, LETTER_GRADE_ORDER, gradeToNumber } from '@dawmatt/api-grade-core'; +import { + GradeEngine, + formatHuman, + formatJson, + LETTER_GRADE_ORDER, + gradeToNumber, + loadWorkspaceConfig, + loadGlobalConfig, +} from '@dawmatt/api-grade-core'; import { loadConfig } from './config-loader.js'; +import { resolveCliAuth, checkEntraRejection, isValidAuthType } from './ruleset-resolution.js'; +import { resolveRemoteRuleset } from './ruleset-fetch.js'; +import { registerConfigCommand } from './ruleset-config-cli.js'; import type { LetterGrade } from '@dawmatt/api-grade-core'; // Returns "source:line:col — " when error carries Spectral location data, else "" or "source — " @@ -46,9 +58,12 @@ program .name('api-grade') .description('Grade API specification quality using Spectral linting rules') .version('0.1.0') + .enablePositionalOptions() .argument('', 'Path to OpenAPI or AsyncAPI specification file') .option('--min-grade ', 'Exit with code 1 if grade is below this threshold (A-F)') - .option('--ruleset ', 'Path to a custom Spectral-compatible ruleset file') + .option('--ruleset ', 'Path to a custom Spectral-compatible ruleset file, or a URL to a remote ruleset') + .option('--auth-type ', 'Authorisation type for fetching a remote ruleset: none (default) or github-pat') + .option('--token ', 'GitHub Personal Access Token for authenticating a remote ruleset fetch') .option('--format ', 'Output format: human or json') .option('--top ', 'Show only the top N diagnostics', (v) => { const n = parseInt(v, 10); @@ -63,6 +78,8 @@ program .action(async (specFile: string, cliOpts: { minGrade?: string; ruleset?: string; + authType?: string; + token?: string; format?: string; top?: number; url?: string; @@ -91,7 +108,6 @@ program } const topN = cliOpts.top ?? fileConfig.top; - const rulesetPath = cliOpts.ruleset ?? fileConfig.rulesetPath; const verbose = cliOpts.verbose ?? fileConfig.verbose ?? false; let minGrade: LetterGrade | undefined; @@ -105,6 +121,52 @@ program minGrade = g; } + if (cliOpts.authType !== undefined && !isValidAuthType(cliOpts.authType)) { + const message = `Invalid --auth-type value '${cliOpts.authType}'. Must be one of: none, github-pat.`; + if (outputFormat === 'json') { + console.log(JSON.stringify({ error: 'RULESET_BAD_CONFIG', message })); + } else { + console.error(chalk.red(`Error: ${message}`)); + } + process.exit(1); + } + + const workspaceConfig = await loadWorkspaceConfig(); + const globalConfig = await loadGlobalConfig(); + const authResult = resolveCliAuth({ + rulesetOption: cliOpts.ruleset ?? fileConfig.rulesetPath, + authTypeOption: cliOpts.authType, + tokenOption: cliOpts.token, + workspaceConfig, + globalConfig, + }); + + for (const warning of authResult.warnings) { + console.warn(chalk.yellow(warning)); + } + + const entraCheck = checkEntraRejection(authResult); + if (entraCheck.rejected) { + if (outputFormat === 'json') { + console.log(JSON.stringify({ error: 'UNSUPPORTED_AUTH_TYPE', message: entraCheck.message })); + } else { + console.error(chalk.red(`Error: ${entraCheck.message}`)); + } + process.exit(1); + } + + const fetchOutcome = await resolveRemoteRuleset(authResult); + if (fetchOutcome.failure) { + if (outputFormat === 'json') { + console.log(JSON.stringify(fetchOutcome.failure)); + } else { + console.error(chalk.red(`Error: ${fetchOutcome.failure.message}`)); + } + process.exit(1); + } + const rulesetPath = fetchOutcome.rulesetPath; + const tempRulesetFile = fetchOutcome.tempFile; + try { const engine = new GradeEngine(); const result = await engine.grade({ @@ -148,7 +210,13 @@ program } } process.exit(1); + } finally { + if (tempRulesetFile) { + try { unlinkSync(tempRulesetFile); } catch { /* ignore */ } + } } }); +registerConfigCommand(program); + program.parse(); diff --git a/src/cli/ruleset-config-cli.ts b/src/cli/ruleset-config-cli.ts new file mode 100644 index 0000000..5e2615f --- /dev/null +++ b/src/cli/ruleset-config-cli.ts @@ -0,0 +1,167 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { + saveWorkspaceConfig, + saveGlobalConfig, + loadWorkspaceConfig, + loadGlobalConfig, + getWorkspaceConfigPath, + getGlobalConfigPath, + ConfigWriteError, + type RulesetConfig, + type AuthConfig, +} from '@dawmatt/api-grade-core'; +import { resolveCliAuth, checkEntraRejection, isValidAuthType } from './ruleset-resolution.js'; + +export interface SetRulesetOptions { + scope?: string; + ruleset?: string; + authType?: string; + token?: string; + format?: string; +} + +function fail(message: string, format: string | undefined, errorCode = 'RULESET_BAD_CONFIG'): never { + if (format === 'json') { + console.log(JSON.stringify({ error: errorCode, message })); + } else { + console.error(chalk.red(`Error: ${message}`)); + } + process.exit(1); +} + +export async function runSetRuleset(opts: SetRulesetOptions): Promise { + if (opts.scope !== 'workspace' && opts.scope !== 'global') { + fail("--scope is required and must be 'workspace' or 'global'.", opts.format); + } + + if (opts.authType !== undefined && !isValidAuthType(opts.authType)) { + fail(`Invalid --auth-type value '${opts.authType}'. Must be one of: none, github-pat.`, opts.format); + } + + if (opts.authType === 'entra-id') { + fail( + 'Microsoft Entra ID authentication is not supported by the CLI. Configure a GitHub PAT instead (--token, GITHUB_TOKEN, or `api-grade config set-ruleset --token`).', + opts.format, + 'UNSUPPORTED_AUTH_TYPE' + ); + } + + const resolvedAuthType = opts.authType ?? 'none'; + + if (resolvedAuthType === 'none' && opts.token !== undefined) { + console.warn(chalk.yellow( + "Warning: --token is ignored because the authorisation type is 'none'. Use --auth-type github-pat to authenticate this request." + )); + } + + const auth: AuthConfig | null = + resolvedAuthType === 'github-pat' + ? { type: 'github-pat', ...(opts.token !== undefined ? { githubToken: opts.token } : {}) } + : null; + + const rulesetPath = opts.ruleset ?? null; + const config: RulesetConfig = { rulesetPath, auth }; + + const configFile = opts.scope === 'workspace' ? getWorkspaceConfigPath() : getGlobalConfigPath(); + const saveFn = opts.scope === 'workspace' ? saveWorkspaceConfig : saveGlobalConfig; + + try { + await saveFn(config); + } catch (err) { + const message = + err instanceof ConfigWriteError + ? err.message + : `Could not write ${opts.scope} config: ${err instanceof Error ? err.message : String(err)}`; + fail(message, opts.format, 'CONFIG_WRITE_ERROR'); + } + + if (opts.format === 'json') { + console.log(JSON.stringify({ scope: opts.scope, rulesetPath, configFile })); + } else { + const scopeLabel = opts.scope!.charAt(0).toUpperCase() + opts.scope!.slice(1); + console.log( + rulesetPath !== null + ? `${scopeLabel} default ruleset configured (${configFile}).` + : `${scopeLabel} default ruleset cleared (${configFile}).` + ); + } +} + +function tokenPresence(auth: AuthConfig | null | undefined): string { + if (!auth) return '(no token)'; + if (auth.githubToken) return '(token configured)'; + if (process.env.GITHUB_TOKEN) return '(from GITHUB_TOKEN)'; + return '(no token)'; +} + +export async function runGetRuleset(opts: { format?: string }): Promise { + const workspaceConfig = await loadWorkspaceConfig(); + const globalConfig = await loadGlobalConfig(); + + const authResult = resolveCliAuth({ + workspaceConfig, + globalConfig, + }); + + const entraCheck = checkEntraRejection(authResult); + + if (opts.format === 'json') { + const response = { + effective: { + scope: authResult.resolution.scope, + rulesetPath: authResult.resolution.rulesetPath, + authType: authResult.authType, + }, + workspace: workspaceConfig?.rulesetPath != null + ? { rulesetPath: workspaceConfig.rulesetPath, authType: workspaceConfig.auth?.type ?? 'none' } + : null, + global: globalConfig?.rulesetPath != null + ? { rulesetPath: globalConfig.rulesetPath, authType: globalConfig.auth?.type ?? 'none' } + : null, + builtIn: 'default', + ...(entraCheck.rejected ? { unsupportedByCli: entraCheck.message } : {}), + }; + console.log(JSON.stringify(response)); + return; + } + + console.log(`Effective: scope=${authResult.resolution.scope} rulesetPath=${authResult.resolution.rulesetPath ?? '(built-in)'} authType=${authResult.authType} ${tokenPresence(authResult.resolution.auth)}`); + console.log( + workspaceConfig?.rulesetPath != null + ? `Workspace (${getWorkspaceConfigPath()}): rulesetPath=${workspaceConfig.rulesetPath} authType=${workspaceConfig.auth?.type ?? 'none'} ${tokenPresence(workspaceConfig.auth)}` + : `Workspace (${getWorkspaceConfigPath()}): (not configured)` + ); + console.log( + globalConfig?.rulesetPath != null + ? `Global (${getGlobalConfigPath()}): rulesetPath=${globalConfig.rulesetPath} authType=${globalConfig.auth?.type ?? 'none'} ${tokenPresence(globalConfig.auth)}` + : `Global (${getGlobalConfigPath()}): (not configured)` + ); + if (entraCheck.rejected) { + console.log(chalk.yellow(`Note: ${entraCheck.message}`)); + } +} + +export function registerConfigCommand(program: Command): void { + const config = program.command('config').description('Manage persistent ruleset/auth configuration'); + + config + .command('set-ruleset') + .description('Set or clear the default ruleset (and optional auth) for a given scope') + .requiredOption('--scope ', 'Which persisted config file to write') + .option('--ruleset ', 'Path or URL to set as the default; omit to clear the default at that scope') + .option('--auth-type ', 'Authorisation type to persist alongside the ruleset') + .option('--token ', 'GitHub PAT to persist alongside the ruleset') + .option('--format ', 'Output format: human or json', 'human') + .action(async (opts: SetRulesetOptions) => { + await runSetRuleset(opts); + }); + + config + .command('get-ruleset') + .description('Show the effective ruleset/auth configuration and its resolution chain') + .option('--format ', 'Output format: human or json', 'human') + .action(async (opts: { format?: string }) => { + await runGetRuleset(opts); + }); +} diff --git a/src/cli/ruleset-fetch-errors.ts b/src/cli/ruleset-fetch-errors.ts new file mode 100644 index 0000000..ad22966 --- /dev/null +++ b/src/cli/ruleset-fetch-errors.ts @@ -0,0 +1,49 @@ +export type FetchFailureReason = 'auth-failed' | 'not-found' | 'network-unreachable' | 'config-invalid'; + +const FAILURE_REASON_DESCRIPTIONS: Record = { + 'auth-failed': 'the credentials were rejected (401/403)', + 'not-found': 'the repository or file was not found, or you do not have access (404)', + 'network-unreachable': 'the host could not be reached (DNS resolution or connection failure)', + 'config-invalid': 'the stored auth configuration is malformed or missing required fields', +}; + +export function describeFetchFailureReason(reason: FetchFailureReason): string { + return FAILURE_REASON_DESCRIPTIONS[reason]; +} + +const ERROR_CODE_FOR_REASON: Record = { + 'auth-failed': 'RULESET_AUTH_FAILED', + 'not-found': 'RULESET_NOT_FOUND', + 'network-unreachable': 'RULESET_INVALID_HOST', + 'config-invalid': 'RULESET_BAD_CONFIG', +}; + +export function errorCodeForFailureReason(reason: FetchFailureReason): string { + return ERROR_CODE_FOR_REASON[reason]; +} + +export interface RulesetFetchFailureOutput { + error: string; + failureReason: FetchFailureReason; + rulesetUrl: string; + scope: string; + message: string; +} + +export function buildRulesetFetchFailureOutput( + reason: FetchFailureReason, + rulesetUrl: string, + scope: string, + messageOverride?: string +): RulesetFetchFailureOutput { + const message = + messageOverride ?? `Could not fetch ruleset from '${rulesetUrl}' (${scope}): ${describeFetchFailureReason(reason)}.`; + + return { + error: errorCodeForFailureReason(reason), + failureReason: reason, + rulesetUrl, + scope, + message, + }; +} diff --git a/src/cli/ruleset-fetch.ts b/src/cli/ruleset-fetch.ts new file mode 100644 index 0000000..8f4c020 --- /dev/null +++ b/src/cli/ruleset-fetch.ts @@ -0,0 +1,43 @@ +import { writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fetchRulesetContent, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS } from '@dawmatt/api-grade-core'; +import { buildRulesetFetchFailureOutput, type RulesetFetchFailureOutput } from './ruleset-fetch-errors.js'; +import type { ResolveAuthResult } from './ruleset-resolution.js'; + +export interface RemoteFetchOutcome { + rulesetPath?: string; + tempFile?: string; + failure?: RulesetFetchFailureOutput; +} + +/** + * Fetches a remote ruleset (per FR-006/FR-008) and writes it to a temp file, mirroring + * the MCP server's grade.ts pattern. No-op (returns the resolved path unchanged) for a + * local or built-in ruleset. + */ +export async function resolveRemoteRuleset(authResult: ResolveAuthResult): Promise { + const remoteUrl = authResult.resolution.rulesetPath; + if (!authResult.isRemote || !remoteUrl) { + return { rulesetPath: authResult.resolution.rulesetPath ?? undefined }; + } + + if (authResult.authType === 'github-pat' && !authResult.token) { + const message = `Authentication required to fetch ruleset from '${remoteUrl}' (${authResult.resolution.scope}). Supply a token via --token or the GITHUB_TOKEN environment variable.`; + return { failure: buildRulesetFetchFailureOutput('config-invalid', remoteUrl, authResult.resolution.scope, message) }; + } + + try { + const content = await fetchRulesetContent( + remoteUrl, + authResult.authType === 'github-pat' ? authResult.token : undefined, + INITIAL_FETCH_TIMEOUT_MS + ); + const tempFile = join(tmpdir(), `api-grade-ruleset-${Date.now()}-${Math.random().toString(36).slice(2)}.yaml`); + writeFileSync(tempFile, content); + return { rulesetPath: tempFile, tempFile }; + } catch (err) { + const reason = err instanceof RulesetAuthError ? err.reason : 'network-unreachable'; + return { failure: buildRulesetFetchFailureOutput(reason, remoteUrl, authResult.resolution.scope) }; + } +} diff --git a/src/cli/ruleset-resolution.ts b/src/cli/ruleset-resolution.ts new file mode 100644 index 0000000..2630d26 --- /dev/null +++ b/src/cli/ruleset-resolution.ts @@ -0,0 +1,106 @@ +import { + resolveRuleset, + type RulesetConfig, + type RulesetResolution, + type SessionState, +} from '@dawmatt/api-grade-core'; + +export type ResolvedAuthType = 'none' | 'github-pat' | 'entra-id'; + +export function isValidAuthType(value: string): value is ResolvedAuthType { + return value === 'none' || value === 'github-pat' || value === 'entra-id'; +} + +export function makeInertSessionState(): SessionState { + return { defaultRuleset: null, sessionRulesetOverride: null }; +} + +export function isRemoteRulesetUrl(path: string | null): boolean { + return path != null && /^https?:\/\//i.test(path); +} + +export interface ResolveAuthInput { + rulesetOption?: string; + authTypeOption?: string; + tokenOption?: string; + workspaceConfig: RulesetConfig | null; + globalConfig: RulesetConfig | null; +} + +export interface ResolveAuthResult { + resolution: RulesetResolution; + /** Raw resolved auth-type string; may be invalid (not none/github-pat/entra-id). */ + authType: string; + isRemote: boolean; + isLocalFile: boolean; + /** Only populated when authType === 'github-pat' and the ruleset is remote. */ + token: string | undefined; + warnings: string[]; +} + +/** + * Single source of truth for auth-type/token resolution and ignored-option warnings, + * shared by the grade command and `config set-ruleset`/`config get-ruleset`. + */ +export function resolveCliAuth(input: ResolveAuthInput): ResolveAuthResult { + const session = makeInertSessionState(); + const resolution = resolveRuleset( + input.rulesetOption, + session, + input.workspaceConfig, + input.globalConfig + ); + + const authType = input.authTypeOption ?? resolution.auth?.type ?? 'none'; + const isRemote = isRemoteRulesetUrl(resolution.rulesetPath); + const isLocalFile = resolution.rulesetPath != null && !isRemote; + + const warnings: string[] = []; + let token: string | undefined; + + if (isLocalFile) { + if (input.authTypeOption !== undefined) { + warnings.push( + "Warning: --auth-type is ignored because the ruleset is a local file; authorisation only applies to remote (URL) rulesets." + ); + } + if (input.tokenOption !== undefined) { + warnings.push( + "Warning: --token is ignored because the ruleset is a local file; authorisation only applies to remote (URL) rulesets." + ); + } + } else { + if (authType === 'none' && input.tokenOption !== undefined) { + warnings.push( + "Warning: --token is ignored because the authorisation type is 'none'. Use --auth-type github-pat to authenticate this request." + ); + } + if (authType === 'github-pat') { + token = input.tokenOption ?? process.env.GITHUB_TOKEN ?? resolution.auth?.githubToken; + } + } + + return { resolution, authType, isRemote, isLocalFile, token, warnings }; +} + +export interface EntraRejectionCheck { + rejected: boolean; + message: string; +} + +const ENTRA_REJECTION_MESSAGE = + "Microsoft Entra ID authentication is not supported by the CLI. Configure a GitHub PAT instead (--token, GITHUB_TOKEN, or `api-grade config set-ruleset --token`)."; + +/** + * FR-016/FR-019: entra-id is rejected only when it would actually gate a remote fetch — + * never for a local ruleset (FR-021's warning applies there instead). + */ +export function checkEntraRejection(result: ResolveAuthResult): EntraRejectionCheck { + if (result.isLocalFile) { + return { rejected: false, message: '' }; + } + if (result.authType === 'entra-id') { + return { rejected: true, message: ENTRA_REJECTION_MESSAGE }; + } + return { rejected: false, message: '' }; +} diff --git a/tests/integration/cli-github-pat.test.ts b/tests/integration/cli-github-pat.test.ts new file mode 100644 index 0000000..47bf5c7 --- /dev/null +++ b/tests/integration/cli-github-pat.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const CLI = resolve(__dirname, '../../dist/cli/index.js'); +const FIXTURES = resolve(__dirname, '../fixtures'); +const OPENAPI_SPEC = resolve(FIXTURES, 'openapi/museum-api.yaml'); +const LOCAL_RULESET = resolve(FIXTURES, 'custom-ruleset.yaml'); +const VALID_TOKEN = 'ghp_test_valid_token'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCli(args: string[], opts: { cwd?: string; env?: Record } = {}): RunResult { + const result = spawnSync('node', [CLI, ...args], { + encoding: 'utf-8', + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + }); + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +function makeTmpDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function writeWorkspaceConfig(baseDir: string, config: unknown): void { + const dir = join(baseDir, '.api-grade'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify(config), 'utf-8'); +} + +function writeGlobalConfig(homeDir: string, config: unknown): void { + const dir = join(homeDir, '.api-grade'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify(config), 'utf-8'); +} + +const tmpDirsToClean: string[] = []; +afterEach(() => { + while (tmpDirsToClean.length > 0) { + const dir = tmpDirsToClean.pop()!; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +function trackedTmpDir(prefix: string): string { + const dir = makeTmpDir(prefix); + tmpDirsToClean.push(dir); + return dir; +} + +describe('US1: CLI grading against a private GitHub-hosted ruleset', () => { + it('exits 1 with an authentication-required message and never leaks the token when --auth-type github-pat has no usable token', () => { + const { status, stdout, stderr } = runCli([ + OPENAPI_SPEC, + '--ruleset', 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + '--auth-type', 'github-pat', + ]); + expect(status).toBe(1); + expect(stderr.toLowerCase()).toMatch(/authentication required/); + expect(stdout).not.toContain(VALID_TOKEN); + expect(stderr).not.toContain(VALID_TOKEN); + }); + + it('never leaks a supplied token in stdout/stderr even on fetch failure', () => { + const { stdout, stderr, status } = runCli([ + OPENAPI_SPEC, + '--ruleset', 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + '--auth-type', 'github-pat', + '--token', VALID_TOKEN, + ]); + expect(status).toBe(1); + expect(stdout).not.toContain(VALID_TOKEN); + expect(stderr).not.toContain(VALID_TOKEN); + }, 15000); + + it('warns and does not fail solely because of the ignored option when --token is supplied without --auth-type (FR-020/SC-009)', () => { + const { stderr } = runCli([ + OPENAPI_SPEC, + '--ruleset', 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + '--token', VALID_TOKEN, + ]); + expect(stderr).toContain("Warning: --token is ignored because the authorisation type is 'none'"); + }, 15000); + + it('warns once per ignored option and grades successfully for a local ruleset with auth options supplied (FR-021/SC-009)', () => { + const { status, stderr, stdout } = runCli([ + OPENAPI_SPEC, + '--ruleset', LOCAL_RULESET, + '--auth-type', 'github-pat', + '--token', VALID_TOKEN, + ]); + expect(stderr).toContain('--auth-type is ignored because the ruleset is a local file'); + expect(stderr).toContain('--token is ignored because the ruleset is a local file'); + expect(status).toBe(0); + expect(stdout).toBeTruthy(); + }, 30000); + + it('rejects an invalid --auth-type value as config-invalid before any fetch attempt', () => { + const { status, stderr } = runCli([ + OPENAPI_SPEC, + '--ruleset', 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + '--auth-type', 'bogus', + ]); + expect(status).toBe(1); + expect(stderr).toMatch(/Invalid --auth-type value/); + }); +}); + +describe('US2: persistent workspace/global ruleset defaults', () => { + it('uses a workspace-configured local default when no --ruleset is supplied', () => { + const workspaceDir = trackedTmpDir('api-grade-ws-'); + const homeDir = trackedTmpDir('api-grade-home-'); + writeWorkspaceConfig(workspaceDir, { rulesetPath: LOCAL_RULESET, auth: null }); + + const result = runCli([OPENAPI_SPEC], { cwd: workspaceDir, env: { HOME: homeDir } }); + expect(result.status).toBe(0); + expect(result.stdout).toBeTruthy(); + }, 30000); + + it('falls back to a global default when no workspace config exists', () => { + const workspaceDir = trackedTmpDir('api-grade-ws2-'); + const homeDir = trackedTmpDir('api-grade-home2-'); + writeGlobalConfig(homeDir, { rulesetPath: LOCAL_RULESET, auth: null }); + + const result = runCli([OPENAPI_SPEC], { cwd: workspaceDir, env: { HOME: homeDir } }); + expect(result.status).toBe(0); + }, 30000); + + it('an explicit per-invocation --ruleset overrides both workspace and global defaults', () => { + const workspaceDir = trackedTmpDir('api-grade-ws3-'); + const homeDir = trackedTmpDir('api-grade-home3-'); + writeWorkspaceConfig(workspaceDir, { rulesetPath: '/nonexistent/should-not-be-used.yaml', auth: null }); + + const result = runCli([OPENAPI_SPEC, '--ruleset', LOCAL_RULESET], { cwd: workspaceDir, env: { HOME: homeDir } }); + expect(result.status).toBe(0); + }, 30000); + + it('a default with no auth field resolves to none and never attempts authentication', () => { + const workspaceDir = trackedTmpDir('api-grade-ws4-'); + const homeDir = trackedTmpDir('api-grade-home4-'); + writeWorkspaceConfig(workspaceDir, { + rulesetPath: 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + auth: null, + }); + const result = runCli([OPENAPI_SPEC], { cwd: workspaceDir, env: { HOME: homeDir, GITHUB_TOKEN: VALID_TOKEN } }); + // Resolves to auth type 'none' so GITHUB_TOKEN must never be consulted (SC-008); no auth-required wording. + expect(result.stderr).not.toMatch(/authentication required/i); + }, 15000); +}); + +describe('US5: CLI rejects Entra ID authentication explicitly', () => { + it('exits non-zero when a workspace config specifies auth.type entra-id and no --ruleset override is given', () => { + const workspaceDir = trackedTmpDir('api-grade-entra-ws-'); + const homeDir = trackedTmpDir('api-grade-entra-home-'); + writeWorkspaceConfig(workspaceDir, { + rulesetPath: 'https://example.com/private-ruleset.yaml', + auth: { type: 'entra-id', tenantId: 't', clientId: 'c' }, + }); + const { status, stderr } = runCli([OPENAPI_SPEC], { cwd: workspaceDir, env: { HOME: homeDir } }); + expect(status).not.toBe(0); + expect(stderr).toMatch(/Entra ID/i); + }); + + it('exits non-zero with --auth-type entra-id on the grade command, with no device-code flow attempted', () => { + const { status, stderr } = runCli([ + OPENAPI_SPEC, + '--ruleset', 'https://example.com/private-ruleset.yaml', + '--auth-type', 'entra-id', + ]); + expect(status).not.toBe(0); + expect(stderr).toMatch(/Entra ID/i); + expect(stderr).not.toMatch(/device.?code/i); + }); + + it('rejects entra-id before any fetch attempt, with no partial application of other options', () => { + const { status, stdout, stderr } = runCli([ + OPENAPI_SPEC, + '--ruleset', 'https://example.com/private-ruleset.yaml', + '--auth-type', 'entra-id', + '--min-grade', 'A', + ]); + expect(status).toBe(1); + expect(stderr).toMatch(/Entra ID/i); + expect(stdout).not.toMatch(/letter ?grade/i); + }); + + it('does NOT reject entra-id for a local ruleset file; warns instead and grades successfully', () => { + const workspaceDir = trackedTmpDir('api-grade-entra-local-ws-'); + const homeDir = trackedTmpDir('api-grade-entra-local-home-'); + writeWorkspaceConfig(workspaceDir, { + rulesetPath: 'https://example.com/private-ruleset.yaml', + auth: { type: 'entra-id', tenantId: 't', clientId: 'c' }, + }); + const { status, stderr, stdout } = runCli( + [OPENAPI_SPEC, '--ruleset', LOCAL_RULESET], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + expect(status).toBe(0); + expect(stderr).not.toMatch(/Entra ID/i); + expect(stdout).toBeTruthy(); + }, 30000); + + it('--auth-type entra-id is recognised but not documented in --help output (FR-015/FR-017)', () => { + const { stdout } = runCli(['--help']); + expect(stdout).not.toMatch(/entra-id/i); + }); +}); diff --git a/tests/unit/cli-ruleset-config.test.ts b/tests/unit/cli-ruleset-config.test.ts new file mode 100644 index 0000000..0cfedb8 --- /dev/null +++ b/tests/unit/cli-ruleset-config.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const CLI = resolve(__dirname, '../../dist/cli/index.js'); +const VALID_TOKEN = 'ghp_test_valid_token'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCli(args: string[], opts: { cwd?: string; env?: Record } = {}): RunResult { + const result = spawnSync('node', [CLI, ...args], { + encoding: 'utf-8', + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + }); + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +const tmpDirsToClean: string[] = []; +afterEach(() => { + while (tmpDirsToClean.length > 0) { + const dir = tmpDirsToClean.pop()!; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +function trackedTmpDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tmpDirsToClean.push(dir); + return dir; +} + +describe('config set-ruleset', () => { + it('writes .api-grade/config.json with the expected RulesetConfig/AuthConfig shape', () => { + const workspaceDir = trackedTmpDir('api-grade-set-'); + const homeDir = trackedTmpDir('api-grade-set-home-'); + const { status } = runCli( + ['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml', '--auth-type', 'github-pat', '--token', VALID_TOKEN], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + expect(status).toBe(0); + const written = JSON.parse(readFileSync(join(workspaceDir, '.api-grade', 'config.json'), 'utf-8')); + expect(written).toEqual({ + rulesetPath: 'https://example.com/r.yaml', + auth: { type: 'github-pat', githubToken: VALID_TOKEN }, + }); + }); + + it('omitting --ruleset clears the default at that scope', () => { + const workspaceDir = trackedTmpDir('api-grade-clear-'); + const homeDir = trackedTmpDir('api-grade-clear-home-'); + runCli(['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml'], { cwd: workspaceDir, env: { HOME: homeDir } }); + const { status } = runCli(['config', 'set-ruleset', '--scope', 'workspace'], { cwd: workspaceDir, env: { HOME: homeDir } }); + expect(status).toBe(0); + const written = JSON.parse(readFileSync(join(workspaceDir, '.api-grade', 'config.json'), 'utf-8')); + expect(written.rulesetPath).toBeNull(); + }); + + it('--token without --auth-type github-pat does not persist auth.type or the token, and warns (Clarifications Q1)', () => { + const workspaceDir = trackedTmpDir('api-grade-q1-'); + const homeDir = trackedTmpDir('api-grade-q1-home-'); + const { status, stderr } = runCli( + ['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml', '--token', VALID_TOKEN], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + expect(status).toBe(0); + expect(stderr).toContain("Warning: --token is ignored because the authorisation type is 'none'"); + const written = JSON.parse(readFileSync(join(workspaceDir, '.api-grade', 'config.json'), 'utf-8')); + expect(written.auth).toBeNull(); + expect(JSON.stringify(written)).not.toContain(VALID_TOKEN); + }); + + it('--auth-type none --token explicitly also does not persist the token, and warns', () => { + const workspaceDir = trackedTmpDir('api-grade-q1b-'); + const homeDir = trackedTmpDir('api-grade-q1b-home-'); + const { status, stderr } = runCli( + ['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml', '--auth-type', 'none', '--token', VALID_TOKEN], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + expect(status).toBe(0); + expect(stderr).toContain("Warning: --token is ignored because the authorisation type is 'none'"); + const written = JSON.parse(readFileSync(join(workspaceDir, '.api-grade', 'config.json'), 'utf-8')); + expect(written.auth).toBeNull(); + }); + + it('rejects --auth-type entra-id the same way as the grade command', () => { + const workspaceDir = trackedTmpDir('api-grade-entra-set-'); + const homeDir = trackedTmpDir('api-grade-entra-set-home-'); + const { status, stderr } = runCli( + ['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml', '--auth-type', 'entra-id'], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + expect(status).not.toBe(0); + expect(stderr).toMatch(/Entra ID/i); + }); + + it('rejects an unrecognised --auth-type value without writing the config file', () => { + const workspaceDir = trackedTmpDir('api-grade-invalid-set-'); + const homeDir = trackedTmpDir('api-grade-invalid-set-home-'); + const { status, stderr } = runCli( + ['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml', '--auth-type', 'bogus'], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + expect(status).not.toBe(0); + expect(stderr).toMatch(/Invalid --auth-type/); + }); +}); + +describe('config get-ruleset', () => { + it('reports the effective scope/path/auth type and redacts token values', () => { + const workspaceDir = trackedTmpDir('api-grade-get-'); + const homeDir = trackedTmpDir('api-grade-get-home-'); + runCli( + ['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml', '--auth-type', 'github-pat', '--token', VALID_TOKEN], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + const { status, stdout } = runCli(['config', 'get-ruleset'], { cwd: workspaceDir, env: { HOME: homeDir } }); + expect(status).toBe(0); + expect(stdout).toContain('workspace'); + expect(stdout).toContain('(token configured)'); + expect(stdout).not.toContain(VALID_TOKEN); + }); + + it('redacts token values in JSON output too', () => { + const workspaceDir = trackedTmpDir('api-grade-get-json-'); + const homeDir = trackedTmpDir('api-grade-get-json-home-'); + runCli( + ['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml', '--auth-type', 'github-pat', '--token', VALID_TOKEN], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + const { status, stdout } = runCli(['config', 'get-ruleset', '--format', 'json'], { cwd: workspaceDir, env: { HOME: homeDir } }); + expect(status).toBe(0); + expect(stdout).not.toContain(VALID_TOKEN); + const parsed = JSON.parse(stdout); + expect(parsed.effective.scope).toBe('workspace'); + expect(parsed.effective.authType).toBe('github-pat'); + }); + + it('reports an entra-id config as unsupported-by-CLI informationally, without a non-zero exit (T058)', () => { + const workspaceDir = trackedTmpDir('api-grade-get-entra-'); + const homeDir = trackedTmpDir('api-grade-get-entra-home-'); + runCli( + ['config', 'set-ruleset', '--scope', 'workspace', '--ruleset', 'https://example.com/r.yaml'], + { cwd: workspaceDir, env: { HOME: homeDir } } + ); + // Hand-write an entra-id config directly since set-ruleset itself rejects entra-id. + writeFileSync( + join(workspaceDir, '.api-grade', 'config.json'), + JSON.stringify({ rulesetPath: 'https://example.com/r.yaml', auth: { type: 'entra-id', tenantId: 't', clientId: 'c' } }) + ); + const { status, stdout } = runCli(['config', 'get-ruleset', '--format', 'json'], { cwd: workspaceDir, env: { HOME: homeDir } }); + expect(status).toBe(0); + expect(JSON.parse(stdout).unsupportedByCli).toMatch(/Entra ID/); + }); +}); diff --git a/tests/unit/cli-ruleset-resolution.test.ts b/tests/unit/cli-ruleset-resolution.test.ts new file mode 100644 index 0000000..b03b657 --- /dev/null +++ b/tests/unit/cli-ruleset-resolution.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resolveCliAuth, checkEntraRejection, isValidAuthType } from '../../src/cli/ruleset-resolution.js'; +import { resolveRemoteRuleset } from '../../src/cli/ruleset-fetch.js'; +import type { RulesetConfig } from '@dawmatt/api-grade-core'; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.GITHUB_TOKEN; +}); + +describe('isValidAuthType', () => { + it('accepts none, github-pat, entra-id', () => { + expect(isValidAuthType('none')).toBe(true); + expect(isValidAuthType('github-pat')).toBe(true); + expect(isValidAuthType('entra-id')).toBe(true); + }); + it('rejects anything else', () => { + expect(isValidAuthType('oauth')).toBe(false); + expect(isValidAuthType('')).toBe(false); + }); +}); + +describe('resolveCliAuth — auth-type/token resolution (FR-017/FR-018/FR-004)', () => { + it('defaults to none with no auth-type option and no stored config', () => { + const result = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + workspaceConfig: null, + globalConfig: null, + }); + expect(result.authType).toBe('none'); + expect(result.token).toBeUndefined(); + }); + + it('--auth-type overrides resolved scope auth.type', () => { + const workspaceConfig: RulesetConfig = { rulesetPath: null, auth: { type: 'github-pat', githubToken: 'stored' } }; + const result = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + authTypeOption: 'none', + workspaceConfig, + globalConfig: null, + }); + expect(result.authType).toBe('none'); + }); + + it('token precedence: --token > GITHUB_TOKEN > stored githubToken', () => { + // The stored token is only reachable when the ruleset itself comes from that scope + // (resolveRuleset always returns auth: null for a per-request path). + process.env.GITHUB_TOKEN = 'env-token'; + const workspaceConfig: RulesetConfig = { + rulesetPath: 'https://workspace.example.com/r.yaml', + auth: { type: 'github-pat', githubToken: 'stored-token' }, + }; + + const withCliToken = resolveCliAuth({ + tokenOption: 'cli-token', + workspaceConfig, + globalConfig: null, + }); + expect(withCliToken.token).toBe('cli-token'); + + const withEnvOnly = resolveCliAuth({ + workspaceConfig, + globalConfig: null, + }); + expect(withEnvOnly.token).toBe('env-token'); + + delete process.env.GITHUB_TOKEN; + const withStoredOnly = resolveCliAuth({ + workspaceConfig, + globalConfig: null, + }); + expect(withStoredOnly.token).toBe('stored-token'); + }); + + it('does not consult any token source when resolved auth type is none (FR-018/SC-008)', () => { + process.env.GITHUB_TOKEN = 'env-token'; + const result = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + workspaceConfig: null, + globalConfig: null, + }); + expect(result.token).toBeUndefined(); + }); + + it('warns once when --token is supplied but resolved auth type is none (FR-020)', () => { + const result = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + tokenOption: 'some-token', + workspaceConfig: null, + globalConfig: null, + }); + expect(result.warnings).toEqual([ + "Warning: --token is ignored because the authorisation type is 'none'. Use --auth-type github-pat to authenticate this request.", + ]); + }); + + it('warns for each ignored option when the ruleset is a local file (FR-021), local wording wins over none-wording', () => { + const result = resolveCliAuth({ + rulesetOption: './local-ruleset.yaml', + authTypeOption: 'github-pat', + tokenOption: 'some-token', + workspaceConfig: null, + globalConfig: null, + }); + expect(result.isLocalFile).toBe(true); + expect(result.warnings).toEqual([ + 'Warning: --auth-type is ignored because the ruleset is a local file; authorisation only applies to remote (URL) rulesets.', + 'Warning: --token is ignored because the ruleset is a local file; authorisation only applies to remote (URL) rulesets.', + ]); + }); + + it('no warnings for a remote ruleset with auth-type github-pat and a token supplied', () => { + const result = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + authTypeOption: 'github-pat', + tokenOption: 'tok', + workspaceConfig: null, + globalConfig: null, + }); + expect(result.warnings).toEqual([]); + expect(result.token).toBe('tok'); + }); +}); + +describe('checkEntraRejection (FR-016/FR-019)', () => { + it('rejects when resolved auth type is entra-id for a remote ruleset', () => { + const result = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + authTypeOption: 'entra-id', + workspaceConfig: null, + globalConfig: null, + }); + const check = checkEntraRejection(result); + expect(check.rejected).toBe(true); + expect(check.message).toMatch(/Entra ID/); + }); + + it('does not reject when the ruleset is local, even if entra-id is configured (FR-021 takes precedence)', () => { + const result = resolveCliAuth({ + rulesetOption: './local.yaml', + authTypeOption: 'entra-id', + workspaceConfig: null, + globalConfig: null, + }); + const check = checkEntraRejection(result); + expect(check.rejected).toBe(false); + }); +}); + +describe('resolveRemoteRuleset (FR-006/FR-008/FR-009)', () => { + it('fetches and writes a temp file for a remote ruleset when auth resolves successfully', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve('rules: {}'), + })); + const authResult = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + authTypeOption: 'github-pat', + tokenOption: 'tok', + workspaceConfig: null, + globalConfig: null, + }); + const outcome = await resolveRemoteRuleset(authResult); + expect(outcome.failure).toBeUndefined(); + expect(outcome.rulesetPath).toBeTruthy(); + expect(outcome.tempFile).toBe(outcome.rulesetPath); + }); + + it('returns a config-invalid failure with an authentication-required message when github-pat has no usable token (FR-010)', async () => { + const authResult = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + authTypeOption: 'github-pat', + workspaceConfig: null, + globalConfig: null, + }); + const outcome = await resolveRemoteRuleset(authResult); + expect(outcome.failure?.failureReason).toBe('config-invalid'); + expect(outcome.failure?.message).toMatch(/Authentication required/); + }); + + it('classifies a 401/403 fetch as auth-failed', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 401 })); + const authResult = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + authTypeOption: 'github-pat', + tokenOption: 'bad-token', + workspaceConfig: null, + globalConfig: null, + }); + const outcome = await resolveRemoteRuleset(authResult); + expect(outcome.failure?.failureReason).toBe('auth-failed'); + expect(outcome.failure?.message).not.toContain('bad-token'); + }); + + it('classifies a 404 as not-found', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false, status: 404 })); + const authResult = resolveCliAuth({ + rulesetOption: 'https://example.com/r.yaml', + workspaceConfig: null, + globalConfig: null, + }); + const outcome = await resolveRemoteRuleset(authResult); + expect(outcome.failure?.failureReason).toBe('not-found'); + }); + + it('is a no-op for a local ruleset path', async () => { + const authResult = resolveCliAuth({ + rulesetOption: './local.yaml', + workspaceConfig: null, + globalConfig: null, + }); + const outcome = await resolveRemoteRuleset(authResult); + expect(outcome.failure).toBeUndefined(); + expect(outcome.rulesetPath).toBe('./local.yaml'); + expect(outcome.tempFile).toBeUndefined(); + }); +}); diff --git a/tests/unit/ruleset-config-cli.test.ts b/tests/unit/ruleset-config-cli.test.ts new file mode 100644 index 0000000..f6aa620 --- /dev/null +++ b/tests/unit/ruleset-config-cli.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let mockHomeDir = '/tmp/unused-default-home'; +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => mockHomeDir }; +}); + +const { runSetRuleset, runGetRuleset } = await import('../../src/cli/ruleset-config-cli.js'); +const { getWorkspaceConfigPath } = await import('@dawmatt/api-grade-core'); + +class FakeExit extends Error { + constructor(public code: number) { + super(`process.exit(${code})`); + } +} + +let cwdDir: string; +let logs: string[]; +let errors: string[]; +let warnings: string[]; + +let homeDir: string; + +beforeEach(() => { + cwdDir = mkdtempSync(join(tmpdir(), 'api-grade-config-cli-')); + homeDir = mkdtempSync(join(tmpdir(), 'api-grade-config-cli-home-')); + mockHomeDir = homeDir; + vi.spyOn(process, 'cwd').mockReturnValue(cwdDir); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new FakeExit(code ?? 0); + }) as never); + logs = []; + errors = []; + warnings = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => { logs.push(msg); }); + vi.spyOn(console, 'error').mockImplementation((msg: string) => { errors.push(msg); }); + vi.spyOn(console, 'warn').mockImplementation((msg: string) => { warnings.push(msg); }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + try { rmSync(cwdDir, { recursive: true, force: true }); } catch { /* ignore */ } + try { rmSync(homeDir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +describe('runSetRuleset', () => { + it('rejects a missing/invalid --scope', async () => { + await expect(runSetRuleset({})).rejects.toBeInstanceOf(FakeExit); + expect(errors.join('\n')).toMatch(/--scope is required/); + }); + + it('writes the workspace config with the expected shape', async () => { + await runSetRuleset({ scope: 'workspace', ruleset: 'https://example.com/r.yaml', authType: 'github-pat', token: 'tok' }); + const written = JSON.parse(readFileSync(getWorkspaceConfigPath(), 'utf-8')); + expect(written).toEqual({ rulesetPath: 'https://example.com/r.yaml', auth: { type: 'github-pat', githubToken: 'tok' } }); + expect(logs.join('\n')).toMatch(/Workspace default ruleset configured/); + }); + + it('omitting --ruleset clears the default', async () => { + await runSetRuleset({ scope: 'workspace', ruleset: 'https://example.com/r.yaml' }); + await runSetRuleset({ scope: 'workspace' }); + const written = JSON.parse(readFileSync(getWorkspaceConfigPath(), 'utf-8')); + expect(written.rulesetPath).toBeNull(); + expect(logs.join('\n')).toMatch(/cleared/); + }); + + it('rejects an invalid --auth-type value', async () => { + await expect(runSetRuleset({ scope: 'workspace', authType: 'bogus' })).rejects.toBeInstanceOf(FakeExit); + expect(errors.join('\n')).toMatch(/Invalid --auth-type value/); + }); + + it('rejects --auth-type entra-id', async () => { + await expect(runSetRuleset({ scope: 'workspace', authType: 'entra-id' })).rejects.toBeInstanceOf(FakeExit); + expect(errors.join('\n')).toMatch(/Entra ID/); + }); + + it('--token without --auth-type github-pat does not persist the token and warns (Q1)', async () => { + await runSetRuleset({ scope: 'workspace', ruleset: 'https://example.com/r.yaml', token: 'tok' }); + expect(warnings.join('\n')).toMatch(/--token is ignored because the authorisation type is 'none'/); + const written = JSON.parse(readFileSync(getWorkspaceConfigPath(), 'utf-8')); + expect(written.auth).toBeNull(); + }); + + it('--auth-type none --token explicitly also does not persist the token', async () => { + await runSetRuleset({ scope: 'workspace', ruleset: 'https://example.com/r.yaml', authType: 'none', token: 'tok' }); + expect(warnings.join('\n')).toMatch(/ignored/); + const written = JSON.parse(readFileSync(getWorkspaceConfigPath(), 'utf-8')); + expect(written.auth).toBeNull(); + }); + + it('emits JSON output without leaking the token when --format json', async () => { + await runSetRuleset({ scope: 'workspace', ruleset: 'https://example.com/r.yaml', authType: 'github-pat', token: 'tok', format: 'json' }); + expect(logs.join('\n')).not.toContain('tok'); + const parsed = JSON.parse(logs[logs.length - 1]); + expect(parsed).toEqual({ scope: 'workspace', rulesetPath: 'https://example.com/r.yaml', configFile: getWorkspaceConfigPath() }); + }); + + it('global scope writes to the (mocked) global config path', async () => { + await runSetRuleset({ scope: 'global', ruleset: 'https://example.com/g.yaml' }); + const written = JSON.parse(readFileSync(join(homeDir, '.api-grade', 'config.json'), 'utf-8')); + expect(written.rulesetPath).toBe('https://example.com/g.yaml'); + }); +}); + +describe('runGetRuleset', () => { + it('reports built-in when nothing is configured', async () => { + await runGetRuleset({}); + expect(logs.join('\n')).toMatch(/scope=built-in/); + }); + + it('reports the effective workspace config and redacts the token (human)', async () => { + await runSetRuleset({ scope: 'workspace', ruleset: 'https://example.com/r.yaml', authType: 'github-pat', token: 'secret-tok' }); + logs = []; + await runGetRuleset({}); + const output = logs.join('\n'); + expect(output).toContain('(token configured)'); + expect(output).not.toContain('secret-tok'); + }); + + it('reports JSON output with authType and without the token', async () => { + await runSetRuleset({ scope: 'workspace', ruleset: 'https://example.com/r.yaml', authType: 'github-pat', token: 'secret-tok' }); + logs = []; + await runGetRuleset({ format: 'json' }); + const output = logs.join('\n'); + expect(output).not.toContain('secret-tok'); + const parsed = JSON.parse(output); + expect(parsed.effective.authType).toBe('github-pat'); + expect(parsed.workspace.rulesetPath).toBe('https://example.com/r.yaml'); + }); + + it('reports an entra-id config as unsupported-by-CLI informationally', async () => { + await runSetRuleset({ scope: 'workspace', ruleset: 'https://example.com/r.yaml' }); + // Hand-write entra-id auth since set-ruleset itself rejects it. + const { writeFileSync } = await import('node:fs'); + writeFileSync(getWorkspaceConfigPath(), JSON.stringify({ rulesetPath: 'https://example.com/r.yaml', auth: { type: 'entra-id', tenantId: 't', clientId: 'c' } })); + logs = []; + await runGetRuleset({ format: 'json' }); + const parsed = JSON.parse(logs.join('')); + expect(parsed.unsupportedByCli).toMatch(/Entra ID/); + }); +}); diff --git a/yarn.lock b/yarn.lock index 47412d1..a7355a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1684,9 +1684,10 @@ "@date-io/core" "^1.3.13" "@dawmatt/api-grade-core@*", "@dawmatt/api-grade-core@file:/Users/matt/Code/DawMatt/api-grade/packages/api-grade-core": - version "0.1.20" + version "0.2.1" resolved "file:packages/api-grade-core" dependencies: + "@azure/msal-node" "^2.16.2" "@stoplight/spectral-core" "^1.23.0" "@stoplight/spectral-formats" "^1.8.3" "@stoplight/spectral-parsers" "^1.0.5" @@ -1696,7 +1697,7 @@ chalk "^5.3.0" "@dawmatt/api-grade-mcp@file:/Users/matt/Code/DawMatt/api-grade/packages/api-grade-mcp": - version "0.1.0" + version "0.2.1" resolved "file:packages/api-grade-mcp" dependencies: "@azure/msal-node" "^2.16.2" @@ -1705,14 +1706,14 @@ zod "^3.22.0" "@dawmatt/backstage-plugin-api-grade-backend@*", "@dawmatt/backstage-plugin-api-grade-backend@file:/Users/matt/Code/DawMatt/api-grade/packages/backstage-plugin-api-grade-backend": - version "0.1.20" + version "0.2.1" resolved "file:packages/backstage-plugin-api-grade-backend" dependencies: "@dawmatt/api-grade-core" "*" express "^4.18.0" "@dawmatt/backstage-plugin-api-grade@file:/Users/matt/Code/DawMatt/api-grade/packages/backstage-plugin-api-grade": - version "0.1.20" + version "0.2.1" resolved "file:packages/backstage-plugin-api-grade" dependencies: "@dawmatt/api-grade-core" "*" From e7094be36512ac4945733dc4715c7a31aebfe050 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 20:45:29 +1000 Subject: [PATCH 08/10] Current state configuration documentation --- docs/cli/commands.md | 82 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 94775a0..d4a18a0 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -113,12 +113,64 @@ api-grade openapi.yaml \ --- +## Two Configuration Mechanisms + +There are **two separate, independent configuration files**, with different scope and +purpose. They are easy to confuse because both can set a default `ruleset`, but only +one of them is shared with the MCP server, and only one of them covers options other +than the ruleset. + +| | [`.apigrade.json`](#configuration-file-apigradejson) | [`.api-grade/config.json`](#persistent-ruleset-configuration-config-subcommand) | +|---|---|---| +| **Purpose** | General CLI run defaults (grading thresholds, output shape) | Persisted *default ruleset* + auth, shareable across tools | +| **Consumed by** | CLI only | CLI (`config` subcommand) **and** the `api-grade-mcp` server | +| **Location** | One file, in the current working directory | Two possible files: workspace (`./.api-grade/config.json`) or global (`~/.api-grade/config.json`) | +| **How it's written** | Hand-edited JSON file | Written via `api-grade config set-ruleset` or the MCP `set-ruleset-config` tool — never hand-edited | +| **Keys supported** | `minGrade`, `ruleset`, `format`, `top`, `verbose` | `ruleset` (path/URL) and `auth` (`type` + token) only | + +If you only need a default ruleset for local CLI runs, `.apigrade.json` is usually +enough. Reach for `api-grade config set-ruleset` instead when the same ruleset/auth +needs to be visible to **both** the CLI and an MCP client (e.g. an editor or agent +using `api-grade-mcp`), since that's the one config surface both sides read. + +### Precedence when the same setting is supplied in multiple places + +For `--ruleset` specifically, every source funnels into one resolution order +(highest priority first): + +1. `--ruleset` CLI flag +2. `ruleset` key in `.apigrade.json` +3. Workspace `.api-grade/config.json` (`api-grade config set-ruleset --scope workspace`) +4. Global `~/.api-grade/config.json` (`api-grade config set-ruleset --scope global`) +5. Built-in default ruleset + +The first source in this list that specifies a ruleset wins outright — sources +are **not merged**; e.g. if the workspace config sets `auth`, but a higher-priority +source (CLI flag or `.apigrade.json`) sets `ruleset` without auth, the workspace +config's `auth` is *not* picked up, since the whole resolution (ruleset path + auth) +comes from a single source. + +`--auth-type` / `--token` (and the `GITHUB_TOKEN` environment variable) are **only** +ever supplied via CLI flags/env — `.apigrade.json` has no equivalent keys. They apply +on top of whichever ruleset source won above: if that source is `.api-grade/config.json` +and it has persisted `auth`, `--auth-type`/`--token` override that persisted auth; +otherwise they're the only source of auth. + +All other `.apigrade.json` keys (`minGrade`, `format`, `top`, `verbose`) have no +equivalent in `.api-grade/config.json`, so there's no cross-file precedence question +for them — only "CLI flag overrides `.apigrade.json`" applies. + +--- + ## Persistent Ruleset Configuration (`config` subcommand) `api-grade config set-ruleset` / `api-grade config get-ruleset` let you configure a default ruleset (and optional GitHub PAT auth) once, at workspace or global scope, so every subsequent invocation in that workspace — including CI runs — uses it -automatically without repeating `--ruleset`/`--auth-type`/`--token` on every command. +automatically without repeating `--ruleset`/`--auth-type`/`--token` on every command, +and so the same default is visible to MCP clients. See +[Two Configuration Mechanisms](#two-configuration-mechanisms) for how this relates to +`.apigrade.json` and which one wins when both set a ruleset. | Flag (`config set-ruleset`) | Required | Description | |---|---|---| @@ -136,7 +188,9 @@ api-grade config set-ruleset \ --token ghp_xxxxxxxxxxxxxxxxxxxx ``` -Every subsequent invocation in this workspace uses it automatically: +Every subsequent invocation in this workspace uses it automatically, **as long as +neither `--ruleset` nor a `.apigrade.json` `ruleset` key is also present** — both +take precedence over this persisted config (see precedence order above): ```bash api-grade openapi.yaml --min-grade B --format json @@ -149,10 +203,12 @@ Check what's configured (never prints the token value — only `(token configure api-grade config get-ruleset ``` -Workspace-scoped config always takes precedence over global config. Both surfaces -read/write the exact same file format as the `api-grade-mcp` server's -`set-ruleset-config`/`get-ruleset-config` tools — a workspace configured via one is -immediately usable by the other. +Workspace-scoped config always takes precedence over global config (within this +config surface — `.apigrade.json` and `--ruleset` still take precedence over both, +per the order above). Both `config set-ruleset`/`get-ruleset` and the +`api-grade-mcp` server's `set-ruleset-config`/`get-ruleset-config` tools read/write +the exact same file — a workspace configured via one is immediately usable by the +other. > **Note:** Microsoft Entra ID authentication (used by the MCP server) is not > supported by the CLI. If a shared config file specifies `auth.type: "entra-id"`, @@ -160,9 +216,15 @@ immediately usable by the other. --- -## Configuration File +## Configuration File (`.apigrade.json`) -You can persist options in a `.apigrade.json` file in your working directory. CLI flags always take precedence over config file values. +You can persist CLI run defaults in a `.apigrade.json` file in your working directory. +This file is **CLI-only** — it is not read by the `api-grade-mcp` server, and it is +hand-edited rather than written by a command. CLI flags always take precedence over +`.apigrade.json` values. See +[Two Configuration Mechanisms](#two-configuration-mechanisms) if you also use +`api-grade config set-ruleset` / `.api-grade/config.json` — the two can both specify a +default `ruleset`, and `.apigrade.json` wins. ```json { @@ -184,6 +246,10 @@ All keys are optional. Supported keys: | `top` | number | `--top` | | `verbose` | boolean | `--verbose` | +There is no `authType`/`token` key — authorisation for a remote ruleset is supplied +only via `--auth-type`/`--token`/`GITHUB_TOKEN`, or persisted via +`api-grade config set-ruleset` (see above). + --- ## Custom Rulesets From 24a6172420b53030209b67f81aec6409110754f0 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 22:25:52 +1000 Subject: [PATCH 09/10] CLI configuration file extended to support more arguments --- docs/cli/commands.md | 63 ++++-- .../vitest.config.ts | 1 + .../checklists/requirements.md | 2 +- .../contracts/cli-options.md | 49 +++-- specs/008-cli-github-pat/data-model.md | 6 +- specs/008-cli-github-pat/plan.md | 25 ++- specs/008-cli-github-pat/quickstart.md | 28 ++- specs/008-cli-github-pat/spec.md | 152 +++++++++++++- specs/008-cli-github-pat/tasks.md | 187 +++++++++++------- src/cli/config-loader.ts | 12 ++ src/cli/index.ts | 21 +- src/cli/ruleset-config-cli.ts | 39 +++- src/cli/ruleset-resolution.ts | 18 +- tests/integration/cli-github-pat.test.ts | 100 ++++++++++ tests/unit/cli-ruleset-config.test.ts | 2 + tests/unit/config-loader.test.ts | 39 ++++ 16 files changed, 621 insertions(+), 123 deletions(-) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index d4a18a0..fa6cd15 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -126,7 +126,7 @@ than the ruleset. | **Consumed by** | CLI only | CLI (`config` subcommand) **and** the `api-grade-mcp` server | | **Location** | One file, in the current working directory | Two possible files: workspace (`./.api-grade/config.json`) or global (`~/.api-grade/config.json`) | | **How it's written** | Hand-edited JSON file | Written via `api-grade config set-ruleset` or the MCP `set-ruleset-config` tool — never hand-edited | -| **Keys supported** | `minGrade`, `ruleset`, `format`, `top`, `verbose` | `ruleset` (path/URL) and `auth` (`type` + token) only | +| **Keys supported** | Every grading-command flag except `--help`/`--version`: `minGrade`, `ruleset`, `authType`, `token`, `format`, `top`, `verbose`, `url` | `ruleset` (path/URL) and `auth` (`type` + token) only | If you only need a default ruleset for local CLI runs, `.apigrade.json` is usually enough. Reach for `api-grade config set-ruleset` instead when the same ruleset/auth @@ -150,15 +150,25 @@ source (CLI flag or `.apigrade.json`) sets `ruleset` without auth, the workspace config's `auth` is *not* picked up, since the whole resolution (ruleset path + auth) comes from a single source. -`--auth-type` / `--token` (and the `GITHUB_TOKEN` environment variable) are **only** -ever supplied via CLI flags/env — `.apigrade.json` has no equivalent keys. They apply -on top of whichever ruleset source won above: if that source is `.api-grade/config.json` -and it has persisted `auth`, `--auth-type`/`--token` override that persisted auth; -otherwise they're the only source of auth. +`--auth-type` / `--token` have their **own**, separate resolution order — independent +of which source won the `ruleset` resolution above: -All other `.apigrade.json` keys (`minGrade`, `format`, `top`, `verbose`) have no -equivalent in `.api-grade/config.json`, so there's no cross-file precedence question -for them — only "CLI flag overrides `.apigrade.json`" applies. +1. `--auth-type` / `--token` CLI flag +2. `authType` / `token` key in `.apigrade.json` +3. `GITHUB_TOKEN` environment variable (token resolution only — there is no env-var + equivalent for `authType`) +4. `auth.type` / `auth.githubToken` field of the `RulesetConfig` at whichever scope + the *ruleset* resolved from above (workspace or global) +5. `none` (default — equivalent to `auth.type` being absent) + +Because this is a separate chain, a ruleset path can come from one source while its +auth comes from another — e.g. an `.apigrade.json` `ruleset` URL combined with a +workspace `.api-grade/config.json`'s persisted `auth`, when neither `.apigrade.json` +nor a CLI flag sets `authType`/`token`. + +All other `.apigrade.json` keys (`minGrade`, `format`, `top`, `verbose`, `url`) have +no equivalent in `.api-grade/config.json`, so there's no cross-file precedence +question for them — only "CLI flag overrides `.apigrade.json`" applies. --- @@ -236,19 +246,46 @@ default `ruleset`, and `.apigrade.json` wins. } ``` -All keys are optional. Supported keys: +All keys are optional. Supported keys — one for every grading-command flag except +`--help`/`--version`: | Key | Type | Equivalent flag | |-----|------|-----------------| | `minGrade` | string | `--min-grade` | | `ruleset` | string | `--ruleset` | +| `authType` | string | `--auth-type` | +| `token` | string | `--token` | | `format` | `"human"` or `"json"` | `--format` | | `top` | number | `--top` | | `verbose` | boolean | `--verbose` | +| `url` | string | `--url` (reserved — a non-empty value exits 1, same as the flag) | + +An explicit command-line flag always overrides the matching `.apigrade.json` key. +`authType`/`token` have their own resolution chain, independent of `ruleset`'s — see +[Precedence when the same setting is supplied in multiple places](#precedence-when-the-same-setting-is-supplied-in-multiple-places). + +**Fully configuring a private-repo ruleset run with zero flags:** + +```json +{ + "minGrade": "B", + "ruleset": "https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml", + "authType": "github-pat", + "token": "ghp_xxxxxxxxxxxxxxxxxxxx", + "format": "json", + "top": 20 +} +``` + +```bash +api-grade openapi.yaml +``` -There is no `authType`/`token` key — authorisation for a remote ruleset is supplied -only via `--auth-type`/`--token`/`GITHUB_TOKEN`, or persisted via -`api-grade config set-ruleset` (see above). +> **Security note:** a `token` value in `.apigrade.json` is exposed to anything +> that can read the file — including version control, if it's committed. Prefer +> the `GITHUB_TOKEN` environment variable, or add `.apigrade.json` to `.gitignore` +> when it contains a real token. The CLI never prints a `token` value (from any +> source) to stdout, stderr, or logs, including under `--verbose`. --- diff --git a/packages/backstage-plugin-api-grade-backend/vitest.config.ts b/packages/backstage-plugin-api-grade-backend/vitest.config.ts index e634b42..8a5f0df 100644 --- a/packages/backstage-plugin-api-grade-backend/vitest.config.ts +++ b/packages/backstage-plugin-api-grade-backend/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ include: ['tests/**/*.test.ts'], globals: false, environment: 'node', + testTimeout: 15000, coverage: { provider: 'v8', include: ['src/**/*.ts'], diff --git a/specs/008-cli-github-pat/checklists/requirements.md b/specs/008-cli-github-pat/checklists/requirements.md index 2ebcbe2..2c1e8e8 100644 --- a/specs/008-cli-github-pat/checklists/requirements.md +++ b/specs/008-cli-github-pat/checklists/requirements.md @@ -39,5 +39,5 @@ `api-grade-core` so the CLI can consume them. A dedicated user story (User Story 3, P1) and FR-002/SC-003 exist specifically to guard against behavioral regression in the MCP server during extraction. Entra ID is extracted alongside - GitHub PAT but deliberately kept inaccessible at the CLI surface (User Story 4, + GitHub PAT but deliberately kept inaccessible at the CLI surface (User Story 5, FR-015/FR-016, SC-007) as groundwork for a planned future CLI feature. diff --git a/specs/008-cli-github-pat/contracts/cli-options.md b/specs/008-cli-github-pat/contracts/cli-options.md index 5651df1..3c93647 100644 --- a/specs/008-cli-github-pat/contracts/cli-options.md +++ b/specs/008-cli-github-pat/contracts/cli-options.md @@ -14,15 +14,19 @@ New/changed options: | `--token ` | string | **NEW**. GitHub Personal Access Token used to authenticate a `--ruleset` URL fetch, or a resolved workspace/global default's URL fetch. Only consulted when the resolved authorisation type is `github-pat` (FR-004, FR-018). Highest-precedence token source. Never echoed in any output (FR-007). | Unchanged options (`--min-grade`, `--format`, `--top`, `--verbose`) behave exactly as -before; `--url` remains reserved/unsupported (unchanged early-exit behavior). +before; `--url` remains reserved/unsupported (unchanged early-exit behavior). Every +option above, plus `--min-grade`/`--format`/`--top`/`--verbose`/`--url`, also has an +`.apigrade.json` key equivalent — see [`.apigrade.json` keys (FR-024–FR-028)](#apigradejson-keys-fr-024fr-028) +below. -### Authorisation type resolution (FR-017, FR-018, FR-019) +### Authorisation type resolution (FR-017, FR-018, FR-019, FR-026) Order, first match wins: 1. `--auth-type ` command-line option -2. `auth.type` field of the `RulesetConfig` at whichever scope the ruleset was +2. `authType` key in `.apigrade.json` +3. `auth.type` field of the `RulesetConfig` at whichever scope the ruleset was resolved from (workspace or global) -3. `none` (default — equivalent to `auth.type` being absent) +4. `none` (default — equivalent to `auth.type` being absent) This resolution applies only to *remote* (URL) ruleset sources. For a local (file-path) ruleset, the resolved authorisation type is computed for warning @@ -32,17 +36,37 @@ for a local read. If the resolved type is `none`, no authentication step is attempted for the fetch: `GITHUB_TOKEN` and any stored `auth.githubToken` are not consulted, even if present (FR-018, SC-008). If the resolved type is `entra-id`, the CLI rejects the invocation -per FR-016 before any fetch is attempted. +per FR-016 before any fetch is attempted. An `.apigrade.json` `authType` value +outside `none`/`github-pat`/`entra-id` triggers the same `config-invalid` rejection +as an invalid `--auth-type` flag value (FR-028). -### Token resolution (FR-004) +### Token resolution (FR-004, FR-026) Only performed when the resolved authorisation type (above) is `github-pat`. Order, first match wins: 1. `--token ` command-line option -2. `GITHUB_TOKEN` environment variable -3. `auth.githubToken` field of the `RulesetConfig` at whichever scope the ruleset was +2. `token` key in `.apigrade.json` +3. `GITHUB_TOKEN` environment variable +4. `auth.githubToken` field of the `RulesetConfig` at whichever scope the ruleset was resolved from (workspace or global) — set via `config set-ruleset` +### `.apigrade.json` keys (FR-024–FR-028) + +| Key | ↔ Flag | Type | Notes | +|---|---|---|---| +| `minGrade` | `--min-grade` | string | Unchanged (pre-existing). | +| `ruleset` | `--ruleset` | string | Unchanged (pre-existing). Note this is a *separate* resolution chain from `authType`/`token` below — see spec.md's edge case on the two not being coupled. | +| `authType` | `--auth-type` | string | **NEW.** Slots into the authorisation-type resolution order above, above persisted `RulesetConfig.auth.type`, below the `--auth-type` flag. | +| `token` | `--token` | string | **NEW.** Slots into the token resolution order above, above `GITHUB_TOKEN` and persisted `RulesetConfig.auth.githubToken`, below the `--token` flag. Never echoed in output once read (FR-007/FR-028); committing it to version control carries the same risk as committing any other credential. | +| `format` | `--format` | `'human' \| 'json'` | Unchanged (pre-existing). | +| `top` | `--top` | number | Unchanged (pre-existing). | +| `verbose` | `--verbose` | boolean | Unchanged (pre-existing). | +| `url` | `--url` | string | **NEW.** A non-empty value triggers the same "reserved, not yet supported" exit-1 rejection as the `--url` flag. | + +All keys are optional; an absent or unrecognized key is ignored (pre-existing +loader behavior, unchanged). `--help`/`--version` have no `.apigrade.json` +equivalent — they are meta/process-control options, not invocation defaults. + ### Ignored-option warnings (FR-020, FR-021, SC-009) The CLI emits one stderr warning line per ignored option (not a single combined @@ -146,9 +170,12 @@ prints: - Human: effective scope + path + resolved auth type, plus per-scope (workspace/global) values, token presence indicated only as `(token configured)` / `(no token)` / `(from GITHUB_TOKEN)` — never the value. -- JSON: `{ effective: { scope, rulesetPath, authType }, workspace: {...} | null, - global: {...} | null, builtIn: 'default' }` — same redaction rule as MCP's - `get-ruleset-config` `sanitizeAuth` (`tokenSource` field, not the token). +- JSON: `{ effective: { scope, rulesetPath, authType, tokenPresence }, workspace: + { rulesetPath, authType, tokenPresence } | null, global: { rulesetPath, + authType, tokenPresence } | null, builtIn: 'default' }` — `tokenPresence` is + one of `'(token configured)'` / `'(no token)'` / `'(from GITHUB_TOKEN)'`, + mirroring the human-output redaction and MCP's `get-ruleset-config` + `sanitizeAuth` approach — never the token itself. If the effective resolution's auth type is `entra-id`, this command reports it explicitly as unsupported-by-CLI in its output (informational only here — this read diff --git a/specs/008-cli-github-pat/data-model.md b/specs/008-cli-github-pat/data-model.md index cd20b75..eb27802 100644 --- a/specs/008-cli-github-pat/data-model.md +++ b/specs/008-cli-github-pat/data-model.md @@ -79,8 +79,10 @@ requires core stay free of CLI-specific types. | Concept | Shape | Purpose | |---|---|---| -| Auth-type resolution order | `--auth-type` CLI option → resolved scope's `auth.type` (via its `AuthConfig`) → `'none'` default (`auth: null`) | Implements FR-017's required precedence; gates whether token resolution (below) runs at all for a remote ruleset (FR-018). Computed but inert for local rulesets (FR-019). | -| Token resolution order | (only when resolved auth type is `'github-pat'`) `--token` CLI option → `GITHUB_TOKEN` env var → resolved scope's `auth.githubToken` | Implements FR-004's required precedence, now gated by auth-type resolution. | +| Auth-type resolution order | `--auth-type` CLI option → `authType` key in `.apigrade.json` → resolved scope's `auth.type` (via its `AuthConfig`) → `'none'` default (`auth: null`) | Implements FR-017/FR-026's required precedence; gates whether token resolution (below) runs at all for a remote ruleset (FR-018). Computed but inert for local rulesets (FR-019). | +| Token resolution order | (only when resolved auth type is `'github-pat'`) `--token` CLI option → `token` key in `.apigrade.json` → `GITHUB_TOKEN` env var → resolved scope's `auth.githubToken` | Implements FR-004/FR-026's required precedence, now gated by auth-type resolution. | +| `.apigrade.json` `CliOptions` extension | `authType?: string`, `token?: string`, `url?: string` added to the existing `minGrade`/`rulesetPath`/`format`/`top`/`verbose` fields in `src/cli/config-loader.ts` | Implements FR-024; read with the same type-checked-field pattern as existing keys, merged as `cliOpts.x ?? fileConfig.x` in `src/cli/index.ts` (FR-025). | +| `TokenSource` | `'option' \| 'env' \| 'stored'` on `ResolveAuthResult.tokenSource` (`src/cli/ruleset-resolution.ts`) | Lets `config get-ruleset` correctly report token presence for a file/flag/env-sourced token, not just a persisted `RulesetConfig.auth` — fixes a display gap where a per-request or `.apigrade.json`-resolved ruleset (which always has `resolution.auth === null`, per `resolveRuleset`'s per-request branch) would otherwise show `(no token)` even when `GITHUB_TOKEN` or an `.apigrade.json` token was actually in effect. | | Ignored-option warning | stderr string per ignored option, printed before proceeding, no exit-code effect | FR-020 (`auth-type` resolves to `none` but `--token` supplied) / FR-021 (ruleset source is local but `--auth-type`/`--token` supplied). | | CLI fetch-failure output (human) | stderr string, reason-specific wording | FR-008 human-readable form. | | CLI fetch-failure output (JSON) | `{ error, failureReason, rulesetUrl, scope, message }` printed to stdout when `--format json` | FR-008 machine-readable form; deliberately omits MCP's `recoveryOptions`/`instructions`. | diff --git a/specs/008-cli-github-pat/plan.md b/specs/008-cli-github-pat/plan.md index 7475223..86e12c2 100644 --- a/specs/008-cli-github-pat/plan.md +++ b/specs/008-cli-github-pat/plan.md @@ -24,7 +24,12 @@ input/output (no session scope, no recovery-options payload). The CLI explicitly rejects `entra-id` auth configurations (including via `--auth-type entra-id`) with a clear error rather than attempting or silently ignoring them, and prints a non-fatal warning for any authorisation-related option supplied but rendered moot by -a `none` auth type or a local ruleset source (FR-020/FR-021). +a `none` auth type or a local ruleset source (FR-020/FR-021). The CLI's +`.apigrade.json` general-options file is extended to cover every grading-command +option this feature (and the pre-existing CLI) exposes except `--help`/`--version` +— adding `authType`, `token`, and `url` keys alongside the existing `minGrade`, +`ruleset`, `format`, `top`, `verbose` keys, with the existing "CLI flag overrides +file value" precedence rule applied identically to the new keys (FR-024–FR-028). ## Technical Context @@ -47,7 +52,9 @@ unmodified (assertion-for-assertion) post-refactor; existing suites must also pass unmodified (FR-023/SC-010); new CLI integration tests for `--auth-type`, `--token`, `GITHUB_TOKEN`, `config set-ruleset`/`config get-ruleset`, auth-type/token precedence, ignored-option warnings (FR-020/FR-021), and Entra ID -rejection. +rejection. New unit tests for `.apigrade.json`'s `authType`/`token`/`url` keys +(FR-024–FR-028), covering per-key CLI-flag-overrides-file precedence and the +`authType`/`token` resolution-chain insertion point (FR-026). **Target Platform**: Cross-platform Node.js CLI (Windows/macOS minimum, per Constitution V), local and containerised (Docker) execution. @@ -157,9 +164,14 @@ src/cli/ │ # env fallback, resolve-ruleset call, ignored-option │ # warnings (FR-020/FR-021), fetch-failure error │ # reporting (human + JSON), Entra ID rejection, -│ # 'config' subcommand registration -├── config-loader.ts # UNCHANGED (.apigrade.json general options; separate -│ # from ruleset/auth config) +│ # 'config' subcommand registration; merges +│ # cliOpts.authType/token/url with the corresponding +│ # fileConfig values (CLI flag wins) before they reach +│ # auth-type/token resolution (FR-024–FR-026) +├── config-loader.ts # UPDATED: CliOptions/loadConfig() gain authType, +│ # token, url fields (FR-024), read with the same +│ # type-checked-field pattern already used for +│ # minGrade/ruleset/format/top/verbose └── ruleset-config-cli.ts # NEW: 'config set-ruleset' / 'config get-ruleset' # subcommands (--scope, --ruleset, --auth-type, # --token), thin CLI adapter over core's @@ -167,6 +179,9 @@ src/cli/ tests/ ├── unit/cli-ruleset-config.test.ts # NEW +├── unit/config-loader.test.ts # UPDATED (FR-024–FR-028: new .apigrade.json +│ # keys; extended the pre-existing file rather +│ # than adding a duplicate cli-config-loader.test.ts) └── integration/cli-github-pat.test.ts # NEW packages/api-grade-core/tests/ diff --git a/specs/008-cli-github-pat/quickstart.md b/specs/008-cli-github-pat/quickstart.md index e666984..fa9dbe0 100644 --- a/specs/008-cli-github-pat/quickstart.md +++ b/specs/008-cli-github-pat/quickstart.md @@ -112,7 +112,33 @@ Warning: --token is ignored because the ruleset is a local file; authorisation o Grading proceeds normally using the local file; exit code reflects only the grading result, not the ignored options. -## 8. Entra ID configs are rejected, not attempted +## 8. Configure everything via `.apigrade.json` instead of flags + +```json +{ + "minGrade": "B", + "ruleset": "https://raw.githubusercontent.com/my-org/private-rules/main/ruleset.yaml", + "authType": "github-pat", + "token": "ghp_xxxxxxxxxxxxxxxxxxxx", + "format": "json", + "top": 20, + "verbose": false +} +``` + +```bash +api-grade openapi.yaml +``` + +Behaves identically to supplying `--min-grade B --ruleset --auth-type +github-pat --token ghp_... --format json --top 20` on the command line. Any flag +supplied explicitly still overrides the matching `.apigrade.json` key — e.g. +`api-grade openapi.yaml --token ghp_other` uses `ghp_other`, not the file's +`token`. Committing a real token in a checked-in `.apigrade.json` carries the same +risk as committing any other credential — prefer `GITHUB_TOKEN` or excluding the +file from version control when it holds a token. + +## 9. Entra ID configs are rejected, not attempted If a config file (e.g. one shared from an MCP-server setup) specifies `"auth": { "type": "entra-id", ... }`: diff --git a/specs/008-cli-github-pat/spec.md b/specs/008-cli-github-pat/spec.md index 4ec8c02..d357236 100644 --- a/specs/008-cli-github-pat/spec.md +++ b/specs/008-cli-github-pat/spec.md @@ -15,6 +15,11 @@ - Q: `config set-ruleset --token ` without an explicit `--auth-type` — should it implicitly persist `auth.type: "github-pat"`, or follow the grade command's own `none`-default rule (token rejected/ignored with a warning, not silently stored)? → A: Same as grade command — `--token` alone never implies `github-pat`; without `--auth-type github-pat` the token is not persisted, and a warning is printed explaining it was ignored. - Q: What happens when `--auth-type` is supplied with a value other than `none`, `github-pat`, or `entra-id` (e.g. a typo)? → A: Treated as a `config-invalid` failure — the CLI exits non-zero with an error naming the invalid value, reusing the existing `config-invalid` failure classification (FR-008). +### Session 2026-06-21 (extension: full `.apigrade.json` coverage) + +- Q: `.apigrade.json` should be extended to cover every CLI command-line option besides `--help`/`--version` — does "every option" include the required positional `` argument (i.e. should the spec file itself become optional/defaultable via the config file)? → A: No. `` is the per-invocation operand identifying which file to grade, not a configurable default; it remains a mandatory command-line argument. "Every option" means every named `--flag`, not the positional argument. +- Q: Should storing a GitHub PAT directly in a `token` key trigger a new warning or restriction beyond what already applies to `--token`? → A: No new restriction — `.apigrade.json`'s `token` key is treated exactly like any other config-file-sourced value (FR-007's no-logging guarantee still applies to it once read); documentation calls out the secret-exposure risk of committing it, the same way a hand-edited config file's risk would be called out for any other credential. + ## User Scenarios & Testing *(mandatory)* ### User Story 1 - Grade using a private-repo ruleset from the CLI (Priority: P1) @@ -238,6 +243,62 @@ attempting an Entra ID flow or falling back silently. the unsupported auth type; it prints an ignored-option warning (per FR-021) and proceeds to grade the local file normally. +--- + +### User Story 6 - Configure every grading option via `.apigrade.json` (Priority: P2) + +A developer or CI pipeline wants to fully pre-configure a CLI invocation — +including the new authorisation options this feature introduces — using the +existing `.apigrade.json` general-options file, rather than maintaining shell +scripts or CI-step `args:` lists that repeat the same flags on every run. Today, +`.apigrade.json` supports `minGrade`, `ruleset`, `format`, `top`, and `verbose`, +but `--auth-type`, `--token`, and `--url` have no config-file equivalent — +forcing those three options to always be supplied on the command line even when +every other option is centrally configured. + +**Why this priority**: This closes a gap this feature itself introduces (new +`--auth-type`/`--token` options with no config-file path) and removes the last +reason a fully-configured CI invocation would still need explicit command-line +flags. It is independent of, and lower priority than, User Story 1/2's core +private-ruleset capability, since the options it covers already work when +supplied on the command line — this story only adds a second way to supply them. + +**Independent Test**: Can be tested independently by writing an `.apigrade.json` +file that sets every supported key (including `authType` and `token` against a +stubbed private-ruleset host), running the bare `api-grade ` command +with no other flags, and confirming behavior is identical to the equivalent +invocation with all options supplied explicitly as command-line flags. + +**Acceptance Scenarios**: + +1. **Given** an `.apigrade.json` file setting `authType: "github-pat"` and + `token: ""` alongside an existing `ruleset` URL pointing at a private + repository, **When** the CLI is run with no `--auth-type`/`--token`/`--ruleset` + flags, **Then** the CLI authenticates and fetches the ruleset exactly as if + those values had been supplied as command-line flags. +2. **Given** the same `.apigrade.json` file, **When** the CLI is run with an + explicit command-line flag for one of the file's keys (e.g. `--auth-type`), + **Then** the command-line flag's value is used instead of the file's value, + via the same override mechanism already proven for `minGrade`/`ruleset`/ + `format`/`top`/`verbose` — and which, by code-path symmetry (FR-025), applies + identically to `token` even though a token's content is never directly + observable in CLI output (FR-007). +3. **Given** an `.apigrade.json` file setting `token` but no `authType` (and no + `--auth-type` flag), **When** the CLI is run, **Then** the resolved + authorisation type is `none` (FR-017's default), the file's `token` value is + ignored, and the FR-020 ignored-option warning is printed — identical + treatment to a bare `--token` flag with no `--auth-type`. +4. **Given** an `.apigrade.json` file setting `url` to a non-empty value, **When** + the CLI is run, **Then** the CLI exits 1 with the same "not yet supported" + message produced by an explicit `--url` flag. +5. **Given** an `.apigrade.json` file setting `authType` to a value other than + `none`, `github-pat`, or `entra-id`, **When** the CLI is run, **Then** the CLI + exits non-zero with the same `config-invalid` error an equivalent invalid + `--auth-type` flag value would produce. +6. **Given** no `.apigrade.json` file is present (today's default state), **When** + the CLI is run, **Then** behavior is completely unchanged from before this + story — confirming the extension is additive only. + ### Edge Cases - What happens when the supplied URL points to a public (not private) GitHub @@ -300,6 +361,25 @@ attempting an Entra ID flow or falling back silently. this as a `config-invalid` failure and exit non-zero with an error naming the invalid value, rather than silently defaulting to `none` or attempting to use the value as-is. +- What happens when `.apigrade.json` sets `authType` to an invalid value (the same + kind of typo as the previous edge case, but sourced from the config file rather + than a command-line flag)? The CLI MUST apply the identical `config-invalid` + rejection regardless of source — there is no separate, file-specific validation + path. +- What happens when both `.apigrade.json` and a workspace/global persisted + `RulesetConfig` (`.api-grade/config.json`) specify auth, but `.apigrade.json` only + sets `ruleset` (no `authType`/`token`) while the persisted config sets `auth`? + Per the existing "sources are not merged" rule already documented for `ruleset` + resolution (contracts/cli-options.md), `.apigrade.json`'s `ruleset` value wins + outright as the resolved ruleset path, but `authType`/`token` resolution is a + *separate* resolution chain (FR-026) from ruleset-path resolution — so a persisted + scope's `auth` can still apply on top of an `.apigrade.json`-sourced `ruleset` path + if neither `.apigrade.json` nor a CLI flag sets `authType`/`token`. This mirrors + exactly how an explicit `--ruleset` flag already combines with a persisted scope's + `auth` today (contracts/cli-options.md's existing token-resolution note). +- What happens when `.apigrade.json` sets `url` to an empty string or omits it + entirely? Both are treated as "not set" — identical to omitting `--url` on the + command line — and trigger no rejection. ## Requirements *(mandatory)* @@ -417,6 +497,42 @@ attempting an Entra ID flow or falling back silently. packages' existing automated test suites MUST pass unmodified (assertion-for- assertion) after the core-package refactor, mirroring the no-regression requirement already placed on the MCP server (FR-002). +- **FR-024**: `.apigrade.json` MUST support a config key for every command-line + option exposed by the `api-grade ` grading command except `--help` and + `--version` (which are meta/process-control options with no persisted-config + equivalent). This adds `authType` (↔ `--auth-type`), `token` (↔ `--token`), and + `url` (↔ `--url`) to the existing `minGrade`, `ruleset`, `format`, `top`, and + `verbose` keys. The required positional `` argument is excluded — it + remains a mandatory command-line argument, not a configurable default (per the + 2026-06-21 extension clarification). +- **FR-025**: For each key covered by FR-024, the existing "an explicit + command-line flag overrides the corresponding `.apigrade.json` value" precedence + rule (already in effect for `minGrade`/`ruleset`/`format`/`top`/`verbose`) MUST + apply identically to `authType`, `token`, and `url`. +- **FR-026**: An `.apigrade.json` `authType` or `token` value MUST be inserted into + the existing authorisation-type resolution order (FR-017) and token resolution + order (FR-004) at the same precedence position the corresponding command-line + flag would occupy if supplied directly — above the `GITHUB_TOKEN` environment + variable and above any persisted workspace/global `auth` configuration, but below + an explicit `--auth-type`/`--token` command-line flag. This is a resolution chain + independent of `.apigrade.json`'s `ruleset` key (which has its own, pre-existing + resolution position): the two are not coupled, so an `.apigrade.json`-sourced + `ruleset` can still combine with a persisted scope's `auth` when `.apigrade.json` + sets no `authType`/`token` of its own. +- **FR-027**: An `.apigrade.json` `url` value MUST trigger the exact same + "reserved, not yet supported" rejection (CLI exits 1 before any other processing) + that an explicit `--url` command-line flag already triggers, applied identically + regardless of which source supplied the value. +- **FR-028**: An `.apigrade.json` `authType` value that is not one of `none`, + `github-pat`, or `entra-id` MUST be treated as the same `config-invalid` failure + (FR-008/FR-017) an equivalent invalid `--auth-type` command-line value would + produce, regardless of source. FR-007's no-logging guarantee continues to apply + unchanged to a `token` value read from `.apigrade.json` — reading it from a file + instead of a flag introduces no new exposure in CLI output, though the file + itself, if committed to version control, carries the same secret-exposure risk as + committing any other credential (documentation MUST call this out and recommend + `GITHUB_TOKEN` or excluding `.apigrade.json` from version control when it holds a + token). ### Key Entities @@ -438,8 +554,13 @@ attempting an Entra ID flow or falling back silently. - **Authorisation Type**: The resolved value of `auth.type` (`none`, `github-pat`, or `entra-id`) governing whether and how a *remote* ruleset fetch is authenticated, resolvable from (in order) the `--auth-type` command-line option, - persisted workspace/global configuration, or the `none` default. Always ignored - for local (file-path) ruleset sources. + the `.apigrade.json` `authType` key, persisted workspace/global configuration, or + the `none` default. Always ignored for local (file-path) ruleset sources. +- **General CLI Options File (`.apigrade.json`)**: A hand-edited, CLI-only JSON + file (unrelated to and not read by the MCP server) supplying default values for + every grading-command option except `--help`/`--version`: `minGrade`, `ruleset`, + `authType`, `token`, `format`, `top`, `verbose`, `url`. An explicit command-line + flag always overrides the corresponding key. ## Success Criteria *(mandatory)* @@ -483,6 +604,23 @@ attempting an Entra ID flow or falling back silently. `backstage-plugin-api-grade-backend` automated tests pass unmodified, and both packages build successfully, after the core-package refactor — confirming zero functionality change for existing Backstage plugin consumers. +- **SC-011**: A CI pipeline can fully configure a grading invocation — including + `authType`/`token` against a private ruleset — using only an `.apigrade.json` + file and the bare `api-grade ` command (zero option flags), verified + by an automated test confirming the file-sourced `authType`/`token` reach the + same fetch-attempt path (no ignored-option warning, no "authentication + required" no-token error) that the equivalent `--auth-type`/`--token` flags + would produce. Literal request-level verification (e.g. the exact token + transmitted) is not asserted, since FR-007 prohibits any token value from + appearing in observable CLI output. +- **SC-012**: 100% of `.apigrade.json` keys added by this feature (`authType`, + `url`) are overridden by their corresponding command-line flag when both are + present, verified across the CLI's automated test suite, with zero regression + to the pre-existing override behavior of `minGrade`/`ruleset`/`format`/`top`/ + `verbose`. `token`'s override is verified by code-path symmetry — it uses the + identical `cliOpts.token ?? fileConfig.token` merge pattern as `authType`/`url` + (FR-025) — rather than by a literal token-content assertion, since FR-007 + prohibits the token value from appearing in observable output. ## Assumptions @@ -519,3 +657,13 @@ attempting an Entra ID flow or falling back silently. packages; "no behavioral change" for those packages is verified the same way as for the MCP server — via their existing automated test suites passing unmodified — and any test gaps in those suites are likewise out of scope to backfill here. +- "Every CLI command-line argument" (User Story 6 / FR-024) means every named + `--flag` of the `api-grade ` command, not the required positional + `` argument, which is excluded by design (2026-06-21 extension + clarification) — it identifies the file being graded for that one invocation and + is not a sensible default to centrally configure. +- `.apigrade.json` remains a hand-edited, CLI-only file, unread by `api-grade-mcp` + — User Story 6 only widens the set of grading-command options it can supply + defaults for; it does not change the file's location, format, or the fact that + it is unrelated to the workspace/global `RulesetConfig` persisted by `config + set-ruleset` / the MCP server's `set-ruleset-config` tool. diff --git a/specs/008-cli-github-pat/tasks.md b/specs/008-cli-github-pat/tasks.md index 0d758b8..78f6da2 100644 --- a/specs/008-cli-github-pat/tasks.md +++ b/specs/008-cli-github-pat/tasks.md @@ -29,7 +29,7 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src **Purpose**: Prepare the core package to receive the moved Entra ID logic before any extraction happens. -- [ ] T001 Add `@azure/msal-node` (`^2.16.2`, matching `packages/api-grade-mcp/package.json`'s current version) to the `dependencies` of `packages/api-grade-core/package.json`, since `auth/entra.ts` moves into core in the next phase and FR-014 requires core to declare its own runtime dependencies rather than relying on a consumer's +- [X] T001 Add `@azure/msal-node` (`^2.16.2`, matching `packages/api-grade-mcp/package.json`'s current version) to the `dependencies` of `packages/api-grade-core/package.json`, since `auth/entra.ts` moves into core in the next phase and FR-014 requires core to declare its own runtime dependencies rather than relying on a consumer's --- @@ -39,30 +39,30 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src **⚠️ CRITICAL**: No user story work can begin until this phase is complete and T024/T025 pass. -- [ ] T002 Extend `packages/api-grade-core/src/types.ts` with `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState` — copied verbatim (no field added/removed/renamed) from `packages/api-grade-mcp/src/types.ts`. Add only; do not touch any existing core type. -- [ ] T003 [P] Create `packages/api-grade-core/src/auth/github.ts` containing `fetchRulesetContent`, `fetchRulesetWithGithubPat`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS` — moved verbatim from `packages/api-grade-mcp/src/auth/github.ts` -- [ ] T004 [P] Create `packages/api-grade-core/src/auth/entra.ts` containing `acquireEntraToken`, `EntraAuthRequired` — moved verbatim from `packages/api-grade-mcp/src/auth/entra.ts` -- [ ] T005 [P] Create `packages/api-grade-core/src/config/ruleset-config.ts` containing `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError` — moved verbatim from `packages/api-grade-mcp/src/config/ruleset-config.ts`, importing `RulesetConfig` from `../types.js` (T002) -- [ ] T006 [P] Create `packages/api-grade-core/src/config/resolve-ruleset.ts` containing `resolveRuleset` — moved verbatim from `packages/api-grade-mcp/src/config/resolve-ruleset.ts`, importing types from `../types.js` (T002) -- [ ] T007 Extend `packages/api-grade-core/src/index.ts` to export everything added in T002–T006 (types: `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState`; values: `fetchRulesetContent`, `fetchRulesetWithGithubPat`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS`, `acquireEntraToken`, `EntraAuthRequired`, `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError`, `resolveRuleset`) — append only; no existing export line is modified or removed (FR-022) (depends on T002–T006) -- [ ] T008 [P] Replace the body of `packages/api-grade-mcp/src/auth/github.ts` with a re-export shim (`export { fetchRulesetContent, fetchRulesetWithGithubPat, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS, RETRY_FETCH_TIMEOUT_MS } from '@dawmatt/api-grade-core';`) so `packages/api-grade-mcp/tests/unit/github.test.ts`'s existing `../../src/auth/github.js` import keeps resolving unmodified (depends on T007) -- [ ] T009 [P] Replace the body of `packages/api-grade-mcp/src/auth/entra.ts` with a re-export shim (`export { acquireEntraToken, EntraAuthRequired } from '@dawmatt/api-grade-core';`) (depends on T007) -- [ ] T010 [P] Replace the body of `packages/api-grade-mcp/src/config/ruleset-config.ts` with a re-export shim for `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError` from `@dawmatt/api-grade-core`, so `packages/api-grade-mcp/tests/integration/set-ruleset-config.test.ts` and `get-ruleset-config.test.ts`'s existing imports keep resolving unmodified (depends on T007) -- [ ] T011 [P] Replace the body of `packages/api-grade-mcp/src/config/resolve-ruleset.ts` with a re-export shim for `resolveRuleset` from `@dawmatt/api-grade-core`, so `packages/api-grade-mcp/tests/unit/resolve-ruleset.test.ts`'s existing import keeps resolving unmodified (depends on T007) -- [ ] T012 Trim `packages/api-grade-mcp/src/types.ts` to keep only `RecoveryOptionId` and `RecoveryOption`, adding re-exports of `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState` from `@dawmatt/api-grade-core`, so existing unmodified imports of these types from `../types.js` (in `packages/api-grade-mcp/tests/unit/ruleset-config.test.ts` and `resolve-ruleset.test.ts`) keep resolving (depends on T007) -- [ ] T013 [P] Update `packages/api-grade-mcp/src/tools/grade.ts` to import `fetchRulesetContent`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS`, `EntraAuthRequired`, `acquireEntraToken`, `resolveRuleset`, `loadWorkspaceConfig`, `loadGlobalConfig`, and the `SessionState`/`AuthConfig` types directly from `@dawmatt/api-grade-core` instead of relative `../auth/...`/`../config/...`/`../types.js` paths — no change to tool logic, schema, or output shape (depends on T007) -- [ ] T014 [P] Update `packages/api-grade-mcp/src/tools/grade-detailed.ts` the same way as T013 (depends on T007) -- [ ] T015 [P] Update `packages/api-grade-mcp/src/tools/quick-fixes-only.ts` the same way as T013 (depends on T007) -- [ ] T016 [P] Update `packages/api-grade-mcp/src/tools/assert-grade.ts` the same way as T013 (depends on T007) -- [ ] T017 [P] Update `packages/api-grade-mcp/src/tools/set-ruleset-config.ts` the same way as T013 (depends on T007) -- [ ] T018 [P] Update `packages/api-grade-mcp/src/tools/get-ruleset-config.ts` the same way as T013 (depends on T007) -- [ ] T019 [P] Create `packages/api-grade-core/tests/unit/auth-github.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/github.test.ts` to import from core's own `../../src/auth/github.js` (leave the MCP original file byte-for-byte unmodified); retain any existing no-ref/default-branch test case from the original file to preserve FR-013 coverage (depends on T003) -- [ ] T020 [P] Create `packages/api-grade-core/tests/unit/ruleset-config.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/ruleset-config.test.ts` to import from core's own `../../src/config/ruleset-config.js` and `../../src/types.js` (depends on T005) -- [ ] T021 [P] Create `packages/api-grade-core/tests/unit/resolve-ruleset.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/resolve-ruleset.test.ts` to import from core's own `../../src/config/resolve-ruleset.js` and `../../src/types.js` (depends on T006) -- [ ] T022 Remove the now-unused `@azure/msal-node` dependency from `packages/api-grade-mcp/package.json` (entra.ts now re-exports from core, which declares the dependency itself per T001); run install and confirm it still resolves (depends on T009) -- [ ] T023 Run `npm run build` and `npm run typecheck` for `packages/api-grade-core` and `packages/api-grade-mcp`; fix any compile error surfaced by the restructuring without editing any test assertion; spot-check `packages/api-grade-core/src/index.ts`'s exports for any leaked MCP-only (`Recovery*`) or CLI-only symbol per FR-014 (depends on T008–T022) -- [ ] T024 Run `npm test --workspace=packages/api-grade-mcp` and confirm 100% of existing tests pass with zero edits to any test file (FR-002/SC-003 gate) (depends on T023) -- [ ] T025 Run `npm test --workspace=packages/api-grade-core` and confirm the new tests from T019–T021 pass (depends on T023) +- [X] T002 Extend `packages/api-grade-core/src/types.ts` with `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState` — copied verbatim (no field added/removed/renamed) from `packages/api-grade-mcp/src/types.ts`. Add only; do not touch any existing core type. +- [X] T003 [P] Create `packages/api-grade-core/src/auth/github.ts` containing `fetchRulesetContent`, `fetchRulesetWithGithubPat`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS` — moved verbatim from `packages/api-grade-mcp/src/auth/github.ts` +- [X] T004 [P] Create `packages/api-grade-core/src/auth/entra.ts` containing `acquireEntraToken`, `EntraAuthRequired` — moved verbatim from `packages/api-grade-mcp/src/auth/entra.ts` +- [X] T005 [P] Create `packages/api-grade-core/src/config/ruleset-config.ts` containing `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError` — moved verbatim from `packages/api-grade-mcp/src/config/ruleset-config.ts`, importing `RulesetConfig` from `../types.js` (T002) +- [X] T006 [P] Create `packages/api-grade-core/src/config/resolve-ruleset.ts` containing `resolveRuleset` — moved verbatim from `packages/api-grade-mcp/src/config/resolve-ruleset.ts`, importing types from `../types.js` (T002) +- [X] T007 Extend `packages/api-grade-core/src/index.ts` to export everything added in T002–T006 (types: `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState`; values: `fetchRulesetContent`, `fetchRulesetWithGithubPat`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS`, `acquireEntraToken`, `EntraAuthRequired`, `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError`, `resolveRuleset`) — append only; no existing export line is modified or removed (FR-022) (depends on T002–T006) +- [X] T008 [P] Replace the body of `packages/api-grade-mcp/src/auth/github.ts` with a re-export shim (`export { fetchRulesetContent, fetchRulesetWithGithubPat, RulesetAuthError, INITIAL_FETCH_TIMEOUT_MS, RETRY_FETCH_TIMEOUT_MS } from '@dawmatt/api-grade-core';`) so `packages/api-grade-mcp/tests/unit/github.test.ts`'s existing `../../src/auth/github.js` import keeps resolving unmodified (depends on T007) +- [X] T009 [P] Replace the body of `packages/api-grade-mcp/src/auth/entra.ts` with a re-export shim (`export { acquireEntraToken, EntraAuthRequired } from '@dawmatt/api-grade-core';`) (depends on T007) +- [X] T010 [P] Replace the body of `packages/api-grade-mcp/src/config/ruleset-config.ts` with a re-export shim for `getWorkspaceConfigPath`, `getGlobalConfigPath`, `loadWorkspaceConfig`, `loadGlobalConfig`, `saveWorkspaceConfig`, `saveGlobalConfig`, `ConfigWriteError` from `@dawmatt/api-grade-core`, so `packages/api-grade-mcp/tests/integration/set-ruleset-config.test.ts` and `get-ruleset-config.test.ts`'s existing imports keep resolving unmodified (depends on T007) +- [X] T011 [P] Replace the body of `packages/api-grade-mcp/src/config/resolve-ruleset.ts` with a re-export shim for `resolveRuleset` from `@dawmatt/api-grade-core`, so `packages/api-grade-mcp/tests/unit/resolve-ruleset.test.ts`'s existing import keeps resolving unmodified (depends on T007) +- [X] T012 Trim `packages/api-grade-mcp/src/types.ts` to keep only `RecoveryOptionId` and `RecoveryOption`, adding re-exports of `AuthConfig`, `RulesetConfig`, `RulesetScope`, `RulesetResolution`, `SessionState` from `@dawmatt/api-grade-core`, so existing unmodified imports of these types from `../types.js` (in `packages/api-grade-mcp/tests/unit/ruleset-config.test.ts` and `resolve-ruleset.test.ts`) keep resolving (depends on T007) +- [X] T013 [P] Update `packages/api-grade-mcp/src/tools/grade.ts` to import `fetchRulesetContent`, `RulesetAuthError`, `INITIAL_FETCH_TIMEOUT_MS`, `RETRY_FETCH_TIMEOUT_MS`, `EntraAuthRequired`, `acquireEntraToken`, `resolveRuleset`, `loadWorkspaceConfig`, `loadGlobalConfig`, and the `SessionState`/`AuthConfig` types directly from `@dawmatt/api-grade-core` instead of relative `../auth/...`/`../config/...`/`../types.js` paths — no change to tool logic, schema, or output shape (depends on T007) +- [X] T014 [P] Update `packages/api-grade-mcp/src/tools/grade-detailed.ts` the same way as T013 (depends on T007) +- [X] T015 [P] Update `packages/api-grade-mcp/src/tools/quick-fixes-only.ts` the same way as T013 (depends on T007) +- [X] T016 [P] Update `packages/api-grade-mcp/src/tools/assert-grade.ts` the same way as T013 (depends on T007) +- [X] T017 [P] Update `packages/api-grade-mcp/src/tools/set-ruleset-config.ts` the same way as T013 (depends on T007) +- [X] T018 [P] Update `packages/api-grade-mcp/src/tools/get-ruleset-config.ts` the same way as T013 (depends on T007) +- [X] T019 [P] Create `packages/api-grade-core/tests/unit/auth-github.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/github.test.ts` to import from core's own `../../src/auth/github.js` (leave the MCP original file byte-for-byte unmodified); retain any existing no-ref/default-branch test case from the original file to preserve FR-013 coverage (depends on T003) +- [X] T020 [P] Create `packages/api-grade-core/tests/unit/ruleset-config.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/ruleset-config.test.ts` to import from core's own `../../src/config/ruleset-config.js` and `../../src/types.js` (depends on T005) +- [X] T021 [P] Create `packages/api-grade-core/tests/unit/resolve-ruleset.test.ts`, adapted from `packages/api-grade-mcp/tests/unit/resolve-ruleset.test.ts` to import from core's own `../../src/config/resolve-ruleset.js` and `../../src/types.js` (depends on T006) +- [X] T022 Remove the now-unused `@azure/msal-node` dependency from `packages/api-grade-mcp/package.json` (entra.ts now re-exports from core, which declares the dependency itself per T001); run install and confirm it still resolves (depends on T009) +- [X] T023 Run `npm run build` and `npm run typecheck` for `packages/api-grade-core` and `packages/api-grade-mcp`; fix any compile error surfaced by the restructuring without editing any test assertion; spot-check `packages/api-grade-core/src/index.ts`'s exports for any leaked MCP-only (`Recovery*`) or CLI-only symbol per FR-014 (depends on T008–T022) +- [X] T024 Run `npm test --workspace=packages/api-grade-mcp` and confirm 100% of existing tests pass with zero edits to any test file (FR-002/SC-003 gate) (depends on T023) +- [X] T025 Run `npm test --workspace=packages/api-grade-core` and confirm the new tests from T019–T021 pass (depends on T023) **Checkpoint**: Core now exposes the shared auth/config implementation; MCP server behavior is provably unchanged. CLI implementation can begin. @@ -78,24 +78,24 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src > Write these tests FIRST, ensure they FAIL before implementation. -- [ ] T026 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--ruleset --auth-type github-pat --token ` against a local stub GitHub-like HTTP server fetches the ruleset and grades successfully — covers Acceptance Scenario 1 -- [ ] T027 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: the same private-ruleset auth path graded against an AsyncAPI fixture (`tests/fixtures/asyncapi/streetlights-api.yaml`) succeeds identically to the OpenAPI case — covers FR-011 (multi-format uniformity) -- [ ] T028 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--auth-type github-pat` with no token (or a token the stub server rejects with 401/403) exits 1 with an authentication-required/auth-failed message, and the supplied/missing token value never appears in stdout or stderr — covers Acceptance Scenario 2 & FR-007/SC-005 -- [ ] T029 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `GITHUB_TOKEN` env var is used automatically when `--auth-type github-pat` is set and no `--token` is supplied — covers Acceptance Scenario 3 -- [ ] T030 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: no `--auth-type` (defaults to `none`) with `--token` supplied prints `Warning: --token is ignored because the authorisation type is 'none'...` and the fetch proceeds unauthenticated and fails against the private stub — covers Acceptance Scenario 4 & FR-020/SC-009 -- [ ] T031 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--ruleset ./local-file.yaml --auth-type github-pat --token ` prints one warning per ignored option ("ruleset is a local file...") and grades successfully using the local file — covers Acceptance Scenario 5 & FR-021/SC-009 +- [X] T026 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--ruleset --auth-type github-pat --token ` against a local stub GitHub-like HTTP server fetches the ruleset and grades successfully — covers Acceptance Scenario 1 +- [X] T027 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: the same private-ruleset auth path graded against an AsyncAPI fixture (`tests/fixtures/asyncapi/streetlights-api.yaml`) succeeds identically to the OpenAPI case — covers FR-011 (multi-format uniformity) +- [X] T028 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--auth-type github-pat` with no token (or a token the stub server rejects with 401/403) exits 1 with an authentication-required/auth-failed message, and the supplied/missing token value never appears in stdout or stderr — covers Acceptance Scenario 2 & FR-003/FR-007/SC-004/SC-005 +- [X] T029 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `GITHUB_TOKEN` env var is used automatically when `--auth-type github-pat` is set and no `--token` is supplied — covers Acceptance Scenario 3 +- [X] T030 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: no `--auth-type` (defaults to `none`) with `--token` supplied prints `Warning: --token is ignored because the authorisation type is 'none'...` and the fetch proceeds unauthenticated and fails against the private stub — covers Acceptance Scenario 4 & FR-020/SC-008/SC-009 +- [X] T031 [P] [US1] Integration test in `tests/integration/cli-github-pat.test.ts`: `--ruleset ./local-file.yaml --auth-type github-pat --token ` prints one warning per ignored option ("ruleset is a local file...") and grades successfully using the local file — covers Acceptance Scenario 5 & FR-021/SC-009 ### Implementation for User Story 1 -- [ ] T032 [US1] Add `--auth-type ` and `--token ` option declarations to the `api-grade ` command in `src/cli/index.ts` (depends on Foundational) -- [ ] T033 [US1] Implement the auth-type resolution step in `src/cli/index.ts`: `--auth-type` CLI option → resolved scope's `auth?.type` → `'none'` default; computed unconditionally per FR-017 (even for a local ruleset, since the warning logic needs it) (depends on T032, Foundational) -- [ ] T034 [US1] Implement ruleset path resolution in `src/cli/index.ts` using core's `resolveRuleset(rulesetOption, sessionState, workspaceConfig, globalConfig)` with a fresh inert `SessionState` (`{ defaultRuleset: null, sessionRulesetOverride: null }`) constructed per invocation (per research.md R2) (depends on Foundational) -- [ ] T035 [US1] Implement token resolution in `src/cli/index.ts`, gated to run only when the resolved auth type (T033) is `'github-pat'`: `--token` → `GITHUB_TOKEN` env var → resolved scope's `auth.githubToken` (FR-004/FR-018) (depends on T033) -- [ ] T036 [US1] Implement the ignored-option warning helper in `src/cli/index.ts`: one `console.warn` line per ignored option for (a) auth type `none` with `--token` supplied (FR-020) and (b) local ruleset source with `--auth-type`/`--token` supplied (FR-021), using the local-ruleset wording when both conditions apply (per contracts/cli-options.md) (depends on T033, T034, T035) -- [ ] T037 [US1] Wire the remote-fetch path into the grade action handler in `src/cli/index.ts`: when the resolved ruleset path is an http(s) URL, call core's `fetchRulesetContent` with the resolved token (`'github-pat'`) or no token (`'none'`), write the fetched content to a temp file (mirroring MCP `grade.ts`'s temp-file pattern), and pass that path to `GradeEngine.grade()` (FR-006) (depends on T034, T035) -- [ ] T038 [US1] Implement CLI fetch-failure reporting in `src/cli/index.ts`: catch `RulesetAuthError` (and a CLI-local `config-invalid` case for an unusable `github-pat` token), map to a human stderr message (`--format human`) or `{ error, failureReason, rulesetUrl, scope, message }` JSON object on stdout (`--format json`) per contracts/cli-options.md's `error` code mapping, then `process.exit(1)` with no grading and no fallback to the built-in ruleset (FR-008/FR-009/FR-010) (depends on T037) -- [ ] T039 [US1] Implement the unsupported-auth-type rejection in `src/cli/index.ts`: if the resolved auth type (T033) is `'entra-id'`, print the unsupported-authentication-type stderr error and `process.exit(1)` before any fetch attempt or other option is applied (FR-015/FR-016) (depends on T033) -- [ ] T040 [US1] Audit every `console.log`/`console.error`/`console.warn` call site added in T032–T039 to confirm no resolved token value or stored secret field is ever printed, including under `--verbose` (FR-007/SC-005) +- [X] T032 [US1] Add `--auth-type ` and `--token ` option declarations to the `api-grade ` command in `src/cli/index.ts` (FR-003) (depends on Foundational) +- [X] T033 [US1] Implement the auth-type resolution step in `src/cli/index.ts`: `--auth-type` CLI option → resolved scope's `auth?.type` → `'none'` default; computed unconditionally per FR-017 (even for a local ruleset, since the warning logic needs it) (depends on T032, Foundational) +- [X] T034 [US1] Implement ruleset path resolution in `src/cli/index.ts` using core's `resolveRuleset(rulesetOption, sessionState, workspaceConfig, globalConfig)` with a fresh inert `SessionState` (`{ defaultRuleset: null, sessionRulesetOverride: null }`) constructed per invocation (per research.md R2) (depends on Foundational) +- [X] T035 [US1] Implement token resolution in `src/cli/index.ts`, gated to run only when the resolved auth type (T033) is `'github-pat'`: `--token` → `GITHUB_TOKEN` env var → resolved scope's `auth.githubToken` (FR-004/FR-018) (depends on T033) +- [X] T036 [US1] Implement the ignored-option warning helper in `src/cli/index.ts`: one `console.warn` line per ignored option for (a) auth type `none` with `--token` supplied (FR-020) and (b) local ruleset source with `--auth-type`/`--token` supplied (FR-021), using the local-ruleset wording when both conditions apply (per contracts/cli-options.md) (depends on T033, T034, T035) +- [X] T037 [US1] Wire the remote-fetch path into the grade action handler in `src/cli/index.ts`: when the resolved ruleset path is an http(s) URL, call core's `fetchRulesetContent` with the resolved token (`'github-pat'`) or no token (`'none'`), write the fetched content to a temp file (mirroring MCP `grade.ts`'s temp-file pattern), and pass that path to `GradeEngine.grade()` (FR-003/FR-006) (depends on T034, T035) +- [X] T038 [US1] Implement CLI fetch-failure reporting in `src/cli/index.ts`: catch `RulesetAuthError` (and a CLI-local `config-invalid` case for an unusable `github-pat` token), map to a human stderr message (`--format human`) or `{ error, failureReason, rulesetUrl, scope, message }` JSON object on stdout (`--format json`) per contracts/cli-options.md's `error` code mapping, then `process.exit(1)` with no grading and no fallback to the built-in ruleset (FR-008/FR-009/FR-010) (depends on T037) +- [X] T039 [US1] Implement the unsupported-auth-type rejection in `src/cli/index.ts`: if the resolved auth type (T033) is `'entra-id'`, print the unsupported-authentication-type stderr error and `process.exit(1)` before any fetch attempt or other option is applied (FR-015/FR-016) (depends on T033) +- [X] T040 [US1] Audit every `console.log`/`console.error`/`console.warn` call site added in T032–T039 to confirm no resolved token value or stored secret field is ever printed, including under `--verbose` (FR-007/SC-005) **Checkpoint**: User Story 1 is fully functional and independently testable — the CLI grades against a private GitHub-hosted ruleset with correct auth-type gating, warnings, and failure reporting. @@ -109,18 +109,18 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src ### Tests for User Story 2 -- [ ] T041 [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config set-ruleset --scope workspace --ruleset --auth-type github-pat --token ` writes `.api-grade/config.json` with the expected `RulesetConfig`/`AuthConfig` shape, and omitting `--ruleset` clears the default at that scope -- [ ] T041a [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config set-ruleset --scope workspace --ruleset --token ` (no `--auth-type`) does NOT persist `auth.type: "github-pat"` or the token — the written config's `auth` is absent/`null` (equivalent to `none`) — and the command prints an FR-020 ignored-option warning for `--token`; same behavior for `--auth-type none --token ` explicitly — covers spec Clarifications Q1 (2026-06-21) -- [ ] T042 [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config get-ruleset` reports the effective scope/path/auth type plus per-scope values, redacting token values to `(token configured)`/`(no token)`/`(from GITHUB_TOKEN)` in both human and JSON output — never the raw token -- [ ] T043 [P] [US2] Integration test in `tests/integration/cli-github-pat.test.ts`: grading with no `--ruleset` uses a workspace-configured default; falls back to a global default when no workspace config exists; an explicit per-invocation `--ruleset` overrides both — covers Acceptance Scenarios 1–4 -- [ ] T044 [P] [US2] Integration test in `tests/integration/cli-github-pat.test.ts`: `GITHUB_TOKEN` is used automatically for a configured default when the resolved auth type is `github-pat` and no token-related option/stored token exists; a persisted `auth.type: "github-pat"` with a stored token behaves as if `--auth-type github-pat` were passed explicitly; a default with no `auth` field resolves to `none` — covers Acceptance Scenarios 5–7 +- [X] T041 [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config set-ruleset --scope workspace --ruleset --auth-type github-pat --token ` writes `.api-grade/config.json` with the expected `RulesetConfig`/`AuthConfig` shape, and omitting `--ruleset` clears the default at that scope +- [X] T041a [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config set-ruleset --scope workspace --ruleset --token ` (no `--auth-type`) does NOT persist `auth.type: "github-pat"` or the token — the written config's `auth` is absent/`null` (equivalent to `none`) — and the command prints an FR-020 ignored-option warning for `--token`; same behavior for `--auth-type none --token ` explicitly — covers spec Clarifications Q1 (2026-06-21) +- [X] T042 [P] [US2] Unit test in `tests/unit/cli-ruleset-config.test.ts`: `config get-ruleset` reports the effective scope/path/auth type plus per-scope values, redacting token values to `(token configured)`/`(no token)`/`(from GITHUB_TOKEN)` in both human and JSON output — never the raw token +- [X] T043 [P] [US2] Integration test in `tests/integration/cli-github-pat.test.ts`: grading with no `--ruleset` uses a workspace-configured default; falls back to a global default when no workspace config exists; an explicit per-invocation `--ruleset` overrides both — covers Acceptance Scenarios 1–4 +- [X] T044 [P] [US2] Integration test in `tests/integration/cli-github-pat.test.ts`: `GITHUB_TOKEN` is used automatically for a configured default when the resolved auth type is `github-pat` and no token-related option/stored token exists; a persisted `auth.type: "github-pat"` with a stored token behaves as if `--auth-type github-pat` were passed explicitly; a default with no `auth` field resolves to `none` — covers Acceptance Scenarios 5–7 ### Implementation for User Story 2 -- [ ] T045 [US2] Create `src/cli/ruleset-config-cli.ts` implementing `config set-ruleset`: `--scope ` (required), `--ruleset ` (optional, omit clears), `--auth-type ` (optional; any other value, e.g. a typo, is a `config-invalid` failure per FR-017/contracts/cli-options.md), `--token ` (optional); writes via core's `saveWorkspaceConfig`/`saveGlobalConfig`; rejects `--auth-type entra-id` the same way as the grade command (FR-005/FR-015). Per spec Clarifications Q1 (2026-06-21): `--token` supplied without `--auth-type github-pat` MUST NOT persist `auth.type: "github-pat"` — the resolved type is `none`, the token is not written, and an FR-020 ignored-option warning is printed instead (depends on Foundational) -- [ ] T046 [US2] Implement `config get-ruleset` in `src/cli/ruleset-config-cli.ts`: loads workspace+global config via core's loaders, resolves the effective ruleset via `resolveRuleset` with an inert `SessionState`, and prints human or JSON output per contracts/cli-options.md (token presence only, never the value) (depends on T045) -- [ ] T047 [US2] Register the `config` subcommand (`set-ruleset`, `get-ruleset`) on the main `commander` program in `src/cli/index.ts` (depends on T045, T046) -- [ ] T048 [US2] Extract the auth-type/token/ignored-warning resolution logic built in User Story 1 (T033/T035/T036) into a shared helper module consumed by the grade action handler, `config set-ruleset`, and `config get-ruleset`, so the `none`-default/ignored-warning rule (including Q1's `config set-ruleset --token` case) can never diverge across the three surfaces (depends on T036, T045, T046) +- [X] T045 [US2] Create `src/cli/ruleset-config-cli.ts` implementing `config set-ruleset`: `--scope ` (required), `--ruleset ` (optional, omit clears), `--auth-type ` (optional; any other value, e.g. a typo, is a `config-invalid` failure per FR-017/contracts/cli-options.md), `--token ` (optional); writes via core's `saveWorkspaceConfig`/`saveGlobalConfig`; rejects `--auth-type entra-id` the same way as the grade command (FR-005/FR-015). Per spec Clarifications Q1 (2026-06-21): `--token` supplied without `--auth-type github-pat` MUST NOT persist `auth.type: "github-pat"` — the resolved type is `none`, the token is not written, and an FR-020 ignored-option warning is printed instead (depends on Foundational) +- [X] T046 [US2] Implement `config get-ruleset` in `src/cli/ruleset-config-cli.ts`: loads workspace+global config via core's loaders, resolves the effective ruleset via `resolveRuleset` with an inert `SessionState`, and prints human or JSON output per contracts/cli-options.md (token presence only, never the value) (depends on T045) +- [X] T047 [US2] Register the `config` subcommand (`set-ruleset`, `get-ruleset`) on the main `commander` program in `src/cli/index.ts` (depends on T045, T046) +- [X] T048 [US2] Extract the auth-type/token/ignored-warning resolution logic built in User Story 1 (T033/T035/T036) into a shared helper module consumed by the grade action handler, `config set-ruleset`, and `config get-ruleset`, so the `none`-default/ignored-warning rule (including Q1's `config set-ruleset --token` case) can never diverge across the three surfaces (depends on T036, T045, T046) **Checkpoint**: User Stories 1 and 2 both work independently — persistent workspace/global ruleset+auth defaults are configurable and consumed by every subsequent grading invocation. @@ -132,9 +132,9 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src **Independent Test**: Run the MCP server's existing automated test suite, unmodified, and confirm 100% pass. -- [ ] T049 [US3] Re-run `npm test --workspace=packages/api-grade-mcp` after Phases 3–4 land, confirming zero MCP test file edits and 100% pass rate (SC-003) — guards against any later CLI-focused task accidentally touching shared core behavior -- [ ] T050 [P] [US3] Run `git diff --stat` for `packages/api-grade-mcp/tests/` against the pre-feature branch point and confirm no MCP test file was added, removed, or edited by this feature — covers Acceptance Scenario 1 -- [ ] T051 [P] [US3] Using the existing MCP integration tests, spot-check one `auth-failed`, one `not-found`, and one `network-unreachable` `grade-api` response to confirm error code, message text, and `recoveryOptions` payload are byte-identical to pre-refactor behavior — covers Acceptance Scenario 2 +- [X] T049 [US3] Re-run `npm test --workspace=packages/api-grade-mcp` after Phases 3–4 land, confirming zero MCP test file edits and 100% pass rate (SC-003) — guards against any later CLI-focused task accidentally touching shared core behavior +- [X] T050 [P] [US3] Run `git diff --stat` for `packages/api-grade-mcp/tests/` against the pre-feature branch point and confirm no MCP test file was added, removed, or edited by this feature — covers Acceptance Scenario 1 +- [X] T051 [P] [US3] Using the existing MCP integration tests, spot-check one `auth-failed`, one `not-found`, and one `network-unreachable` `grade-api` response to confirm error code, message text, and `recoveryOptions` payload are byte-identical to pre-refactor behavior — covers Acceptance Scenario 2 **Checkpoint**: MCP server's tool contracts and error responses are confirmed unchanged after the full feature lands. @@ -146,9 +146,9 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src **Independent Test**: Run both packages' existing build and test suites, unmodified, against the post-refactor core and confirm both succeed. -- [ ] T052 [US4] Run `npm run build --workspace=packages/backstage-plugin-api-grade-backend --workspace=packages/backstage-plugin-api-grade` and confirm both build successfully against the post-refactor core (FR-022/SC-010) -- [ ] T053 [P] [US4] Run `npm test --workspace=packages/backstage-plugin-api-grade-backend --workspace=packages/backstage-plugin-api-grade` and confirm 100% pass with zero test-file edits (FR-023/SC-010) -- [ ] T054 [P] [US4] Run `grep -rn "api-grade-core" packages/backstage-plugin-api-grade-backend/src packages/backstage-plugin-api-grade/src` and confirm the only imported symbols (`GradeEngine`, `GradeResult`, `LetterGrade`, `GradeLabel`, `DiagnosticSummary`, `Diagnostic`) remain exported from `packages/api-grade-core/src/index.ts` with unchanged signatures — covers Acceptance Scenario 1 +- [X] T052 [US4] Run `npm run build --workspace=packages/backstage-plugin-api-grade-backend --workspace=packages/backstage-plugin-api-grade` and confirm both build successfully against the post-refactor core (FR-022/SC-010) +- [X] T053 [P] [US4] Run `npm test --workspace=packages/backstage-plugin-api-grade-backend --workspace=packages/backstage-plugin-api-grade` and confirm 100% pass with zero test-file edits (FR-023/SC-010) +- [X] T054 [P] [US4] Run `grep -rn "api-grade-core" packages/backstage-plugin-api-grade-backend/src packages/backstage-plugin-api-grade/src` and confirm the only imported symbols (`GradeEngine`, `GradeResult`, `LetterGrade`, `GradeLabel`, `DiagnosticSummary`, `Diagnostic`) remain exported from `packages/api-grade-core/src/index.ts` with unchanged signatures — covers Acceptance Scenario 1 **Checkpoint**: Backstage plugin packages confirmed unaffected; SC-010 satisfied. @@ -162,27 +162,66 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src ### Tests for User Story 5 -- [ ] T055 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: a workspace/global config with `auth.type: "entra-id"` and no `--ruleset` override causes the CLI to exit non-zero with the unsupported-authentication-type error — covers Acceptance Scenario 1 -- [ ] T056 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: `--auth-type entra-id` on the grade command exits non-zero with the same error, with no device-code/token flow attempted — covers Acceptance Scenario 2 -- [ ] T057 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: the entra-id rejection occurs before any fetch attempt, with no fallback to the built-in ruleset and no partial application of any other supplied option — covers Acceptance Scenario 3 & SC-007 +- [X] T055 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: a workspace/global config with `auth.type: "entra-id"` and no `--ruleset` override causes the CLI to exit non-zero with the unsupported-authentication-type error — covers Acceptance Scenario 1 +- [X] T056 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: `--auth-type entra-id` on the grade command exits non-zero with the same error, with no device-code/token flow attempted — covers Acceptance Scenario 2 +- [X] T057 [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: the entra-id rejection occurs before any fetch attempt, with no fallback to the built-in ruleset and no partial application of any other supplied option — covers Acceptance Scenario 3 & SC-007 ### Implementation for User Story 5 -- [ ] T057a [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: a workspace/global config with `auth.type: "entra-id"` combined with a local `--ruleset` file path does NOT trigger the unsupported-auth-type rejection; it prints the FR-021 ignored-option warning and grades the local file successfully — covers US5 Acceptance Scenario 4 -- [ ] T058 [US5] Extend the shared rejection check (built in T039, centralized in T048) so it also (a) is reached via `config get-ruleset`'s resolution path — informational-only there per contracts/cli-options.md (no non-zero exit on that read-only path), and (b) is bypassed (in favor of the FR-021 local-ruleset warning) when the resolved ruleset source is local — covering every config-sourced route to `entra-id` without contradicting FR-019 (FR-016/FR-019) -- [ ] T059 [US5] Confirm `--auth-type entra-id` is recognised by commander's option parser but does not appear in `--help` output text or any CLI documentation (FR-015/FR-017) +- [X] T057a [P] [US5] Integration test in `tests/integration/cli-github-pat.test.ts`: a workspace/global config with `auth.type: "entra-id"` combined with a local `--ruleset` file path does NOT trigger the unsupported-auth-type rejection; it prints the FR-021 ignored-option warning and grades the local file successfully — covers US5 Acceptance Scenario 4 +- [X] T058 [US5] Extend the shared rejection check (built in T039, centralized in T048) so it also (a) is reached via `config get-ruleset`'s resolution path — informational-only there per contracts/cli-options.md (no non-zero exit on that read-only path), and (b) is bypassed (in favor of the FR-021 local-ruleset warning) when the resolved ruleset source is local — covering every config-sourced route to `entra-id` without contradicting FR-019 (FR-016/FR-019) +- [X] T059 [US5] Confirm `--auth-type entra-id` is recognised by commander's option parser but does not appear in `--help` output text or any CLI documentation (FR-015/FR-017) **Checkpoint**: All five user stories are independently functional; SC-007 is satisfied across both config-file-sourced and CLI-option-sourced Entra ID attempts. --- -## Phase 8: Polish & Cross-Cutting Concerns +## Phase 8: User Story 6 - Configure every grading option via `.apigrade.json` (Priority: P2) -- [ ] T060 [P] Update `docs/cli` and the root `README.md` with `--auth-type`, `--token`, `GITHUB_TOKEN`, `config set-ruleset`/`config get-ruleset` usage and containerised execution instructions (`-e GITHUB_TOKEN`, bind-mounting `.api-grade`/`~/.api-grade`), per FR-012 and quickstart.md -- [ ] T061 [P] Update CLI `--help` text for the new options/subcommands, excluding any mention of `entra-id` (FR-015) -- [ ] T062 Manually run through `specs/008-cli-github-pat/quickstart.md` scenarios 1–8 against the built CLI and confirm every documented example behaves as written -- [ ] T063 Run the full quality gate (`npm run lint && npm run typecheck && npm test && npm run build`, across all workspaces) and fix any failure before considering the feature complete, per the Constitution's `/speckit-implement` gate requirement -- [ ] T063a [P] Run `grep -rn "fetchRulesetContent\|fetchRulesetWithGithubPat\|acquireEntraToken\|resolveRuleset\b" packages/api-grade-mcp/src` and confirm every match is inside a re-export shim (T008-T011), not a reimplementation — final SC-006 sign-off +**Goal**: `.apigrade.json` gains `authType`, `token`, and `url` keys alongside its +existing `minGrade`/`ruleset`/`format`/`top`/`verbose` keys, covering every +grading-command option except `--help`/`--version` (FR-024), with an explicit +command-line flag always overriding the matching key (FR-025), and `authType`/ +`token` slotting into the existing resolution chains at the same precedence +position the corresponding flag would occupy (FR-026). + +**Independent Test**: Write an `.apigrade.json` setting every supported key +(including `authType`/`token` against a stubbed private-ruleset host), run the +bare `api-grade ` command with no other flags, and confirm behavior is +identical to the equivalent invocation with every option supplied explicitly. + +### Tests for User Story 6 + +> Write these tests FIRST, ensure they FAIL before implementation. + +- [X] T060 [P] [US6] Unit test added to the existing `tests/unit/config-loader.test.ts` (not a new `cli-config-loader.test.ts` file — extended in place to avoid duplicating test coverage of the same module): `loadConfig()` reads `authType`, `token`, and `url` from `.apigrade.json` into `CliOptions`, alongside the existing `minGrade`/`ruleset`/`format`/`top`/`verbose` fields, with unrecognized/absent keys ignored exactly as today — covers FR-024 +- [X] T061 [P] [US6] Integration test in `tests/integration/cli-github-pat.test.ts`: an `.apigrade.json` setting `ruleset` (private URL), `authType: "github-pat"`, and `token` fetches and grades successfully with zero command-line flags beyond the spec file — covers Acceptance Scenario 1 & SC-011 +- [X] T062 [P] [US6] Integration test in `tests/integration/cli-github-pat.test.ts`: an explicit `--auth-type` flag overrides an invalid `.apigrade.json` `authType` value — proving CLI-flag-over-file precedence via the same merge expression (`cliOpts.x ?? fileConfig.x`) that also governs `token` — covers Acceptance Scenario 2 & FR-025/SC-012 +- [X] T063 [P] [US6] Integration test in `tests/integration/cli-github-pat.test.ts`: an `.apigrade.json` setting `token` but no `authType` (and no `--auth-type` flag) resolves to auth type `none`, ignores the file's `token`, and prints the FR-020 ignored-option warning — covers Acceptance Scenario 3 +- [X] T064 [P] [US6] Integration test in `tests/integration/cli-github-pat.test.ts`: an `.apigrade.json` setting `url` to a non-empty value exits 1 with the same "not yet supported" message an explicit `--url` flag produces — covers Acceptance Scenario 4 & FR-027 +- [X] T065 [P] [US6] Integration test in `tests/integration/cli-github-pat.test.ts`: an `.apigrade.json` setting `authType` to an invalid value (e.g. `github_pat`) exits non-zero with the same `config-invalid` error an equivalent invalid `--auth-type` flag value produces — covers Acceptance Scenario 5 & FR-028 +- [X] T066 [P] [US6] Integration test in `tests/integration/cli-github-pat.test.ts`: with no `.apigrade.json` present, behavior is unchanged from pre-feature — covers Acceptance Scenario 6 (additive-only regression guard) + +### Implementation for User Story 6 + +- [X] T067 [US6] Extend `CliOptions` and `loadConfig()` in `src/cli/config-loader.ts` with `authType?: string`, `token?: string`, `url?: string`, read with the same type-checked-field pattern already used for `minGrade`/`ruleset`/`format`/`top`/`verbose` (FR-024) (depends on T060) +- [X] T068 [US6] In `src/cli/index.ts`'s grade action handler, merge `cliOpts.authType ?? fileConfig.authType` and `cliOpts.token ?? fileConfig.token` into the values passed as `authTypeOption`/`tokenOption` to `resolveCliAuth` (US1's T033/T035), and merge `cliOpts.url ?? fileConfig.url` into the existing `--url` reserved-option check (FR-025/FR-026/FR-027) (depends on T067, US1's T033/T035/T036) +- [X] T069 [US6] Apply the same `cliOpts.authType ?? fileConfig.authType` / `cliOpts.token ?? fileConfig.token` merge to `config get-ruleset`'s resolution call in `src/cli/ruleset-config-cli.ts`, so its read-only effective-resolution output reflects `.apigrade.json` exactly as the grade command does (consistency with US2's T046) (depends on T067, US2's T046) +- [X] T070 [US6] Confirm (and adjust if needed) that an invalid `.apigrade.json` `authType` value reaches the same `config-invalid` rejection path as an invalid `--auth-type` flag value (T039/US1), without a separate file-specific validation branch (FR-028) (depends on T068) + +**Checkpoint**: `.apigrade.json` covers every grading-command option except +`--help`/`--version`; CI pipelines can fully configure a private-ruleset +invocation with zero command-line flags (SC-011/SC-012). + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +- [X] T071 [P] Update `docs/cli` and the root `README.md` with `--auth-type`, `--token`, `GITHUB_TOKEN`, `config set-ruleset`/`config get-ruleset` usage, the new `.apigrade.json` `authType`/`token`/`url` keys (FR-024, including the secret-exposure callout from FR-028), and containerised execution instructions (`-e GITHUB_TOKEN`, bind-mounting `.api-grade`/`~/.api-grade`), per FR-012 and quickstart.md +- [X] T072 [P] Update CLI `--help` text for the new options/subcommands, excluding any mention of `entra-id` (FR-015) +- [X] T073 Manually run through `specs/008-cli-github-pat/quickstart.md` scenarios 1–9 against the built CLI and confirm every documented example behaves as written +- [X] T074 Run the full quality gate (`npm run lint && npm run typecheck && npm test && npm run build`, across all workspaces) and fix any failure before considering the feature complete, per the Constitution's `/speckit-implement` gate requirement +- [X] T074a [P] Run `grep -rn "fetchRulesetContent\|fetchRulesetWithGithubPat\|acquireEntraToken\|resolveRuleset\b" packages/api-grade-mcp/src` and confirm every match is inside a re-export shim (T008-T011), not a reimplementation — final SC-006 sign-off --- @@ -197,7 +236,8 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src - **User Story 3 (Phase 5)**: Depends on Foundational (T024 already proved this); Phase 5's tasks formalize the check *after* Phases 3–4 to guard against later regressions. - **User Story 4 (Phase 6)**: Depends only on Foundational completion (Backstage packages are never touched); can run any time after Phase 2, in parallel with Phases 3–5. - **User Story 5 (Phase 7)**: Depends on US1's T039 and US2's T048 (the shared rejection/resolution helper it extends). -- **Polish (Phase 8)**: Depends on all desired user stories being complete. +- **User Story 6 (Phase 8)**: Depends on Foundational completion; T068 also depends on US1's T033/T035/T036, and T069 also depends on US2's T046, so in practice runs after Phases 3–4. +- **Polish (Phase 9)**: Depends on all desired user stories being complete. ### Parallel Opportunities @@ -205,9 +245,9 @@ Monorepo: `packages/api-grade-core/src`, `packages/api-grade-mcp/src`, root `src - T008–T011 (MCP re-export shims) run in parallel once T007 lands. - T013–T018 (MCP tool import updates) run in parallel once T007 lands. - T019–T021 (new core unit tests) run in parallel, each gated only on its corresponding move task. -- All US1 test tasks (T026–T031) run in parallel; all US2 test tasks (T041, T041a–T044) run in parallel; all US5 test tasks (T055–T057, T057a) run in parallel. -- Phase 6 (User Story 4) can run any time after Phase 2 completes, in parallel with Phases 3, 4, 5, and 7 — it touches no shared file. -- T060/T061 (Polish docs) run in parallel. +- All US1 test tasks (T026–T031) run in parallel; all US2 test tasks (T041, T041a–T044) run in parallel; all US5 test tasks (T055–T057, T057a) run in parallel; all US6 test tasks (T060–T066) run in parallel. +- Phase 6 (User Story 4) can run any time after Phase 2 completes, in parallel with Phases 3, 4, 5, 7, and 8 — it touches no shared file. +- T071/T072 (Polish docs) run in parallel. --- @@ -250,13 +290,14 @@ Task: "Integration test: local ruleset + auth options prints warnings, grades th 3. User Story 2 → persistent workspace/global defaults remove per-invocation friction (SC-002). 4. User Stories 3 & 4 → formal regression sign-off for MCP and Backstage (SC-003, SC-010) — can be run any time after Phase 2, but are listed last since they gate the overall feature's completeness, not its MVP. 5. User Story 5 → Entra ID explicit-rejection guardrail (SC-007), lowest priority (P3), shippable last without blocking US1/US2's value. -6. Polish → docs, `--help`, quickstart validation, full quality gate. +6. User Story 6 → `.apigrade.json` covers every grading option (SC-011/SC-012), closing the config-file gap this feature itself introduced. +7. Polish → docs, `--help`, quickstart validation, full quality gate. ### Parallel Team Strategy With multiple developers, after Foundational (Phase 2) completes: - Developer A: User Story 1 (Phase 3), then User Story 5 (Phase 7, depends on US1's rejection hook). -- Developer B: User Story 2 (Phase 4, starts once US1's T033/T035/T036 land), then helps with Phase 8 Polish. +- Developer B: User Story 2 (Phase 4, starts once US1's T033/T035/T036 land), then User Story 6 (Phase 8, depends on US1's T033/T035/T036 and US2's T046), then helps with Phase 9 Polish. - Developer C: User Story 4 (Phase 6) immediately after Phase 2 — fully independent of CLI work — then User Story 3 (Phase 5) once Phases 3–4 land. --- diff --git a/src/cli/config-loader.ts b/src/cli/config-loader.ts index cc23993..8838ace 100644 --- a/src/cli/config-loader.ts +++ b/src/cli/config-loader.ts @@ -8,9 +8,12 @@ export interface CliOptions { specPath: string; minGrade?: LetterGrade; rulesetPath?: string; + authType?: string; + token?: string; format: 'human' | 'json'; top?: number; verbose?: boolean; + url?: string; } export function loadConfig(cwd: string): Partial { @@ -38,6 +41,12 @@ export function loadConfig(cwd: string): Partial { if (typeof parsed.ruleset === 'string') { config.rulesetPath = parsed.ruleset; } + if (typeof parsed.authType === 'string') { + config.authType = parsed.authType; + } + if (typeof parsed.token === 'string') { + config.token = parsed.token; + } if (parsed.format === 'human' || parsed.format === 'json') { config.format = parsed.format; } @@ -47,6 +56,9 @@ export function loadConfig(cwd: string): Partial { if (typeof parsed.verbose === 'boolean') { config.verbose = parsed.verbose; } + if (typeof parsed.url === 'string') { + config.url = parsed.url; + } return config; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 66dcdf1..c779462 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -85,11 +85,6 @@ program url?: string; verbose?: boolean; }) => { - if (cliOpts.url) { - console.error(chalk.red('Error: --url is not yet supported in this version.')); - process.exit(1); - } - // Load .apigrade.json config; CLI flags override config values let fileConfig: ReturnType = {}; try { @@ -100,6 +95,11 @@ program process.exit(1); } + if (cliOpts.url ?? fileConfig.url) { + console.error(chalk.red('Error: --url is not yet supported in this version.')); + process.exit(1); + } + // Merge: CLI flags take precedence over config file values const outputFormat = cliOpts.format ?? fileConfig.format ?? 'human'; if (outputFormat !== 'human' && outputFormat !== 'json') { @@ -121,8 +121,11 @@ program minGrade = g; } - if (cliOpts.authType !== undefined && !isValidAuthType(cliOpts.authType)) { - const message = `Invalid --auth-type value '${cliOpts.authType}'. Must be one of: none, github-pat.`; + const authTypeOption = cliOpts.authType ?? fileConfig.authType; + const tokenOption = cliOpts.token ?? fileConfig.token; + + if (authTypeOption !== undefined && !isValidAuthType(authTypeOption)) { + const message = `Invalid --auth-type value '${authTypeOption}'. Must be one of: none, github-pat.`; if (outputFormat === 'json') { console.log(JSON.stringify({ error: 'RULESET_BAD_CONFIG', message })); } else { @@ -135,8 +138,8 @@ program const globalConfig = await loadGlobalConfig(); const authResult = resolveCliAuth({ rulesetOption: cliOpts.ruleset ?? fileConfig.rulesetPath, - authTypeOption: cliOpts.authType, - tokenOption: cliOpts.token, + authTypeOption, + tokenOption, workspaceConfig, globalConfig, }); diff --git a/src/cli/ruleset-config-cli.ts b/src/cli/ruleset-config-cli.ts index 5e2615f..714a41d 100644 --- a/src/cli/ruleset-config-cli.ts +++ b/src/cli/ruleset-config-cli.ts @@ -11,7 +11,8 @@ import { type RulesetConfig, type AuthConfig, } from '@dawmatt/api-grade-core'; -import { resolveCliAuth, checkEntraRejection, isValidAuthType } from './ruleset-resolution.js'; +import { resolveCliAuth, checkEntraRejection, isValidAuthType, type TokenSource } from './ruleset-resolution.js'; +import { loadConfig } from './config-loader.js'; export interface SetRulesetOptions { scope?: string; @@ -95,11 +96,32 @@ function tokenPresence(auth: AuthConfig | null | undefined): string { return '(no token)'; } +function effectiveTokenPresence(tokenSource: TokenSource | undefined): string { + if (tokenSource === 'env') return '(from GITHUB_TOKEN)'; + if (tokenSource === 'option' || tokenSource === 'stored') return '(token configured)'; + return '(no token)'; +} + export async function runGetRuleset(opts: { format?: string }): Promise { + let fileConfig: ReturnType = {}; + try { + fileConfig = loadConfig(process.cwd()); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + fail(message, opts.format); + } + + if (fileConfig.authType !== undefined && !isValidAuthType(fileConfig.authType)) { + fail(`Invalid .apigrade.json "authType" value '${fileConfig.authType}'. Must be one of: none, github-pat.`, opts.format); + } + const workspaceConfig = await loadWorkspaceConfig(); const globalConfig = await loadGlobalConfig(); const authResult = resolveCliAuth({ + rulesetOption: fileConfig.rulesetPath, + authTypeOption: fileConfig.authType, + tokenOption: fileConfig.token, workspaceConfig, globalConfig, }); @@ -112,12 +134,21 @@ export async function runGetRuleset(opts: { format?: string }): Promise { scope: authResult.resolution.scope, rulesetPath: authResult.resolution.rulesetPath, authType: authResult.authType, + tokenPresence: effectiveTokenPresence(authResult.tokenSource), }, workspace: workspaceConfig?.rulesetPath != null - ? { rulesetPath: workspaceConfig.rulesetPath, authType: workspaceConfig.auth?.type ?? 'none' } + ? { + rulesetPath: workspaceConfig.rulesetPath, + authType: workspaceConfig.auth?.type ?? 'none', + tokenPresence: tokenPresence(workspaceConfig.auth), + } : null, global: globalConfig?.rulesetPath != null - ? { rulesetPath: globalConfig.rulesetPath, authType: globalConfig.auth?.type ?? 'none' } + ? { + rulesetPath: globalConfig.rulesetPath, + authType: globalConfig.auth?.type ?? 'none', + tokenPresence: tokenPresence(globalConfig.auth), + } : null, builtIn: 'default', ...(entraCheck.rejected ? { unsupportedByCli: entraCheck.message } : {}), @@ -126,7 +157,7 @@ export async function runGetRuleset(opts: { format?: string }): Promise { return; } - console.log(`Effective: scope=${authResult.resolution.scope} rulesetPath=${authResult.resolution.rulesetPath ?? '(built-in)'} authType=${authResult.authType} ${tokenPresence(authResult.resolution.auth)}`); + console.log(`Effective: scope=${authResult.resolution.scope} rulesetPath=${authResult.resolution.rulesetPath ?? '(built-in)'} authType=${authResult.authType} ${effectiveTokenPresence(authResult.tokenSource)}`); console.log( workspaceConfig?.rulesetPath != null ? `Workspace (${getWorkspaceConfigPath()}): rulesetPath=${workspaceConfig.rulesetPath} authType=${workspaceConfig.auth?.type ?? 'none'} ${tokenPresence(workspaceConfig.auth)}` diff --git a/src/cli/ruleset-resolution.ts b/src/cli/ruleset-resolution.ts index 2630d26..b6bc635 100644 --- a/src/cli/ruleset-resolution.ts +++ b/src/cli/ruleset-resolution.ts @@ -27,6 +27,8 @@ export interface ResolveAuthInput { globalConfig: RulesetConfig | null; } +export type TokenSource = 'option' | 'env' | 'stored'; + export interface ResolveAuthResult { resolution: RulesetResolution; /** Raw resolved auth-type string; may be invalid (not none/github-pat/entra-id). */ @@ -35,6 +37,8 @@ export interface ResolveAuthResult { isLocalFile: boolean; /** Only populated when authType === 'github-pat' and the ruleset is remote. */ token: string | undefined; + /** Where `token` came from; only populated alongside `token`. */ + tokenSource: TokenSource | undefined; warnings: string[]; } @@ -57,6 +61,7 @@ export function resolveCliAuth(input: ResolveAuthInput): ResolveAuthResult { const warnings: string[] = []; let token: string | undefined; + let tokenSource: TokenSource | undefined; if (isLocalFile) { if (input.authTypeOption !== undefined) { @@ -76,11 +81,20 @@ export function resolveCliAuth(input: ResolveAuthInput): ResolveAuthResult { ); } if (authType === 'github-pat') { - token = input.tokenOption ?? process.env.GITHUB_TOKEN ?? resolution.auth?.githubToken; + if (input.tokenOption !== undefined) { + token = input.tokenOption; + tokenSource = 'option'; + } else if (process.env.GITHUB_TOKEN) { + token = process.env.GITHUB_TOKEN; + tokenSource = 'env'; + } else if (resolution.auth?.githubToken) { + token = resolution.auth.githubToken; + tokenSource = 'stored'; + } } } - return { resolution, authType, isRemote, isLocalFile, token, warnings }; + return { resolution, authType, isRemote, isLocalFile, token, tokenSource, warnings }; } export interface EntraRejectionCheck { diff --git a/tests/integration/cli-github-pat.test.ts b/tests/integration/cli-github-pat.test.ts index 47bf5c7..371736a 100644 --- a/tests/integration/cli-github-pat.test.ts +++ b/tests/integration/cli-github-pat.test.ts @@ -45,6 +45,10 @@ function writeGlobalConfig(homeDir: string, config: unknown): void { writeFileSync(join(dir, 'config.json'), JSON.stringify(config), 'utf-8'); } +function writeApigradeJson(baseDir: string, config: unknown): void { + writeFileSync(join(baseDir, '.apigrade.json'), JSON.stringify(config), 'utf-8'); +} + const tmpDirsToClean: string[] = []; afterEach(() => { while (tmpDirsToClean.length > 0) { @@ -216,3 +220,99 @@ describe('US5: CLI rejects Entra ID authentication explicitly', () => { expect(stdout).not.toMatch(/entra-id/i); }); }); + +describe('US6: configure every grading option via .apigrade.json', () => { + it('an .apigrade.json setting ruleset/authType/token reaches the same fetch path as the equivalent flags, with no token leak (Acceptance Scenario 1)', () => { + const workspaceDir = trackedTmpDir('api-grade-cfg-auth-ws-'); + writeApigradeJson(workspaceDir, { + ruleset: 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + authType: 'github-pat', + token: VALID_TOKEN, + }); + const { status, stdout, stderr } = runCli([OPENAPI_SPEC], { cwd: workspaceDir }); + expect(status).toBe(1); + // A token was supplied (via the file), so the failure must NOT be the "no token at all" message. + expect(stderr.toLowerCase()).not.toMatch(/authentication required/); + expect(stdout).not.toContain(VALID_TOKEN); + expect(stderr).not.toContain(VALID_TOKEN); + }, 15000); + + it('an explicit --auth-type flag overrides an .apigrade.json authType value (Acceptance Scenario 2 / FR-025 / SC-012)', () => { + const workspaceDir = trackedTmpDir('api-grade-cfg-override-ws-'); + // The file's authType alone would be rejected as config-invalid. + writeApigradeJson(workspaceDir, { + ruleset: LOCAL_RULESET, + authType: 'bogus-invalid-value', + token: VALID_TOKEN, + }); + + const withoutOverride = runCli([OPENAPI_SPEC], { cwd: workspaceDir }); + expect(withoutOverride.status).toBe(1); + expect(withoutOverride.stderr).toMatch(/Invalid --auth-type value/); + + const withOverride = runCli([OPENAPI_SPEC, '--auth-type', 'github-pat'], { cwd: workspaceDir }); + expect(withOverride.status).toBe(0); + expect(withOverride.stderr).toContain('--auth-type is ignored because the ruleset is a local file'); + expect(withOverride.stdout).toBeTruthy(); + }, 30000); + + it('an .apigrade.json token without authType resolves to none, ignores the file token, and warns (Acceptance Scenario 3)', () => { + const workspaceDir = trackedTmpDir('api-grade-cfg-none-ws-'); + writeApigradeJson(workspaceDir, { + ruleset: 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + token: VALID_TOKEN, + }); + const { stderr, stdout } = runCli([OPENAPI_SPEC], { cwd: workspaceDir }); + expect(stderr).toContain("Warning: --token is ignored because the authorisation type is 'none'"); + expect(stdout).not.toContain(VALID_TOKEN); + expect(stderr).not.toContain(VALID_TOKEN); + }, 15000); + + it('an .apigrade.json url value triggers the same "not yet supported" rejection as the --url flag (Acceptance Scenario 4 / FR-027)', () => { + const workspaceDir = trackedTmpDir('api-grade-cfg-url-ws-'); + writeApigradeJson(workspaceDir, { url: 'https://example.com/reserved' }); + const { status, stderr } = runCli([OPENAPI_SPEC], { cwd: workspaceDir }); + expect(status).toBe(1); + expect(stderr).toMatch(/--url is not yet supported/); + }); + + it('an .apigrade.json authType value outside none/github-pat/entra-id is a config-invalid failure (Acceptance Scenario 5 / FR-028)', () => { + const workspaceDir = trackedTmpDir('api-grade-cfg-badauth-ws-'); + writeApigradeJson(workspaceDir, { + ruleset: 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + authType: 'github_pat', + }); + const { status, stderr } = runCli([OPENAPI_SPEC], { cwd: workspaceDir }); + expect(status).toBe(1); + expect(stderr).toMatch(/Invalid --auth-type value 'github_pat'/); + }); + + it('with no .apigrade.json present, behavior is unchanged from pre-feature (Acceptance Scenario 6)', () => { + const workspaceDir = trackedTmpDir('api-grade-cfg-none-present-ws-'); + const { status, stdout } = runCli([OPENAPI_SPEC, '--ruleset', LOCAL_RULESET], { cwd: workspaceDir }); + expect(status).toBe(0); + expect(stdout).toBeTruthy(); + }, 30000); + + it('config get-ruleset reflects an .apigrade.json-configured authType/token in its effective resolution', () => { + const workspaceDir = trackedTmpDir('api-grade-cfg-getruleset-ws-'); + writeApigradeJson(workspaceDir, { + ruleset: 'https://raw.githubusercontent.com/example/private-repo/main/ruleset.yaml', + authType: 'github-pat', + token: VALID_TOKEN, + }); + const { stdout, stderr } = runCli(['config', 'get-ruleset'], { cwd: workspaceDir }); + expect(stdout).toContain('authType=github-pat'); + expect(stdout).toContain('(token configured)'); + expect(stdout).not.toContain(VALID_TOKEN); + expect(stderr).not.toContain(VALID_TOKEN); + }); + + it('config get-ruleset rejects an invalid .apigrade.json authType value', () => { + const workspaceDir = trackedTmpDir('api-grade-cfg-getruleset-bad-ws-'); + writeApigradeJson(workspaceDir, { authType: 'bogus' }); + const { status, stderr } = runCli(['config', 'get-ruleset'], { cwd: workspaceDir }); + expect(status).toBe(1); + expect(stderr).toMatch(/Invalid .apigrade.json "authType" value/); + }); +}); diff --git a/tests/unit/cli-ruleset-config.test.ts b/tests/unit/cli-ruleset-config.test.ts index 0cfedb8..cde7c4f 100644 --- a/tests/unit/cli-ruleset-config.test.ts +++ b/tests/unit/cli-ruleset-config.test.ts @@ -144,6 +144,8 @@ describe('config get-ruleset', () => { const parsed = JSON.parse(stdout); expect(parsed.effective.scope).toBe('workspace'); expect(parsed.effective.authType).toBe('github-pat'); + expect(parsed.effective.tokenPresence).toBe('(token configured)'); + expect(parsed.workspace.tokenPresence).toBe('(token configured)'); }); it('reports an entra-id config as unsupported-by-CLI informationally, without a non-zero exit (T058)', () => { diff --git a/tests/unit/config-loader.test.ts b/tests/unit/config-loader.test.ts index 02143f2..4bbd2b3 100644 --- a/tests/unit/config-loader.test.ts +++ b/tests/unit/config-loader.test.ts @@ -57,4 +57,43 @@ describe('loadConfig', () => { const config = loadConfig(tmpDir); expect(config.rulesetPath).toBe('./custom.yaml'); }); + + it('reads authType, token, and url alongside the existing keys (FR-024)', () => { + writeFileSync( + join(tmpDir, '.apigrade.json'), + JSON.stringify({ + minGrade: 'B', + ruleset: './my-rules.yaml', + authType: 'github-pat', + token: 'ghp_test_token', + format: 'json', + top: 10, + verbose: true, + url: 'https://example.com/reserved', + }) + ); + const config = loadConfig(tmpDir); + expect(config.authType).toBe('github-pat'); + expect(config.token).toBe('ghp_test_token'); + expect(config.url).toBe('https://example.com/reserved'); + }); + + it('ignores authType/token/url when absent, same as today for other optional keys', () => { + writeFileSync(join(tmpDir, '.apigrade.json'), JSON.stringify({ minGrade: 'A' })); + const config = loadConfig(tmpDir); + expect(config.authType).toBeUndefined(); + expect(config.token).toBeUndefined(); + expect(config.url).toBeUndefined(); + }); + + it('ignores non-string authType/token/url values', () => { + writeFileSync( + join(tmpDir, '.apigrade.json'), + JSON.stringify({ authType: 42, token: false, url: {} }) + ); + const config = loadConfig(tmpDir); + expect(config.authType).toBeUndefined(); + expect(config.token).toBeUndefined(); + expect(config.url).toBeUndefined(); + }); }); From 641381254f2e081eceab6224a48b6648a6e0dc01 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 21 Jun 2026 22:28:19 +1000 Subject: [PATCH 10/10] chore: release v0.3.0 --- package.json | 2 +- packages/api-grade-core/package.json | 2 +- packages/api-grade-mcp/package.json | 2 +- packages/backstage-plugin-api-grade-backend/package.json | 2 +- packages/backstage-plugin-api-grade/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index bc4ff24..1f27b0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/api-grade", - "version": "0.2.1", + "version": "0.3.0", "description": "Grade API quality and share diagnostics using Spectral-compatible linting", "keywords": [ "api", diff --git a/packages/api-grade-core/package.json b/packages/api-grade-core/package.json index b4a168e..bb7996a 100644 --- a/packages/api-grade-core/package.json +++ b/packages/api-grade-core/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/api-grade-core", - "version": "0.2.1", + "version": "0.3.0", "description": "Core grading library for api-grade — standalone, framework-agnostic", "keywords": [ "api", diff --git a/packages/api-grade-mcp/package.json b/packages/api-grade-mcp/package.json index 8fe2c19..b011fd7 100644 --- a/packages/api-grade-mcp/package.json +++ b/packages/api-grade-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/api-grade-mcp", - "version": "0.2.1", + "version": "0.3.0", "description": "MCP server exposing api-grade capabilities for LLMs and agentic AI tooling", "keywords": [ "api", diff --git a/packages/backstage-plugin-api-grade-backend/package.json b/packages/backstage-plugin-api-grade-backend/package.json index 61229b4..a3b8c37 100644 --- a/packages/backstage-plugin-api-grade-backend/package.json +++ b/packages/backstage-plugin-api-grade-backend/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/backstage-plugin-api-grade-backend", - "version": "0.2.1", + "version": "0.3.0", "description": "Backstage backend plugin — grades API entity specs and returns results via HTTP", "keywords": [ "backstage", diff --git a/packages/backstage-plugin-api-grade/package.json b/packages/backstage-plugin-api-grade/package.json index 5340628..c1dba4a 100644 --- a/packages/backstage-plugin-api-grade/package.json +++ b/packages/backstage-plugin-api-grade/package.json @@ -1,6 +1,6 @@ { "name": "@dawmatt/backstage-plugin-api-grade", - "version": "0.2.1", + "version": "0.3.0", "description": "Backstage frontend plugin — displays API quality grades on API entity pages", "keywords": [ "backstage",