mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
20 Commits
copilot/ad
...
v0.8.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b5f812b44 | ||
|
|
e1b531c648 | ||
|
|
b5db159394 | ||
|
|
947b4398c7 | ||
|
|
28145b9a3a | ||
|
|
cec0d2db5e | ||
|
|
688ca1b3c5 | ||
|
|
2b4a33e1fd | ||
|
|
2be4ef713d | ||
|
|
282a1f7d1b | ||
|
|
b0674243d2 | ||
|
|
abb5fe7090 | ||
|
|
f0998348be | ||
|
|
5563269831 | ||
|
|
5b9f0040e7 | ||
|
|
11f49ebfb2 | ||
|
|
cd44dc2147 | ||
|
|
f5b675e9ee | ||
|
|
38bb88bde1 | ||
|
|
0facb1bdc2 |
2
.github/workflows/catalog-assign.yml
vendored
2
.github/workflows/catalog-assign.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,14 +19,14 @@ jobs:
|
||||
language: [ 'actions', 'python' ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
dotnet-version: '8.x'
|
||||
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23
|
||||
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -2,6 +2,39 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.8.8] - 2026-05-11
|
||||
|
||||
### Changed
|
||||
|
||||
- chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#2486)
|
||||
- feat(catalog): add Spec Kit Schedule (schedule) community extension (#2473)
|
||||
- fix(integration): refresh shared infra on `integration switch` (#2375)
|
||||
- Add MDE preset to community catalog (#2513)
|
||||
- Add MDE extension to community catalog (#2512)
|
||||
- chore: update community catalog with latest extension versions (#2490)
|
||||
- chore(deps): bump actions/setup-dotnet from 4.3.1 to 5.2.0 (#2489)
|
||||
- chore(deps): bump actions/github-script from 7 to 9 (#2488)
|
||||
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#2487)
|
||||
- chore(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#2485)
|
||||
- feat(catalog): add API Evolve (api-evolve) community extension (#2479)
|
||||
- feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
|
||||
- chore: release 0.8.7, begin 0.8.8.dev0 development (#2480)
|
||||
|
||||
## [0.8.7] - 2026-05-07
|
||||
|
||||
### Changed
|
||||
|
||||
- feat: add agent-orchestrator to community extension catalog (#2236)
|
||||
- chore: update extension versions in community catalog (#2468)
|
||||
- fix(goose): Declare args parameter in generated recipes (#2402)
|
||||
- feat: Add lingma support (#2348)
|
||||
- docs: Add uv installation guide and inline callouts (#2465)
|
||||
- Add fx-to-dotnet to community extension catalog (#2471)
|
||||
- fix: default non-interactive init to copilot integration (#2414)
|
||||
- fix(forge): use hyphen notation for command refs in Forge integration (#2462)
|
||||
- feat(catalog): add Cost Tracker (cost) community extension (#2448)
|
||||
- chore: release 0.8.6, begin 0.8.7.dev0 development (#2463)
|
||||
|
||||
## [0.8.6] - 2026-05-06
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -56,6 +56,9 @@ Choose your preferred installation method:
|
||||
|
||||
Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||
|
||||
> [!NOTE]
|
||||
> The `uv tool install` commands below require **[uv](https://docs.astral.sh/uv/)** — a fast Python package manager. If you see `command not found: uv`, [install uv first](./docs/install/uv.md). The `pipx` alternative does not require uv.
|
||||
|
||||
```bash
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
@@ -197,6 +200,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
@@ -220,6 +224,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
|
||||
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
@@ -231,11 +236,13 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
|
||||
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
|
||||
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
|
||||
| MDE | Minimal model-driven engineering workflow with setup, next, and status commands | `process` | Read+Write | [spec-kit-mde](https://github.com/AI-MDE/spec-kit-mde) |
|
||||
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
|
||||
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
|
||||
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
@@ -261,6 +268,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
|
||||
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
|
||||
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
|
||||
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
|
||||
|
||||
@@ -18,6 +18,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
|
||||
60
docs/install/uv.md
Normal file
60
docs/install/uv.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Installing uv
|
||||
|
||||
[uv](https://docs.astral.sh/uv/) is a fast Python package manager by [Astral](https://astral.sh/). Spec Kit uses `uv` (via `uvx` or `uv tool install`) to run the `specify` CLI without polluting your global Python environment.
|
||||
|
||||
> [!NOTE]
|
||||
> **Already have uv?** Run `uv --version` to confirm it is installed, then head back to the [Installation Guide](../installation.md).
|
||||
|
||||
## Installation
|
||||
|
||||
### macOS and Linux — Standalone Installer
|
||||
|
||||
The quickest way to install uv on macOS or Linux is the official shell script:
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
After the script finishes, follow any instructions printed by the installer to add uv to your `PATH`, then open a new terminal.
|
||||
|
||||
### Windows — Standalone Installer
|
||||
|
||||
Run the following in **Command Prompt or PowerShell**:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
After the script finishes, open a new terminal so the `uv` binary is on your `PATH`.
|
||||
|
||||
### macOS — Homebrew
|
||||
|
||||
```bash
|
||||
brew install uv
|
||||
```
|
||||
|
||||
### Windows — WinGet
|
||||
|
||||
```powershell
|
||||
winget install --id=astral-sh.uv -e
|
||||
```
|
||||
|
||||
### Windows — Scoop
|
||||
|
||||
```powershell
|
||||
scoop install uv
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Confirm that uv is installed and on your `PATH`:
|
||||
|
||||
```bash
|
||||
uv --version
|
||||
```
|
||||
|
||||
You should see output similar to `uv 0.x.y (...)`.
|
||||
|
||||
## Further Reading
|
||||
|
||||
For advanced options (self-update, proxy settings, uninstall, etc.) see the official [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/).
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||
|
||||
> [!NOTE]
|
||||
> The `uvx` commands below require **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uvx`, [install uv first](./install/uv.md). The `pipx` alternative does not require uv.
|
||||
|
||||
```bash
|
||||
# Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||
|
||||
181
docs/reference/authentication.md
Normal file
181
docs/reference/authentication.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Authentication
|
||||
|
||||
Specify CLI uses **opt-in authentication** for HTTP requests to catalog
|
||||
sources, extension downloads, and release checks. No credentials are
|
||||
sent unless you explicitly configure them.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.specify/auth.json` to enable authentication:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Security:** Restrict the file to owner-only access:
|
||||
> ```bash
|
||||
> chmod 600 ~/.specify/auth.json
|
||||
> ```
|
||||
|
||||
Without this file, all HTTP requests are unauthenticated.
|
||||
|
||||
## Fields
|
||||
|
||||
Each entry in the `providers` array has the following fields:
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `hosts` | Yes | Array of hostnames this entry applies to. Supports exact hostnames, or a leading `*.` wildcard for subdomains only (for example, `*.visualstudio.com`). `*.visualstudio.com` matches `foo.visualstudio.com`, but not `visualstudio.com`. Other glob patterns such as `*github.com` or `gith?b.com` are not supported. |
|
||||
| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. |
|
||||
| `auth` | Yes | Auth scheme (see below). |
|
||||
| `token` | No | Token value (inline). Use `token_env` instead when possible. |
|
||||
| `token_env` | No | Environment variable name to read the token from. |
|
||||
|
||||
For `azure-ad` auth, additional fields are required:
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `tenant_id` | Yes | Azure AD tenant ID. |
|
||||
| `client_id` | Yes | Service principal client ID. |
|
||||
| `client_secret_env` | Yes | Environment variable containing the client secret. |
|
||||
|
||||
Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.
|
||||
|
||||
## Providers and auth schemes
|
||||
|
||||
### GitHub (`github`)
|
||||
|
||||
| Scheme | Header | Use for |
|
||||
|---|---|---|
|
||||
| `bearer` | `Authorization: Bearer <token>` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens |
|
||||
|
||||
**Example — PAT via environment variable:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN"
|
||||
}
|
||||
```
|
||||
|
||||
### Azure DevOps (`azure-devops`)
|
||||
|
||||
| Scheme | Header | Use for |
|
||||
|---|---|---|
|
||||
| `basic-pat` | `Authorization: Basic base64(:<PAT>)` | Personal Access Tokens |
|
||||
| `bearer` | `Authorization: Bearer <token>` | Pre-acquired OAuth / Azure AD tokens |
|
||||
| `azure-cli` | `Authorization: Bearer <token>` | Token acquired via `az account get-access-token` |
|
||||
| `azure-ad` | `Authorization: Bearer <token>` | Token acquired via OAuth2 client credentials flow |
|
||||
|
||||
**Example — PAT via environment variable:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "AZURE_DEVOPS_PAT"
|
||||
}
|
||||
```
|
||||
|
||||
**Example — Azure CLI (interactive login):**
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-cli"
|
||||
}
|
||||
```
|
||||
|
||||
Requires `az login` to have been run beforehand.
|
||||
|
||||
**Example — Azure AD service principal (CI/automation):**
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"client_secret_env": "AZURE_CLIENT_SECRET"
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple entries
|
||||
|
||||
You can configure multiple entries for different hosts or organizations:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN"
|
||||
},
|
||||
{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "AZURE_DEVOPS_PAT"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. For each outbound HTTP request, the URL hostname is matched against
|
||||
the `hosts` patterns in `auth.json`.
|
||||
2. If a match is found, the corresponding provider resolves the token
|
||||
and attaches the appropriate `Authorization` header.
|
||||
3. If the request receives a 401 or 403, the next matching entry is tried.
|
||||
4. After all matching entries are exhausted, an unauthenticated request
|
||||
is attempted as a final fallback.
|
||||
5. On redirects, the `Authorization` header is stripped if the redirect
|
||||
target leaves the entry's declared hosts — preventing credential
|
||||
leakage to CDNs or third-party services.
|
||||
|
||||
## Template
|
||||
|
||||
A reference `auth.json` with GitHub pre-configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"hosts": [
|
||||
"github.com",
|
||||
"api.github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"codeload.github.com"
|
||||
],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To use it:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.specify
|
||||
# Copy the JSON above into ~/.specify/auth.json
|
||||
chmod 600 ~/.specify/auth.json
|
||||
```
|
||||
@@ -24,6 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
||||
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration |
|
||||
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` |
|
||||
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
|
||||
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
|
||||
| [opencode](https://opencode.ai/) | `opencode` | |
|
||||
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
href: quickstart.md
|
||||
- name: Upgrade
|
||||
href: upgrade.md
|
||||
- name: Install uv
|
||||
href: install/uv.md
|
||||
|
||||
# Reference
|
||||
- name: Reference
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-06T00:00:00Z",
|
||||
"updated_at": "2026-05-07T20:05:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -68,6 +68,75 @@
|
||||
"created_at": "2026-03-31T00:00:00Z",
|
||||
"updated_at": "2026-03-31T00:00:00Z"
|
||||
},
|
||||
"agent-orchestrator": {
|
||||
"name": "Intelligent Agent Orchestrator",
|
||||
"id": "agent-orchestrator",
|
||||
"description": "Cross-catalog agent discovery and intelligent prompt-to-command routing",
|
||||
"author": "pragya247",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/pragya247/spec-kit-orchestrator/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/pragya247/spec-kit-orchestrator",
|
||||
"homepage": "https://github.com/pragya247/spec-kit-orchestrator",
|
||||
"documentation": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/README.md",
|
||||
"changelog": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.1"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"orchestrator",
|
||||
"routing",
|
||||
"discovery",
|
||||
"agent",
|
||||
"ai"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-04T00:00:00Z",
|
||||
"updated_at": "2026-05-04T00:00:00Z"
|
||||
},
|
||||
"api-evolve": {
|
||||
"name": "API Evolve",
|
||||
"id": "api-evolve",
|
||||
"description": "Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-api-evolve",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-api-evolve",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 12,
|
||||
"hooks": 5
|
||||
},
|
||||
"tags": [
|
||||
"api",
|
||||
"contracts",
|
||||
"versioning",
|
||||
"openapi",
|
||||
"graphql",
|
||||
"grpc",
|
||||
"deprecation",
|
||||
"breaking-changes",
|
||||
"semver",
|
||||
"governance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"updated_at": "2026-05-07T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
@@ -105,8 +174,8 @@
|
||||
"id": "architecture-guard",
|
||||
"description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.4.0",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.4.0.zip",
|
||||
"version": "1.8.0",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.0.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
|
||||
@@ -131,7 +200,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-05-05T07:26:00Z"
|
||||
"updated_at": "2026-05-07T15:37:14Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
@@ -842,6 +911,44 @@
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-03-31T00:00:00Z"
|
||||
},
|
||||
"fx-to-dotnet": {
|
||||
"name": ".NET Framework to Modern .NET Migration",
|
||||
"id": "fx-to-dotnet",
|
||||
"description": "Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration.",
|
||||
"author": "RogerBestMsft",
|
||||
"version": "0.8.0",
|
||||
"download_url": "https://github.com/RogerBestMsft/spec-kit-FxToNet/releases/download/v0.8.0/fx-to-dotnet.zip",
|
||||
"repository": "https://github.com/RogerBestMsft/spec-kit-FxToNet",
|
||||
"homepage": "https://github.com/RogerBestMsft/spec-kit-FxToNet",
|
||||
"documentation": "https://github.com/RogerBestMsft/spec-kit-FxToNet/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "Microsoft.GitHubCopilot.Modernization.Mcp",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 12,
|
||||
"hooks": 5
|
||||
},
|
||||
"tags": [
|
||||
"dotnet",
|
||||
"migration",
|
||||
"modernization",
|
||||
"framework",
|
||||
"aspnet",
|
||||
"shared-artifact"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-06T00:00:00Z",
|
||||
"updated_at": "2026-05-06T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration 1",
|
||||
"id": "github-issues",
|
||||
@@ -1309,6 +1416,35 @@
|
||||
"created_at": "2026-04-28T00:00:00Z",
|
||||
"updated_at": "2026-04-28T00:00:00Z"
|
||||
},
|
||||
"mde": {
|
||||
"name": "MDE",
|
||||
"id": "mde",
|
||||
"description": "A Spec Kit extension that exposes a minimal model-driven engineering workflow with setup, next, and status commands.",
|
||||
"author": "AI-MDE",
|
||||
"version": "0.5.1",
|
||||
"download_url": "https://github.com/AI-MDE/spec-kit-mde/archive/refs/tags/v0.5.1.zip",
|
||||
"repository": "https://github.com/AI-MDE/spec-kit-mde",
|
||||
"homepage": "https://github.com/AI-MDE/spec-kit-mde",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"mde",
|
||||
"model-driven-engineering",
|
||||
"workflow",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"memory-loader": {
|
||||
"name": "Memory Loader",
|
||||
"id": "memory-loader",
|
||||
@@ -1345,8 +1481,8 @@
|
||||
"id": "memory-md",
|
||||
"description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context",
|
||||
"author": "DyanGalih",
|
||||
"version": "0.7.5",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.5.zip",
|
||||
"version": "0.8.0",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.8.0.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
|
||||
@@ -1371,7 +1507,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-23T00:00:00Z",
|
||||
"updated_at": "2026-05-03T00:00:00Z"
|
||||
"updated_at": "2026-05-07T15:37:14Z"
|
||||
},
|
||||
"memorylint": {
|
||||
"name": "MemoryLint",
|
||||
@@ -2009,6 +2145,38 @@
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
},
|
||||
"schedule": {
|
||||
"name": "Spec Kit Schedule — CP-SAT Agent Orchestrator",
|
||||
"id": "schedule",
|
||||
"description": "Optimal multi-agent task scheduling via CP-SAT solver with DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output",
|
||||
"author": "Julio César Franco Ardila",
|
||||
"version": "0.6.2",
|
||||
"download_url": "https://github.com/jfranc38/spec-kit-schedule/archive/refs/tags/v0.6.2.zip",
|
||||
"repository": "https://github.com/jfranc38/spec-kit-schedule",
|
||||
"homepage": "https://github.com/jfranc38/spec-kit-schedule",
|
||||
"documentation": "https://github.com/jfranc38/spec-kit-schedule/blob/main/README.md",
|
||||
"changelog": "https://github.com/jfranc38/spec-kit-schedule/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"scheduling",
|
||||
"optimization",
|
||||
"multi-agent",
|
||||
"cp-sat",
|
||||
"operations-research"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-06T22:35:00Z",
|
||||
"updated_at": "2026-05-07T17:25:00Z"
|
||||
},
|
||||
"scope": {
|
||||
"name": "Spec Scope",
|
||||
"id": "scope",
|
||||
@@ -2047,8 +2215,8 @@
|
||||
"id": "security-review",
|
||||
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.4.2",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.2.zip",
|
||||
"version": "1.4.5",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.5.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
||||
@@ -2072,7 +2240,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-03T03:24:03Z",
|
||||
"updated_at": "2026-05-03T00:00:00Z"
|
||||
"updated_at": "2026-05-06T22:28:55Z"
|
||||
},
|
||||
"sf": {
|
||||
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-28T00:00:00Z",
|
||||
"updated_at": "2026-04-29T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"claude": {
|
||||
@@ -210,6 +210,15 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills"]
|
||||
},
|
||||
"lingma": {
|
||||
"id": "lingma",
|
||||
"name": "Lingma",
|
||||
"version": "1.0.0",
|
||||
"description": "Lingma IDE skills-based integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide", "skills"]
|
||||
},
|
||||
"pi": {
|
||||
"id": "pi",
|
||||
"name": "Pi Coding Agent",
|
||||
|
||||
@@ -311,6 +311,37 @@
|
||||
"created_at": "2026-04-15T00:00:00Z",
|
||||
"updated_at": "2026-04-15T00:00:00Z"
|
||||
},
|
||||
"mde": {
|
||||
"name": "Model Driven Engineering",
|
||||
"id": "mde",
|
||||
"version": "0.5.1",
|
||||
"description": "Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows.",
|
||||
"author": "Ralph Hanna",
|
||||
"repository": "https://github.com/AI-MDE/spec-kit-preset-mde",
|
||||
"download_url": "https://github.com/AI-MDE/spec-kit-preset-mde/archive/refs/tags/v0.5.1.zip",
|
||||
"homepage": "https://github.com/AI-MDE/spec-kit-preset-mde",
|
||||
"documentation": "https://github.com/AI-MDE/spec-kit-preset-mde/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"extensions": [
|
||||
"mde"
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"templates": 6,
|
||||
"commands": 11
|
||||
},
|
||||
"tags": [
|
||||
"model-driven-engineering",
|
||||
"software-lifecycle",
|
||||
"business-analysis",
|
||||
"business-application",
|
||||
"multi-layered-architecture"
|
||||
],
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"multi-repo-branching": {
|
||||
"name": "Multi-Repo Branching",
|
||||
"id": "multi-repo-branching",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.7.dev0"
|
||||
version = "0.8.8"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -769,6 +769,8 @@ def _install_shared_infra(
|
||||
tracker: StepTracker | None = None,
|
||||
force: bool = False,
|
||||
invoke_separator: str = ".",
|
||||
refresh_managed: bool = False,
|
||||
refresh_hint: str | None = None,
|
||||
) -> bool:
|
||||
"""Install shared infrastructure files into *project_path*.
|
||||
|
||||
@@ -780,9 +782,23 @@ def _install_shared_infra(
|
||||
placeholders using *invoke_separator* (``"."`` for markdown agents,
|
||||
``"-"`` for skills agents).
|
||||
|
||||
When *force* is ``True``, existing files are overwritten with the
|
||||
latest bundled versions. When ``False`` (default), only missing
|
||||
files are added and existing ones are skipped.
|
||||
Overwrite policy:
|
||||
|
||||
* ``force=True`` — overwrite every existing file (still skips symlinks
|
||||
to avoid following links outside the project root).
|
||||
* ``refresh_managed=True`` — overwrite only files whose on-disk hash
|
||||
still matches the previously recorded manifest hash (i.e. unmodified
|
||||
files installed by spec-kit). Files with diverging hashes are
|
||||
treated as user customizations and preserved with a warning.
|
||||
* Default — only add missing files; existing ones are skipped.
|
||||
|
||||
*refresh_hint* — caller-supplied rich-text fragment shown after the
|
||||
"Preserved customized files" warning to tell the user which flag/command
|
||||
they should re-run with to overwrite their customizations. Each caller
|
||||
passes the flag that's actually valid in its CLI surface (e.g.
|
||||
``--refresh-shared-infra`` for ``integration switch``,
|
||||
``--force`` for ``init``/``integration upgrade``). When ``None``, no
|
||||
remediation hint is printed for customizations.
|
||||
|
||||
Returns ``True`` on success.
|
||||
"""
|
||||
@@ -795,6 +811,8 @@ def _install_shared_infra(
|
||||
console=console,
|
||||
force=force,
|
||||
invoke_separator=invoke_separator,
|
||||
refresh_managed=refresh_managed,
|
||||
refresh_hint=refresh_hint,
|
||||
)
|
||||
|
||||
|
||||
@@ -804,6 +822,8 @@ def _install_shared_infra_or_exit(
|
||||
tracker: StepTracker | None = None,
|
||||
force: bool = False,
|
||||
invoke_separator: str = ".",
|
||||
refresh_managed: bool = False,
|
||||
refresh_hint: str | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
return _install_shared_infra(
|
||||
@@ -812,6 +832,8 @@ def _install_shared_infra_or_exit(
|
||||
tracker=tracker,
|
||||
force=force,
|
||||
invoke_separator=invoke_separator,
|
||||
refresh_managed=refresh_managed,
|
||||
refresh_hint=refresh_hint,
|
||||
)
|
||||
except (ValueError, OSError) as exc:
|
||||
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
|
||||
@@ -1762,22 +1784,14 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
On anything else — including a malformed response body — the exception
|
||||
propagates; there is no catch-all (research D-006).
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
GITHUB_API_LATEST,
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
token = None
|
||||
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
|
||||
candidate = os.environ.get(env_var)
|
||||
if candidate is not None:
|
||||
candidate = candidate.strip()
|
||||
if candidate:
|
||||
token = candidate
|
||||
break
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
from .authentication.http import open_url
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with open_url(
|
||||
GITHUB_API_LATEST,
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
@@ -1786,7 +1800,9 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
except urllib.error.HTTPError as e:
|
||||
# Order matters: HTTPError is a subclass of URLError.
|
||||
if e.code == 403:
|
||||
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||
return None, (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
return None, f"HTTP {e.code}"
|
||||
except (urllib.error.URLError, OSError):
|
||||
return None, "offline or timeout"
|
||||
@@ -2589,7 +2605,8 @@ def integration_uninstall(
|
||||
def integration_switch(
|
||||
target: str = typer.Argument(help="Integration key to switch to"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"),
|
||||
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
||||
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
||||
):
|
||||
"""Switch from the current integration to a different one."""
|
||||
@@ -2760,14 +2777,27 @@ def integration_switch(
|
||||
target_integration, current, target, integration_options
|
||||
)
|
||||
|
||||
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||
# _install_shared_infra merges missing files without overwriting).
|
||||
# Refresh shared infrastructure to the current CLI version. Switching
|
||||
# integrations is exactly when stale vendored shared scripts (e.g.
|
||||
# update-agent-context.sh that pre-dates the target integration's
|
||||
# supported-agent list) would silently break the new integration.
|
||||
#
|
||||
# Use refresh_managed=True so only files that match their previously
|
||||
# recorded hash are overwritten — user customizations are detected via
|
||||
# hash divergence and preserved with a warning. Pass
|
||||
# --refresh-shared-infra to overwrite customizations as well. See #2293.
|
||||
_install_shared_infra_or_exit(
|
||||
project_root,
|
||||
selected_script,
|
||||
force=refresh_shared_infra,
|
||||
refresh_managed=True,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
target_integration, current, target, parsed_options
|
||||
),
|
||||
refresh_hint=(
|
||||
"To overwrite customizations, re-run with "
|
||||
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
ensure_executable_scripts(project_root)
|
||||
@@ -3381,7 +3411,9 @@ def preset_add(
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
try:
|
||||
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
with _open_url(from_url, timeout=60) as response:
|
||||
zip_path.write_bytes(response.read())
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||
@@ -4285,7 +4317,9 @@ def extension_add(
|
||||
zip_path = download_dir / f"{extension}-url-download.zip"
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
with _open_url(from_url, timeout=60) as response:
|
||||
zip_data = response.read()
|
||||
zip_path.write_bytes(zip_data)
|
||||
|
||||
@@ -5500,7 +5534,7 @@ def workflow_add(
|
||||
if source.startswith("http://") or source.startswith("https://"):
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen # noqa: S310
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
parsed_src = urlparse(source)
|
||||
src_host = parsed_src.hostname or ""
|
||||
@@ -5517,7 +5551,7 @@ def workflow_add(
|
||||
|
||||
import tempfile
|
||||
try:
|
||||
with urlopen(source, timeout=30) as resp: # noqa: S310
|
||||
with _open_url(source, timeout=30) as resp:
|
||||
final_url = resp.geturl()
|
||||
final_parsed = urlparse(final_url)
|
||||
final_host = final_parsed.hostname or ""
|
||||
@@ -5613,10 +5647,10 @@ def workflow_add(
|
||||
workflow_file = workflow_dir / "workflow.yml"
|
||||
|
||||
try:
|
||||
from urllib.request import urlopen # noqa: S310 — URL comes from catalog
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
workflow_dir.mkdir(parents=True, exist_ok=True)
|
||||
with urlopen(workflow_url, timeout=30) as response: # noqa: S310
|
||||
with _open_url(workflow_url, timeout=30) as response:
|
||||
# Validate final URL after redirects
|
||||
final_url = response.geturl()
|
||||
final_parsed = urlparse(final_url)
|
||||
|
||||
50
src/specify_cli/authentication/__init__.py
Normal file
50
src/specify_cli/authentication/__init__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Authentication provider registry for multi-platform support.
|
||||
|
||||
Credentials are **opt-in only**. No authentication headers are sent unless
|
||||
the user creates ``~/.specify/auth.json`` mapping hosts to providers.
|
||||
Provider classes define *how* to authenticate (Bearer, Basic-PAT, etc.)
|
||||
while the config file defines *where* and *with what credentials*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import AuthProvider
|
||||
|
||||
# Maps provider key → AuthProvider class instance.
|
||||
AUTH_REGISTRY: dict[str, AuthProvider] = {}
|
||||
|
||||
|
||||
def _register(provider: AuthProvider) -> None:
|
||||
"""Register a provider instance in the global registry.
|
||||
|
||||
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
|
||||
"""
|
||||
key = provider.key
|
||||
if not key:
|
||||
raise ValueError("Cannot register provider with an empty key.")
|
||||
if key in AUTH_REGISTRY:
|
||||
raise KeyError(f"Provider with key {key!r} is already registered.")
|
||||
AUTH_REGISTRY[key] = provider
|
||||
|
||||
|
||||
def get_provider(key: str) -> AuthProvider | None:
|
||||
"""Return the provider for *key*, or ``None`` if not registered."""
|
||||
return AUTH_REGISTRY.get(key)
|
||||
|
||||
|
||||
# -- Register built-in providers -----------------------------------------
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
"""Register all built-in authentication providers (alphabetical)."""
|
||||
from .azure_devops import AzureDevOpsAuth
|
||||
from .github import GitHubAuth
|
||||
|
||||
_register(AzureDevOpsAuth())
|
||||
_register(GitHubAuth())
|
||||
|
||||
|
||||
_register_builtins()
|
||||
117
src/specify_cli/authentication/azure_devops.py
Normal file
117
src/specify_cli/authentication/azure_devops.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Azure DevOps authentication provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json as _json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import AuthProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import AuthConfigEntry
|
||||
|
||||
# Azure DevOps resource ID for OAuth / Azure AD token acquisition.
|
||||
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
|
||||
|
||||
|
||||
class AzureDevOpsAuth(AuthProvider):
|
||||
"""Azure DevOps authentication provider.
|
||||
|
||||
Supports four auth schemes:
|
||||
|
||||
* ``basic-pat`` — PAT with empty username, Base64-encoded as ``:<PAT>``
|
||||
* ``bearer`` — pre-acquired OAuth / Azure AD token
|
||||
* ``azure-cli`` — acquires a token via ``az account get-access-token``
|
||||
* ``azure-ad`` — acquires a token via OAuth2 client credentials flow
|
||||
"""
|
||||
|
||||
key = "azure-devops"
|
||||
supported_auth_schemes = ("basic-pat", "bearer", "azure-cli", "azure-ad")
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
"""Build the ``Authorization`` header for the given scheme."""
|
||||
if auth_scheme == "basic-pat":
|
||||
encoded = base64.b64encode(f":{token}".encode("ascii")).decode("ascii")
|
||||
return {"Authorization": f"Basic {encoded}"}
|
||||
if auth_scheme in ("bearer", "azure-cli", "azure-ad"):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
raise ValueError(
|
||||
f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}"
|
||||
)
|
||||
|
||||
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
|
||||
"""Resolve token, with special handling for azure-cli and azure-ad."""
|
||||
if entry.auth == "azure-cli":
|
||||
return self._acquire_via_az_cli()
|
||||
if entry.auth == "azure-ad":
|
||||
return self._acquire_via_client_credentials(entry)
|
||||
return super().resolve_token(entry)
|
||||
|
||||
# -- Token acquisition ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _acquire_via_az_cli() -> str | None:
|
||||
"""Run ``az account get-access-token`` and return the access token."""
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603, S607
|
||||
[
|
||||
"az",
|
||||
"account",
|
||||
"get-access-token",
|
||||
"--resource",
|
||||
_ADO_RESOURCE_ID,
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
payload = _json.loads(result.stdout)
|
||||
token = payload.get("accessToken", "").strip()
|
||||
return token or None
|
||||
except (OSError, subprocess.TimeoutExpired, _json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
|
||||
"""Acquire a token via OAuth2 client credentials flow."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
if not entry.tenant_id or not entry.client_id or not entry.client_secret_env:
|
||||
return None
|
||||
client_secret = os.environ.get(entry.client_secret_env, "").strip()
|
||||
if not client_secret:
|
||||
return None
|
||||
|
||||
url = (
|
||||
f"https://login.microsoftonline.com/{entry.tenant_id}"
|
||||
"/oauth2/v2.0/token"
|
||||
)
|
||||
from urllib.parse import urlencode
|
||||
body = urlencode({
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": entry.client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": f"{_ADO_RESOURCE_ID}/.default",
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
|
||||
payload = _json.loads(resp.read().decode("utf-8"))
|
||||
token = payload.get("access_token", "").strip()
|
||||
return token or None
|
||||
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
57
src/specify_cli/authentication/base.py
Normal file
57
src/specify_cli/authentication/base.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Abstract base class for authentication providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import AuthConfigEntry
|
||||
|
||||
|
||||
class AuthProvider(ABC):
|
||||
"""Abstract base class every authentication provider must implement.
|
||||
|
||||
Subclasses must set:
|
||||
|
||||
* ``key`` — unique provider identifier (e.g. ``"github"``, ``"azure-devops"``)
|
||||
* ``supported_auth_schemes`` — tuple of auth scheme strings this provider handles
|
||||
|
||||
And implement:
|
||||
|
||||
* ``auth_headers(token, auth_scheme)`` — build headers from a resolved token
|
||||
* ``resolve_token(entry)`` — obtain the token for a config entry
|
||||
"""
|
||||
|
||||
key: str = ""
|
||||
"""Unique provider identifier."""
|
||||
|
||||
supported_auth_schemes: tuple[str, ...] = ()
|
||||
"""Auth schemes this provider supports (e.g. ``("bearer",)``)."""
|
||||
|
||||
@abstractmethod
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
"""Build authentication headers for *token* using *auth_scheme*.
|
||||
|
||||
Must return a dict with at least an ``Authorization`` key.
|
||||
"""
|
||||
|
||||
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
|
||||
"""Resolve the token for *entry*.
|
||||
|
||||
Default implementation reads from ``entry.token`` directly
|
||||
or from the environment variable named by ``entry.token_env``.
|
||||
Override for schemes that acquire tokens dynamically
|
||||
(e.g. ``azure-cli``, ``azure-ad``).
|
||||
"""
|
||||
import os
|
||||
|
||||
if entry.token:
|
||||
return entry.token.strip() or None
|
||||
if entry.token_env:
|
||||
val = os.environ.get(entry.token_env)
|
||||
if val is not None:
|
||||
val = val.strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
209
src/specify_cli/authentication/config.py
Normal file
209
src/specify_cli/authentication/config.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Authentication configuration loader.
|
||||
|
||||
Reads ``~/.specify/auth.json`` to determine which hosts receive credentials
|
||||
and which provider/auth-scheme to use. No credentials are sent without
|
||||
an explicit opt-in via this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfigEntry:
|
||||
"""A single provider entry from ``auth.json``."""
|
||||
|
||||
hosts: tuple[str, ...]
|
||||
provider: str
|
||||
auth: str
|
||||
token: str | None = None
|
||||
token_env: str | None = None
|
||||
# Azure AD service-principal fields
|
||||
tenant_id: str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret_env: str | None = None
|
||||
|
||||
|
||||
def _default_config_path() -> Path:
|
||||
"""Return ``~/.specify/auth.json``."""
|
||||
return Path.home() / ".specify" / "auth.json"
|
||||
|
||||
|
||||
def _is_valid_host_pattern(pattern: str) -> bool:
|
||||
"""Return True for safe host patterns: exact hostnames or ``*.suffix`` only.
|
||||
|
||||
Rejects patterns like ``*github.com`` (which would match
|
||||
``github.com.evil.com``) or multi-wildcard forms. Only these two
|
||||
forms are accepted:
|
||||
|
||||
* ``example.com`` — exact hostname
|
||||
* ``*.example.com`` — leading ``*.`` wildcard; matches subdomains
|
||||
such as ``myorg.example.com`` but not ``example.com`` itself
|
||||
"""
|
||||
if "*" not in pattern:
|
||||
return True # exact hostname — already validated as non-empty
|
||||
# Only *.suffix is allowed; no other wildcard positions
|
||||
return pattern.startswith("*.") and "*" not in pattern[2:]
|
||||
|
||||
|
||||
def load_auth_config(
|
||||
path: Path | None = None,
|
||||
) -> list[AuthConfigEntry]:
|
||||
"""Load and validate ``auth.json``, returning configured entries.
|
||||
|
||||
Returns an empty list when the file does not exist — this means
|
||||
all HTTP requests will be unauthenticated (opt-in model).
|
||||
|
||||
Raises ``ValueError`` on schema violations. Callers that want
|
||||
misconfigurations to fail fast can allow this exception to
|
||||
propagate; higher-level HTTP helpers may instead catch it,
|
||||
warn, and continue with unauthenticated requests.
|
||||
"""
|
||||
config_path = path or _default_config_path()
|
||||
|
||||
if not config_path.is_file():
|
||||
return []
|
||||
|
||||
# Warn (but don't fail) if the file is world-readable (POSIX only).
|
||||
if os.name != "nt":
|
||||
try:
|
||||
mode = config_path.stat().st_mode
|
||||
if mode & (stat.S_IRGRP | stat.S_IROTH):
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"{config_path} is readable by group/others. "
|
||||
"Consider restricting with: chmod 600 "
|
||||
f"{config_path}",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
except OSError:
|
||||
pass # stat failed — skip permission check
|
||||
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}")
|
||||
|
||||
providers_raw = raw.get("providers")
|
||||
if not isinstance(providers_raw, list):
|
||||
raise ValueError("auth.json must contain a 'providers' array")
|
||||
|
||||
entries: list[AuthConfigEntry] = []
|
||||
for i, entry_raw in enumerate(providers_raw):
|
||||
if not isinstance(entry_raw, dict):
|
||||
raise ValueError(f"providers[{i}]: must be a JSON object")
|
||||
|
||||
hosts = entry_raw.get("hosts")
|
||||
if not isinstance(hosts, list) or not hosts:
|
||||
raise ValueError(f"providers[{i}]: 'hosts' must be a non-empty array")
|
||||
if not all(isinstance(h, str) and h.strip() for h in hosts):
|
||||
raise ValueError(f"providers[{i}]: each host must be a non-empty string")
|
||||
# Normalize hosts: strip whitespace and lowercase
|
||||
hosts = [h.strip().lower() for h in hosts]
|
||||
# Reject dangerous wildcard forms (e.g. *github.com matches github.com.evil.com)
|
||||
for h in hosts:
|
||||
if not _is_valid_host_pattern(h):
|
||||
raise ValueError(
|
||||
f"providers[{i}]: invalid host pattern {h!r}. "
|
||||
"Only exact hostnames or '*.suffix' forms are allowed "
|
||||
"(e.g. 'github.com' or '*.visualstudio.com')."
|
||||
)
|
||||
|
||||
provider = entry_raw.get("provider", "")
|
||||
if not isinstance(provider, str) or not provider:
|
||||
raise ValueError(f"providers[{i}]: 'provider' must be a non-empty string")
|
||||
|
||||
auth = entry_raw.get("auth", "")
|
||||
if not isinstance(auth, str) or not auth:
|
||||
raise ValueError(f"providers[{i}]: 'auth' must be a non-empty string")
|
||||
|
||||
token = entry_raw.get("token")
|
||||
token_env = entry_raw.get("token_env")
|
||||
|
||||
# Validate token/token_env types
|
||||
if token is not None and (not isinstance(token, str) or not token.strip()):
|
||||
raise ValueError(f"providers[{i}]: 'token' must be a non-empty string")
|
||||
if token_env is not None and (not isinstance(token_env, str) or not token_env.strip()):
|
||||
raise ValueError(f"providers[{i}]: 'token_env' must be a non-empty string")
|
||||
|
||||
# Validate provider+scheme compatibility
|
||||
from . import get_provider as _get_provider
|
||||
_prov = _get_provider(provider)
|
||||
if _prov is None:
|
||||
from . import AUTH_REGISTRY
|
||||
raise ValueError(
|
||||
f"providers[{i}]: unknown provider {provider!r}; "
|
||||
f"registered: {sorted(AUTH_REGISTRY.keys())}"
|
||||
)
|
||||
if auth not in _prov.supported_auth_schemes:
|
||||
raise ValueError(
|
||||
f"providers[{i}]: provider {provider!r} does not support "
|
||||
f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}"
|
||||
)
|
||||
|
||||
# Validate token source based on auth scheme
|
||||
if auth in ("bearer", "basic-pat"):
|
||||
if not token and not token_env:
|
||||
raise ValueError(
|
||||
f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'"
|
||||
)
|
||||
elif auth == "azure-ad":
|
||||
tenant_id = entry_raw.get("tenant_id")
|
||||
client_id = entry_raw.get("client_id")
|
||||
client_secret_env = entry_raw.get("client_secret_env")
|
||||
if not all([tenant_id, client_id, client_secret_env]):
|
||||
raise ValueError(
|
||||
f"providers[{i}]: auth='azure-ad' requires "
|
||||
"'tenant_id', 'client_id', and 'client_secret_env'"
|
||||
)
|
||||
for field_name, field_val in [
|
||||
("tenant_id", tenant_id),
|
||||
("client_id", client_id),
|
||||
("client_secret_env", client_secret_env),
|
||||
]:
|
||||
if not isinstance(field_val, str) or not field_val.strip():
|
||||
raise ValueError(
|
||||
f"providers[{i}]: '{field_name}' must be a non-empty string"
|
||||
)
|
||||
# azure-cli needs no extra fields
|
||||
|
||||
entries.append(
|
||||
AuthConfigEntry(
|
||||
hosts=tuple(hosts),
|
||||
provider=provider,
|
||||
auth=auth,
|
||||
token=token,
|
||||
token_env=token_env,
|
||||
tenant_id=entry_raw.get("tenant_id"),
|
||||
client_id=entry_raw.get("client_id"),
|
||||
client_secret_env=entry_raw.get("client_secret_env"),
|
||||
)
|
||||
)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def find_entries_for_url(
|
||||
url: str, entries: list[AuthConfigEntry]
|
||||
) -> list[AuthConfigEntry]:
|
||||
"""Return entries whose ``hosts`` match the hostname of *url*."""
|
||||
hostname = (urlparse(url).hostname or "").lower()
|
||||
if not hostname:
|
||||
return []
|
||||
return [
|
||||
e
|
||||
for e in entries
|
||||
if any(
|
||||
pattern == hostname or fnmatch(hostname, pattern)
|
||||
for pattern in e.hosts
|
||||
)
|
||||
]
|
||||
24
src/specify_cli/authentication/github.py
Normal file
24
src/specify_cli/authentication/github.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""GitHub authentication provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import AuthProvider
|
||||
|
||||
|
||||
class GitHubAuth(AuthProvider):
|
||||
"""GitHub authentication provider.
|
||||
|
||||
Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs,
|
||||
OAuth tokens, and GitHub App installation tokens.
|
||||
"""
|
||||
|
||||
key = "github"
|
||||
supported_auth_schemes = ("bearer",)
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
"""Return ``Authorization: Bearer <token>``."""
|
||||
if auth_scheme != "bearer":
|
||||
raise ValueError(
|
||||
f"GitHubAuth does not support auth scheme {auth_scheme!r}"
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
149
src/specify_cli/authentication/http.py
Normal file
149
src/specify_cli/authentication/http.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Authenticated HTTP helpers driven by ``~/.specify/auth.json``.
|
||||
|
||||
No credentials are sent unless the user has created ``auth.json``.
|
||||
For each outbound URL the helper matches the hostname against
|
||||
configured entries, resolves the token via the appropriate provider
|
||||
class, and attaches auth headers. Redirect safety is enforced:
|
||||
the ``Authorization`` header is stripped when a redirect leaves the
|
||||
entry's declared hosts. On 401/403 the next matching entry is tried,
|
||||
then unauthenticated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from fnmatch import fnmatch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import get_provider
|
||||
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
|
||||
|
||||
|
||||
_config_override: list[AuthConfigEntry] | None = None
|
||||
_config_cache: list[AuthConfigEntry] | None = None # None = not yet loaded
|
||||
|
||||
|
||||
def _load_config() -> list[AuthConfigEntry]:
|
||||
"""Load auth config, using override if set (for testing).
|
||||
|
||||
The result is cached per-process so ``auth.json`` is read at most once,
|
||||
and any warning about a malformed file fires only once.
|
||||
"""
|
||||
global _config_cache
|
||||
if _config_override is not None:
|
||||
return _config_override
|
||||
if _config_cache is not None:
|
||||
return _config_cache
|
||||
try:
|
||||
_config_cache = load_auth_config()
|
||||
except (ValueError, OSError) as exc:
|
||||
import warnings
|
||||
config_path = _default_config_path()
|
||||
warnings.warn(
|
||||
f"Failed to load {config_path}: {exc}. "
|
||||
"All requests will be unauthenticated.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
_config_cache = []
|
||||
return _config_cache
|
||||
|
||||
|
||||
def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
"""Return True if *hostname* matches any pattern in *hosts*."""
|
||||
hostname = hostname.lower()
|
||||
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
|
||||
|
||||
def __init__(self, hosts: tuple[str, ...]) -> None:
|
||||
super().__init__()
|
||||
self._hosts = hosts
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
original_auth = (
|
||||
req.get_header("Authorization")
|
||||
or req.unredirected_hdrs.get("Authorization")
|
||||
)
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
hostname = (urlparse(newurl).hostname or "").lower()
|
||||
if _hostname_in_hosts(hostname, self._hosts):
|
||||
if original_auth:
|
||||
new_req.add_unredirected_header("Authorization", original_auth)
|
||||
else:
|
||||
new_req.headers.pop("Authorization", None)
|
||||
new_req.unredirected_hdrs.pop("Authorization", None)
|
||||
return new_req
|
||||
|
||||
|
||||
def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urllib.request.Request:
|
||||
"""Build a :class:`~urllib.request.Request`, attaching auth when config matches.
|
||||
|
||||
Uses the first matching entry from ``auth.json`` whose token resolves.
|
||||
Returns a plain request when no entry matches or the file doesn't exist.
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if extra_headers:
|
||||
# Strip Authorization from extra_headers to prevent bypass
|
||||
headers.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"})
|
||||
# Auth headers applied last — cannot be overridden by extra_headers
|
||||
entries = find_entries_for_url(url, _load_config())
|
||||
for entry in entries:
|
||||
provider = get_provider(entry.provider)
|
||||
if provider is None:
|
||||
continue
|
||||
token = provider.resolve_token(entry)
|
||||
if token:
|
||||
headers.update(provider.auth_headers(token, entry.auth))
|
||||
break
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None):
|
||||
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
|
||||
|
||||
1. Find ``auth.json`` entries whose hosts match the URL.
|
||||
2. For each entry, resolve the token and try the request.
|
||||
3. On 401/403 move to the next matching entry.
|
||||
4. After all entries exhausted (or none matched), try unauthenticated.
|
||||
5. Non-auth errors (404, 500, network) raise immediately.
|
||||
|
||||
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
|
||||
"""
|
||||
entries = find_entries_for_url(url, _load_config())
|
||||
|
||||
def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
|
||||
merged = {}
|
||||
if extra_headers:
|
||||
# Strip Authorization from extra_headers to prevent bypass
|
||||
merged.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"})
|
||||
# Auth headers applied last — cannot be overridden by extra_headers
|
||||
merged.update(auth_headers)
|
||||
return urllib.request.Request(url, headers=merged)
|
||||
|
||||
# Try each matching entry
|
||||
for entry in entries:
|
||||
provider = get_provider(entry.provider)
|
||||
if provider is None:
|
||||
continue
|
||||
token = provider.resolve_token(entry)
|
||||
if not token:
|
||||
continue
|
||||
|
||||
req = _make_req(provider.auth_headers(token, entry.auth))
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
|
||||
try:
|
||||
return opener.open(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (401, 403):
|
||||
exc.close()
|
||||
continue # try next entry
|
||||
raise
|
||||
|
||||
# No entry worked (or none matched) — unauthenticated fallback
|
||||
req = _make_req({})
|
||||
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
|
||||
@@ -1707,20 +1707,20 @@ class ExtensionCatalog:
|
||||
raise ValidationError("Catalog URL must be a valid URL with a host.")
|
||||
|
||||
def _make_request(self, url: str):
|
||||
"""Build a urllib Request, adding a GitHub auth header when available.
|
||||
"""Build a urllib Request, adding auth headers when a provider matches.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.build_github_request`.
|
||||
Delegates to :func:`specify_cli.authentication.http.build_request`.
|
||||
"""
|
||||
from specify_cli._github_http import build_github_request
|
||||
return build_github_request(url)
|
||||
from specify_cli.authentication.http import build_request
|
||||
return build_request(url)
|
||||
|
||||
def _open_url(self, url: str, timeout: int = 10):
|
||||
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||
"""Open a URL with provider-based auth, trying each configured provider.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.open_github_url`.
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
"""
|
||||
from specify_cli._github_http import open_github_url
|
||||
return open_github_url(url, timeout)
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
@@ -66,6 +66,7 @@ def _register_builtins() -> None:
|
||||
from .kilocode import KilocodeIntegration
|
||||
from .kimi import KimiIntegration
|
||||
from .kiro_cli import KiroCliIntegration
|
||||
from .lingma import LingmaIntegration
|
||||
from .opencode import OpencodeIntegration
|
||||
from .pi import PiIntegration
|
||||
from .qodercli import QodercliIntegration
|
||||
@@ -97,6 +98,7 @@ def _register_builtins() -> None:
|
||||
_register(KilocodeIntegration())
|
||||
_register(KimiIntegration())
|
||||
_register(KiroCliIntegration())
|
||||
_register(LingmaIntegration())
|
||||
_register(OpencodeIntegration())
|
||||
_register(PiIntegration())
|
||||
_register(QodercliIntegration())
|
||||
|
||||
@@ -20,6 +20,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import IntegrationManifest
|
||||
|
||||
@@ -606,6 +608,7 @@ class IntegrationBase(ABC):
|
||||
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
|
||||
if ctx_path.suffix == ".mdc":
|
||||
import re
|
||||
|
||||
# Delete the file if only YAML frontmatter remains (no body content)
|
||||
frontmatter_only = re.match(
|
||||
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
|
||||
@@ -953,7 +956,6 @@ class TomlIntegration(IntegrationBase):
|
||||
and ``>``) keep their YAML semantics instead of being treated as
|
||||
raw text.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
frontmatter_text, _ = TomlIntegration._split_frontmatter(content)
|
||||
if not frontmatter_text:
|
||||
@@ -1140,7 +1142,6 @@ class YamlIntegration(IntegrationBase):
|
||||
@staticmethod
|
||||
def _extract_frontmatter(content: str) -> dict[str, Any]:
|
||||
"""Extract frontmatter as a dict from YAML frontmatter block."""
|
||||
import yaml
|
||||
|
||||
if not content.startswith("---"):
|
||||
return {}
|
||||
@@ -1201,24 +1202,38 @@ class YamlIntegration(IntegrationBase):
|
||||
text = text[len("speckit.") :]
|
||||
return text.replace(".", " ").replace("-", " ").replace("_", " ").title()
|
||||
|
||||
@staticmethod
|
||||
def _render_yaml(title: str, description: str, body: str, source_id: str) -> str:
|
||||
|
||||
@classmethod
|
||||
def _build_yaml_header(cls, title: str, description: str) -> dict[str, Any]:
|
||||
"""Build the base YAML header."""
|
||||
header = {
|
||||
"version": "1.0.0",
|
||||
"title": title,
|
||||
"description": description,
|
||||
"author": {"contact": "spec-kit"},
|
||||
"parameters": [
|
||||
{
|
||||
"key": "args",
|
||||
"input_type": "string",
|
||||
"requirement": "optional",
|
||||
"default": "",
|
||||
"description": "User input passed to the command.",
|
||||
}
|
||||
],
|
||||
"extensions": [{"type": "builtin", "name": "developer"}],
|
||||
"activities": ["Spec-Driven Development"],
|
||||
}
|
||||
return header
|
||||
|
||||
@classmethod
|
||||
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
|
||||
"""Render a YAML recipe file from title, description, and body.
|
||||
|
||||
Produces a Goose-compatible recipe with a literal block scalar
|
||||
for the prompt content. Uses ``yaml.safe_dump()`` for the
|
||||
header fields to ensure proper escaping.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
header = {
|
||||
"version": "1.0.0",
|
||||
"title": title,
|
||||
"description": description,
|
||||
"author": {"contact": "spec-kit"},
|
||||
"extensions": [{"type": "builtin", "name": "developer"}],
|
||||
"activities": ["Spec-Driven Development"],
|
||||
}
|
||||
header = cls._build_yaml_header(title, description)
|
||||
|
||||
header_yaml = yaml.safe_dump(
|
||||
header,
|
||||
@@ -1227,12 +1242,20 @@ class YamlIntegration(IntegrationBase):
|
||||
default_flow_style=False,
|
||||
).strip()
|
||||
|
||||
# Indent each line for YAML block scalar
|
||||
# Indent the body for YAML block scalar
|
||||
indented = "\n".join(f" {line}" for line in body.split("\n"))
|
||||
|
||||
lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"]
|
||||
lines = [
|
||||
header_yaml,
|
||||
"prompt: |",
|
||||
indented,
|
||||
"",
|
||||
f"# Source: {source_id}",
|
||||
]
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -1391,7 +1414,6 @@ class SkillsIntegration(IntegrationBase):
|
||||
template. Each SKILL.md has normalised frontmatter containing
|
||||
``name``, ``description``, ``compatibility``, and ``metadata``.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
templates = self.list_command_templates()
|
||||
if not templates:
|
||||
|
||||
@@ -265,7 +265,6 @@ class IntegrationCatalog:
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch one catalog, with per-URL caching."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
|
||||
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
|
||||
@@ -289,7 +288,9 @@ class IntegrationCatalog:
|
||||
pass # Cache cleanup is best-effort; ignore deletion failures.
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(entry.url, timeout=10) as resp:
|
||||
from specify_cli.authentication.http import open_url
|
||||
|
||||
with open_url(entry.url, timeout=10) as resp:
|
||||
# Validate final URL after redirects
|
||||
final_url = resp.geturl()
|
||||
if final_url != entry.url:
|
||||
|
||||
41
src/specify_cli/integrations/lingma/__init__.py
Normal file
41
src/specify_cli/integrations/lingma/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Lingma IDE integration. — skills-based agent.
|
||||
|
||||
Lingma IDE uses ``.lingma/skills/speckit-<name>/SKILL.md`` layout.
|
||||
In Specify CLI, the Lingma integration is skills-only, and ``--skills``
|
||||
defaults to ``True``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
|
||||
class LingmaIntegration(SkillsIntegration):
|
||||
"""Integration for Lingma IDE."""
|
||||
|
||||
key = "lingma"
|
||||
config = {
|
||||
"name": "Lingma",
|
||||
"folder": ".lingma/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".lingma/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = ".lingma/rules/specify-rules.md"
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills",
|
||||
),
|
||||
]
|
||||
@@ -1845,20 +1845,20 @@ class PresetCatalog:
|
||||
)
|
||||
|
||||
def _make_request(self, url: str):
|
||||
"""Build a urllib Request, adding a GitHub auth header when available.
|
||||
"""Build a urllib Request, adding auth headers when a provider matches.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.build_github_request`.
|
||||
Delegates to :func:`specify_cli.authentication.http.build_request`.
|
||||
"""
|
||||
from specify_cli._github_http import build_github_request
|
||||
return build_github_request(url)
|
||||
from specify_cli.authentication.http import build_request
|
||||
return build_request(url)
|
||||
|
||||
def _open_url(self, url: str, timeout: int = 10):
|
||||
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||
"""Open a URL with provider-based auth, trying each configured provider.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.open_github_url`.
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
"""
|
||||
from specify_cli._github_http import open_github_url
|
||||
return open_github_url(url, timeout)
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
@@ -11,6 +11,15 @@ from .integrations.base import IntegrationBase
|
||||
from .integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class SymlinkedSharedPathError(ValueError):
|
||||
"""Raised when a shared infrastructure path or ancestor is a symlink.
|
||||
|
||||
Distinct from other unsafe-path errors so callers can preserve symlinked
|
||||
destinations as customizations while still letting genuine safety errors
|
||||
(e.g. path escape, not-a-directory) propagate and abort the operation.
|
||||
"""
|
||||
|
||||
|
||||
def load_speckit_manifest(
|
||||
project_path: Path,
|
||||
*,
|
||||
@@ -89,7 +98,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
|
||||
current = current / part
|
||||
label = _shared_destination_label(project_path, current)
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
if current.exists():
|
||||
if not current.is_dir():
|
||||
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
|
||||
@@ -102,7 +111,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
|
||||
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
|
||||
current.mkdir()
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
try:
|
||||
current.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
@@ -119,7 +128,7 @@ def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None
|
||||
current = current / part
|
||||
label = _shared_destination_label(project_path, current)
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
if not current.exists():
|
||||
continue
|
||||
if not current.is_dir():
|
||||
@@ -145,7 +154,7 @@ def _ensure_safe_shared_destination(
|
||||
_validate_safe_shared_directory(project_path, dest.parent)
|
||||
label = _shared_destination_label(project_path, dest)
|
||||
if dest.is_symlink():
|
||||
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
|
||||
|
||||
if dest.exists():
|
||||
try:
|
||||
@@ -242,58 +251,147 @@ def install_shared_infra(
|
||||
console: Any,
|
||||
force: bool = False,
|
||||
invoke_separator: str = ".",
|
||||
refresh_managed: bool = False,
|
||||
refresh_hint: str | None = None,
|
||||
) -> bool:
|
||||
"""Install shared scripts and templates into *project_path*."""
|
||||
"""Install shared scripts and templates into *project_path*.
|
||||
|
||||
When ``refresh_managed`` is True, files whose on-disk hash still matches
|
||||
the previously recorded manifest hash are overwritten with the bundled
|
||||
version. Files whose hash diverges are treated as user customizations and
|
||||
preserved with a warning. ``force=True`` overwrites every regular file
|
||||
(symlinks and symlinked-parent destinations are always preserved with a
|
||||
warning — the safe-destination check refuses to follow them so writes
|
||||
cannot escape the project root). ``refresh_hint`` is shown after the
|
||||
customization warning to tell the user which flag would overwrite their
|
||||
customizations.
|
||||
"""
|
||||
from .integrations.manifest import _sha256
|
||||
|
||||
manifest = load_speckit_manifest(project_path, version=version, console=console)
|
||||
prior_hashes = dict(manifest.files)
|
||||
|
||||
def _is_managed(rel: str, dst: Path) -> bool:
|
||||
expected = prior_hashes.get(rel)
|
||||
if not expected or not dst.is_file() or dst.is_symlink():
|
||||
return False
|
||||
try:
|
||||
return _sha256(dst) == expected
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
skipped_files: list[str] = []
|
||||
preserved_user_files: list[str] = []
|
||||
symlinked_files: list[str] = []
|
||||
planned_copies: list[tuple[Path, str, bytes, int]] = []
|
||||
planned_templates: list[tuple[Path, str, str]] = []
|
||||
|
||||
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
|
||||
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
|
||||
if not dst.exists():
|
||||
return True, None
|
||||
if force:
|
||||
return True, None
|
||||
if refresh_managed:
|
||||
if _is_managed(rel, dst):
|
||||
return True, None
|
||||
if rel in prior_hashes:
|
||||
return False, "preserved"
|
||||
return False, "skip"
|
||||
return False, "skip"
|
||||
|
||||
def _safe_dest_or_bucket(dst: Path, rel: str, *, parent_must_exist: bool = True) -> bool:
|
||||
"""Run the safe-destination check and bucket symlinked paths.
|
||||
|
||||
Returns True when the destination is safe to consider (write or skip).
|
||||
Returns False (and records *rel* under ``symlinked_files``) when the
|
||||
destination or any of its ancestors is a symlink — those paths can't
|
||||
be written to safely, but they shouldn't abort the whole switch
|
||||
either. They're surfaced as a separate "symlinked" warning bucket.
|
||||
|
||||
Other unsafe-path errors (e.g. path escape, parent-not-a-directory)
|
||||
are NOT caught here: they re-raise so the operation aborts, since
|
||||
treating them as "symlinked" would mask security-relevant failures.
|
||||
"""
|
||||
try:
|
||||
_ensure_safe_shared_destination(project_path, dst, parent_must_exist=parent_must_exist)
|
||||
except SymlinkedSharedPathError:
|
||||
symlinked_files.append(rel)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _ensure_or_bucket_dir(directory: Path) -> bool:
|
||||
"""Create *directory* unless an ancestor is symlinked.
|
||||
|
||||
Returns True when the directory is safe to use. Returns False (and
|
||||
records the path under ``symlinked_files``) when a symlink ancestor
|
||||
forces us to skip the whole subtree. Other unsafe-path errors
|
||||
(escape, not-a-directory) re-raise so the operation aborts.
|
||||
"""
|
||||
try:
|
||||
_ensure_safe_shared_directory(project_path, directory)
|
||||
except SymlinkedSharedPathError:
|
||||
symlinked_files.append(directory.relative_to(project_path).as_posix())
|
||||
return False
|
||||
return True
|
||||
|
||||
scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if scripts_src.is_dir():
|
||||
dest_scripts = project_path / ".specify" / "scripts"
|
||||
_ensure_safe_shared_directory(project_path, dest_scripts)
|
||||
variant_dir = "bash" if script_type == "sh" else "powershell"
|
||||
variant_src = scripts_src / variant_dir
|
||||
if variant_src.is_dir():
|
||||
dest_variant = dest_scripts / variant_dir
|
||||
_ensure_safe_shared_directory(project_path, dest_variant)
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
if _ensure_or_bucket_dir(dest_scripts):
|
||||
variant_dir = "bash" if script_type == "sh" else "powershell"
|
||||
variant_src = scripts_src / variant_dir
|
||||
if variant_src.is_dir():
|
||||
dest_variant = dest_scripts / variant_dir
|
||||
if _ensure_or_bucket_dir(dest_variant):
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
_ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False)
|
||||
if dst_path.exists() and not force:
|
||||
skipped_files.append(dst_path.relative_to(project_path).as_posix())
|
||||
continue
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
|
||||
continue
|
||||
write, bucket = _decide_overwrite(rel, dst_path)
|
||||
if not write:
|
||||
if bucket == "preserved":
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
continue
|
||||
|
||||
_ensure_safe_shared_directory(project_path, dst_path.parent)
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
|
||||
|
||||
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if templates_src.is_dir():
|
||||
dest_templates = project_path / ".specify" / "templates"
|
||||
_ensure_safe_shared_directory(project_path, dest_templates)
|
||||
for src in templates_src.iterdir():
|
||||
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
|
||||
continue
|
||||
if _ensure_or_bucket_dir(dest_templates):
|
||||
for src in templates_src.iterdir():
|
||||
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
|
||||
continue
|
||||
|
||||
dst = dest_templates / src.name
|
||||
_ensure_safe_shared_destination(project_path, dst)
|
||||
if dst.exists() and not force:
|
||||
skipped_files.append(dst.relative_to(project_path).as_posix())
|
||||
continue
|
||||
dst = dest_templates / src.name
|
||||
rel = dst.relative_to(project_path).as_posix()
|
||||
if not _safe_dest_or_bucket(dst, rel):
|
||||
continue
|
||||
write, bucket = _decide_overwrite(rel, dst)
|
||||
if not write:
|
||||
if bucket == "preserved":
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
continue
|
||||
|
||||
content = src.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
rel = dst.relative_to(project_path).as_posix()
|
||||
planned_templates.append((dst, rel, content))
|
||||
content = src.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
planned_templates.append((dst, rel, content))
|
||||
|
||||
for dst_path, rel, content, mode in planned_copies:
|
||||
_ensure_safe_shared_directory(project_path, dst_path.parent)
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
_write_shared_bytes(project_path, dst_path, content, mode=mode)
|
||||
manifest.record_existing(rel)
|
||||
|
||||
@@ -307,11 +405,37 @@ def install_shared_infra(
|
||||
)
|
||||
for path in skipped_files:
|
||||
console.print(f" {path}")
|
||||
if refresh_managed and refresh_hint:
|
||||
console.print(refresh_hint)
|
||||
else:
|
||||
console.print(
|
||||
"To refresh shared infrastructure, run "
|
||||
"[cyan]specify init --here --force[/cyan] or "
|
||||
"[cyan]specify integration upgrade --force[/cyan]."
|
||||
)
|
||||
|
||||
if symlinked_files:
|
||||
console.print(
|
||||
"To refresh shared infrastructure, run "
|
||||
"[cyan]specify init --here --force[/cyan] or "
|
||||
"[cyan]specify integration upgrade --force[/cyan]."
|
||||
f"[yellow]⚠[/yellow] Skipped {len(symlinked_files)} symlinked shared "
|
||||
"infrastructure path(s) — symlinks are never overwritten because they "
|
||||
"may resolve outside the project root:"
|
||||
)
|
||||
for path in symlinked_files:
|
||||
console.print(f" {path}")
|
||||
console.print(
|
||||
"To restore the bundled version, remove or replace the symlink manually, "
|
||||
"then re-run the command."
|
||||
)
|
||||
|
||||
if preserved_user_files:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] Preserved {len(preserved_user_files)} customized shared "
|
||||
"infrastructure file(s) (hash differs from previous install):"
|
||||
)
|
||||
for path in preserved_user_files:
|
||||
console.print(f" {path}")
|
||||
if refresh_hint:
|
||||
console.print(refresh_hint)
|
||||
|
||||
manifest.save()
|
||||
return True
|
||||
|
||||
@@ -322,7 +322,7 @@ class WorkflowCatalog:
|
||||
|
||||
# Fetch from URL — validate scheme before opening and after redirects
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_catalog_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
@@ -337,7 +337,7 @@ class WorkflowCatalog:
|
||||
_validate_catalog_url(entry.url)
|
||||
|
||||
try:
|
||||
with urlopen(entry.url, timeout=30) as resp: # noqa: S310
|
||||
with _open_url(entry.url, timeout=30) as resp:
|
||||
_validate_catalog_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
|
||||
21
tests/auth_helpers.py
Normal file
21
tests/auth_helpers.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Shared test helpers for authentication config injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
|
||||
def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry:
|
||||
"""Build a GitHub ``AuthConfigEntry`` for testing."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token_env=token_env,
|
||||
)
|
||||
|
||||
|
||||
def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None:
|
||||
"""Inject a GitHub auth.json config entry into the auth HTTP module."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)])
|
||||
@@ -66,3 +66,18 @@ requires_bash = pytest.mark.skipif(
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_auth_config(monkeypatch):
|
||||
"""Ensure no test reads the real ~/.specify/auth.json."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [])
|
||||
# Also clear the per-process cache so tests that unset _config_override
|
||||
# won't see a previously cached real-file result.
|
||||
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
||||
|
||||
@@ -320,8 +320,8 @@ class TestInitIntegrationFlag:
|
||||
assert "A new shared manifest will be created" in captured.out
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
|
||||
"""Shared script refreshes must not follow destination symlinks."""
|
||||
def test_shared_infra_buckets_symlinked_script_destination(self, tmp_path, capsys):
|
||||
"""Symlinked script destinations are bucketed with a warning; the symlink target is preserved."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-script-test"
|
||||
@@ -334,14 +334,15 @@ class TestInitIntegrationFlag:
|
||||
scripts_dir.mkdir(parents=True)
|
||||
os.symlink(outside, scripts_dir / "common.sh")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "symlinked shared infrastructure" in captured.out
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
|
||||
"""Shared template installs must not follow destination symlinks."""
|
||||
def test_shared_infra_buckets_symlinked_template_destination(self, tmp_path, capsys):
|
||||
"""Symlinked template destinations are bucketed with a warning; the symlink target is preserved."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-template-test"
|
||||
@@ -354,9 +355,10 @@ class TestInitIntegrationFlag:
|
||||
templates_dir.mkdir(parents=True)
|
||||
os.symlink(outside, templates_dir / "plan-template.md")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "symlinked shared infrastructure" in captured.out
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
@@ -381,7 +383,7 @@ class TestInitIntegrationFlag:
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
|
||||
"""Shared infra directory creation must not follow a symlinked .specify."""
|
||||
"""Shared infra installs must not follow a symlinked .specify directory."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-dir-test"
|
||||
@@ -390,8 +392,10 @@ class TestInitIntegrationFlag:
|
||||
outside.mkdir()
|
||||
os.symlink(outside, project / ".specify")
|
||||
|
||||
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
# Nothing should have been written under the symlinked .specify target.
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
assert not (outside / "scripts").exists()
|
||||
assert not (outside / "templates").exists()
|
||||
@@ -465,8 +469,8 @@ class TestInitIntegrationFlag:
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
|
||||
"""Full shared infra installs validate destinations before writing any file."""
|
||||
def test_shared_infra_install_buckets_unsafe_destinations_and_continues(self, tmp_path):
|
||||
"""Symlinked destinations are bucketed with a warning; safe destinations in the same install still complete."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "preflight-install-test"
|
||||
@@ -486,19 +490,19 @@ class TestInitIntegrationFlag:
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
os.symlink(outside, scripts_dir / "z.sh")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert existing.read_text(encoding="utf-8") == "# old a\n"
|
||||
# Symlinked z.sh is preserved (bucketed); regular a.sh is overwritten.
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
assert existing.read_text(encoding="utf-8") == "# new a\n"
|
||||
|
||||
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
|
||||
"""Nested script source files create safe destination parents at write time."""
|
||||
|
||||
@@ -166,12 +166,12 @@ class TestCatalogFetch:
|
||||
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||
|
||||
def _patch_urlopen(self, monkeypatch, catalog_data):
|
||||
"""Patch urllib.request.urlopen to return *catalog_data*."""
|
||||
"""Patch authentication.http.urllib.request.urlopen to return *catalog_data*."""
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
@@ -185,11 +185,12 @@ class TestCatalogFetch:
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
def fake_urlopen(url, timeout=10):
|
||||
def fake_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
return FakeResponse(catalog_data, url)
|
||||
|
||||
import urllib.request
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
@@ -486,12 +487,12 @@ class TestIntegrationListCatalog:
|
||||
},
|
||||
}
|
||||
|
||||
import urllib.request
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
def read(self):
|
||||
return self._data
|
||||
def geturl(self):
|
||||
@@ -501,7 +502,8 @@ class TestIntegrationListCatalog:
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen",
|
||||
lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url))
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Tests for GooseIntegration."""
|
||||
|
||||
import yaml
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_yaml import YamlIntegrationTests
|
||||
|
||||
|
||||
@@ -9,3 +13,27 @@ class TestGooseIntegration(YamlIntegrationTests):
|
||||
COMMANDS_SUBDIR = "recipes"
|
||||
REGISTRAR_DIR = ".goose/recipes"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path):
|
||||
# “If a generated Goose recipe uses {{args}} in its prompt, it
|
||||
# must declare a corresponding args parameter.”
|
||||
|
||||
integration = get_integration("goose")
|
||||
assert integration is not None
|
||||
|
||||
manifest = IntegrationManifest("goose", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
recipe_files = [path for path in created if path.suffix == ".yaml"]
|
||||
assert recipe_files
|
||||
|
||||
for recipe_file in recipe_files:
|
||||
data = yaml.safe_load(recipe_file.read_text(encoding="utf-8"))
|
||||
|
||||
if "{{args}}" not in data["prompt"]:
|
||||
continue
|
||||
|
||||
assert any(
|
||||
param.get("key") == "args"
|
||||
for param in data.get("parameters", [])
|
||||
), f"{recipe_file} uses {{{{args}}}} but does not declare args"
|
||||
|
||||
11
tests/integrations/test_integration_lingma.py
Normal file
11
tests/integrations/test_integration_lingma.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Tests for LingmaIntegration."""
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestLingmaIntegration(SkillsIntegrationTests):
|
||||
KEY = "lingma"
|
||||
FOLDER = ".lingma/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".lingma/skills"
|
||||
CONTEXT_FILE = ".lingma/rules/specify-rules.md"
|
||||
@@ -901,6 +901,152 @@ class TestIntegrationSwitch:
|
||||
assert shared_script.exists()
|
||||
assert shared_script.read_text(encoding="utf-8") == shared_content
|
||||
|
||||
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
|
||||
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
|
||||
import hashlib
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
bundled_bytes = shared_script.read_bytes()
|
||||
|
||||
# Simulate a stale vendored script: write truncated content as bytes
|
||||
# (write_text would translate \n→\r\n on Windows and break the hash)
|
||||
# and update the speckit manifest hash so the stale copy is treated
|
||||
# as "managed" (installed by spec-kit, not a user customization).
|
||||
stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n"
|
||||
shared_script.write_bytes(stale_bytes)
|
||||
|
||||
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
|
||||
hashlib.sha256(stale_bytes).hexdigest()
|
||||
)
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Stale managed file should be replaced by the bundled version
|
||||
assert shared_script.read_bytes() == bundled_bytes
|
||||
|
||||
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
|
||||
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
|
||||
# User customization: append bytes but do NOT update manifest hash,
|
||||
# so on-disk hash diverges from the recorded one.
|
||||
original = shared_script.read_bytes()
|
||||
custom_bytes = original + b"\n# user customization\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert shared_script.read_bytes() == custom_bytes
|
||||
assert "Preserved" in result.output
|
||||
|
||||
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
|
||||
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
bundled_bytes = shared_script.read_bytes()
|
||||
|
||||
# User customization (hash diverges from manifest)
|
||||
custom_bytes = bundled_bytes + b"\n# user customization\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
"--refresh-shared-infra",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# Customization is overwritten with the bundled version
|
||||
assert shared_script.read_bytes() == bundled_bytes
|
||||
|
||||
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
|
||||
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
|
||||
|
||||
Copilot follow-up on #2375: leaf-only symlink check let writes escape
|
||||
when an *ancestor* directory was symlinked outside the project root.
|
||||
"""
|
||||
import sys
|
||||
if sys.platform.startswith("win"):
|
||||
import pytest as _pytest
|
||||
_pytest.skip("Symlink creation typically requires admin on Windows")
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
bash_dir = project / ".specify" / "scripts" / "bash"
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
for child in bash_dir.iterdir():
|
||||
child.rename(outside / child.name)
|
||||
bash_dir.rmdir()
|
||||
bash_dir.symlink_to(outside, target_is_directory=True)
|
||||
sentinel = (outside / "common.sh").read_bytes()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# Symlinked tree reported, not written through.
|
||||
assert "symlink" in result.output.lower()
|
||||
# Outside dir contents unchanged.
|
||||
assert (outside / "common.sh").read_bytes() == sentinel
|
||||
|
||||
def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path):
|
||||
"""--force (uninstall semantics) must NOT overwrite shared-infra customizations.
|
||||
|
||||
Regression: ensures the decoupling of --force and --refresh-shared-infra.
|
||||
"""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
bundled_bytes = shared_script.read_bytes()
|
||||
|
||||
custom_bytes = bundled_bytes + b"\n# user customization\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# --force alone preserves the customization
|
||||
assert shared_script.read_bytes() == custom_bytes
|
||||
|
||||
def test_switch_from_nothing(self, tmp_path):
|
||||
"""Switch when no integration is installed should just install the target."""
|
||||
project = tmp_path / "bare"
|
||||
|
||||
860
tests/test_authentication.py
Normal file
860
tests/test_authentication.py
Normal file
@@ -0,0 +1,860 @@
|
||||
"""Tests for the authentication provider registry and config-driven HTTP helpers.
|
||||
|
||||
Covers:
|
||||
- Config loading (auth.json parsing, validation, permission warning)
|
||||
- Registry mechanics (_register, get_provider, duplicate/empty-key guards)
|
||||
- GitHubAuth — bearer headers
|
||||
- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers
|
||||
- Host matching (find_entries_for_url)
|
||||
- open_url — config-driven auth with fallthrough and redirect stripping
|
||||
- build_request — single-shot request construction
|
||||
- _fetch_latest_release_tag() delegation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider
|
||||
from specify_cli.authentication.azure_devops import AzureDevOpsAuth
|
||||
from specify_cli.authentication.base import AuthProvider
|
||||
from specify_cli.authentication.config import (
|
||||
AuthConfigEntry,
|
||||
find_entries_for_url,
|
||||
load_auth_config,
|
||||
)
|
||||
from specify_cli.authentication.github import GitHubAuth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry:
|
||||
"""Build a standard GitHub config entry."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token=token,
|
||||
token_env=token_env if token is None else None,
|
||||
)
|
||||
|
||||
|
||||
def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry:
|
||||
"""Build an ADO basic-pat config entry."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("dev.azure.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env=token_env,
|
||||
)
|
||||
|
||||
|
||||
class _StubProvider(AuthProvider):
|
||||
"""Minimal concrete provider for registry mechanics tests."""
|
||||
|
||||
key = "stub-provider"
|
||||
supported_auth_schemes = ("bearer",)
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadAuthConfig:
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
assert load_auth_config(tmp_path / "nonexistent.json") == []
|
||||
|
||||
def test_valid_github_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].provider == "github"
|
||||
assert entries[0].auth == "bearer"
|
||||
assert entries[0].token_env == "GH_TOKEN"
|
||||
|
||||
def test_valid_ado_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "AZURE_DEVOPS_PAT",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].provider == "azure-devops"
|
||||
assert entries[0].auth == "basic-pat"
|
||||
|
||||
def test_inline_token(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token": "ghp_inline_token",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].token == "ghp_inline_token"
|
||||
|
||||
def test_azure_ad_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "tid",
|
||||
"client_id": "cid",
|
||||
"client_secret_env": "SECRET",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].auth == "azure-ad"
|
||||
assert entries[0].tenant_id == "tid"
|
||||
|
||||
def test_azure_cli_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-cli",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].auth == "azure-cli"
|
||||
|
||||
def test_multiple_entries(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [
|
||||
{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"},
|
||||
{"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"},
|
||||
]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 2
|
||||
|
||||
# -- Negative: validation errors --
|
||||
|
||||
def test_invalid_json_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text("not json")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_not_object_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text("[]")
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_missing_providers_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({"foo": "bar"}))
|
||||
with pytest.raises(ValueError, match="providers"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_empty_hosts_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_missing_provider_key_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="provider"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_unsupported_auth_scheme_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_bearer_without_token_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="token"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_azure_ad_missing_fields_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "tid",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="azure-ad"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_unknown_provider_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="unknown provider"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_incompatible_provider_scheme_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "X",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_dangerous_wildcard_host_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_multi_wildcard_host_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_valid_star_dot_host_accepted(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].hosts == ("*.visualstudio.com",)
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows")
|
||||
def test_world_readable_warns(self, tmp_path):
|
||||
import stat
|
||||
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}]
|
||||
}))
|
||||
cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
|
||||
with pytest.warns(UserWarning, match="readable by group"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindEntriesForUrl:
|
||||
def test_exact_match(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://github.com/org/repo", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
def test_wildcard_match(self):
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("*.visualstudio.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env="ADO_PAT",
|
||||
)
|
||||
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://evil.example.com/file", [entry])
|
||||
assert result == []
|
||||
|
||||
def test_no_match_for_lookalike_host(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://github.com.evil.com/file", [entry])
|
||||
assert result == []
|
||||
|
||||
def test_empty_url_returns_empty(self):
|
||||
assert find_entries_for_url("", [_github_entry()]) == []
|
||||
|
||||
def test_empty_entries_returns_empty(self):
|
||||
assert find_entries_for_url("https://github.com/org/repo", []) == []
|
||||
|
||||
def test_multiple_matches_returned(self):
|
||||
e1 = _github_entry(token_env="GH_TOKEN")
|
||||
e2 = _github_entry(token_env="GITHUB_TOKEN")
|
||||
result = find_entries_for_url("https://github.com/org/repo", [e1, e2])
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry mechanics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthRegistry:
|
||||
def test_github_registered(self):
|
||||
assert "github" in AUTH_REGISTRY
|
||||
|
||||
def test_azure_devops_registered(self):
|
||||
assert "azure-devops" in AUTH_REGISTRY
|
||||
|
||||
def test_get_provider_returns_github(self):
|
||||
assert isinstance(get_provider("github"), GitHubAuth)
|
||||
|
||||
def test_get_provider_returns_azure_devops(self):
|
||||
assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth)
|
||||
|
||||
def test_get_provider_unknown_returns_none(self):
|
||||
assert get_provider("does-not-exist") is None
|
||||
|
||||
def test_register_duplicate_raises_key_error(self):
|
||||
class _UniqueStub(_StubProvider):
|
||||
key = "__test_duplicate__"
|
||||
|
||||
try:
|
||||
_register(_UniqueStub())
|
||||
with pytest.raises(KeyError, match="already registered"):
|
||||
_register(_UniqueStub())
|
||||
finally:
|
||||
AUTH_REGISTRY.pop("__test_duplicate__", None)
|
||||
|
||||
def test_register_empty_key_raises_value_error(self):
|
||||
class _EmptyKey(_StubProvider):
|
||||
key = ""
|
||||
|
||||
with pytest.raises(ValueError, match="empty key"):
|
||||
_register(_EmptyKey())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGitHubAuth:
|
||||
def test_bearer_headers(self):
|
||||
assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"}
|
||||
|
||||
def test_unsupported_scheme_raises(self):
|
||||
with pytest.raises(ValueError, match="basic-pat"):
|
||||
GitHubAuth().auth_headers("tok", "basic-pat")
|
||||
|
||||
def test_resolve_token_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "env-token")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) == "env-token"
|
||||
|
||||
def test_resolve_token_inline(self):
|
||||
assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok"
|
||||
|
||||
def test_resolve_token_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " my-token ")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) == "my-token"
|
||||
|
||||
def test_resolve_token_empty_env_returns_none(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) is None
|
||||
|
||||
def test_resolve_token_missing_env_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
assert GitHubAuth().resolve_token(_github_entry()) is None
|
||||
|
||||
def test_key(self):
|
||||
assert GitHubAuth.key == "github"
|
||||
|
||||
def test_supported_schemes(self):
|
||||
assert GitHubAuth.supported_auth_schemes == ("bearer",)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AzureDevOpsAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAzureDevOpsAuth:
|
||||
def test_basic_pat_headers(self):
|
||||
headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat")
|
||||
encoded = base64.b64encode(b":my-pat").decode("ascii")
|
||||
assert headers == {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
def test_basic_pat_format(self):
|
||||
header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"]
|
||||
raw = base64.b64decode(header[len("Basic "):]).decode("ascii")
|
||||
assert raw == ":test-pat"
|
||||
|
||||
def test_bearer_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_azure_cli_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_azure_ad_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_unsupported_scheme_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
AzureDevOpsAuth().auth_headers("tok", "ntlm")
|
||||
|
||||
def test_resolve_token_basic_pat(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat")
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
|
||||
|
||||
def test_resolve_token_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ")
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
|
||||
|
||||
def test_resolve_token_missing_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False)
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None
|
||||
|
||||
def test_key(self):
|
||||
assert AzureDevOpsAuth.key == "azure-devops"
|
||||
|
||||
def test_supported_schemes(self):
|
||||
schemes = AzureDevOpsAuth.supported_auth_schemes
|
||||
assert "basic-pat" in schemes
|
||||
assert "bearer" in schemes
|
||||
assert "azure-cli" in schemes
|
||||
assert "azure-ad" in schemes
|
||||
|
||||
def test_resolve_token_azure_cli_success(self):
|
||||
"""azure-cli acquires token via az CLI."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = '{"accessToken": "cli-acquired-token"}'
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_cli_failure_returns_none(self):
|
||||
"""azure-cli returns None when az CLI fails."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 1
|
||||
result.stdout = ""
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_cli_not_installed_returns_none(self):
|
||||
"""azure-cli returns None when az is not installed."""
|
||||
from unittest.mock import patch
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_success(self, monkeypatch):
|
||||
"""azure-ad acquires token via OAuth2 client credentials."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None when client secret env var is missing."""
|
||||
monkeypatch.delenv("MY_SECRET", raising=False)
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None on network errors."""
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
with patch("urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("connection refused")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_url / build_request — positive tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticatedHttp:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def test_build_request_attaches_auth_for_matching_host(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://github.com/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_build_request_no_auth_for_non_matching_host(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://evil.example.com/file")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_build_request_no_auth_when_no_config(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
self._set_config(monkeypatch, [])
|
||||
req = build_request("https://github.com/org/repo")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_build_request_extra_headers(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"})
|
||||
assert req.get_header("Accept") == "application/json"
|
||||
assert req.get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_open_url_attaches_auth_for_matching_host(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener.open.side_effect = fake_open
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
open_url("https://github.com/org/repo/catalog.json")
|
||||
assert captured["req"].get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_open_url_no_auth_for_non_matching_host(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://example.com/file.json")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
def test_open_url_no_auth_when_no_config(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
def test_open_url_falls_through_on_401(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "bad-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
call_count = 0
|
||||
def fake_side_effect(req, timeout=None):
|
||||
nonlocal call_count; call_count += 1
|
||||
if call_count == 1:
|
||||
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
|
||||
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_url — negative tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticatedHttpNegative:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def test_500_raises_immediately(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "tok")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None)
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with pytest.raises(urllib.error.HTTPError, match="500"):
|
||||
open_url("https://github.com/org/repo")
|
||||
|
||||
def test_404_raises_immediately(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "tok")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with pytest.raises(urllib.error.HTTPError, match="404"):
|
||||
open_url("https://github.com/org/repo")
|
||||
|
||||
def test_urlerror_propagates(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("refused")):
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
def test_timeout_propagates(self, monkeypatch):
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=socket.timeout("timed out")):
|
||||
with pytest.raises(socket.timeout):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_config caching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadConfigCaching:
|
||||
def test_config_cached_after_first_load(self, monkeypatch):
|
||||
"""_load_config() should call load_auth_config only once per process."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
# Allow the real load path (no override)
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
entry = _github_entry()
|
||||
call_count = 0
|
||||
|
||||
def fake_load(path=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return [entry]
|
||||
|
||||
with patch.object(_mod, "load_auth_config", side_effect=fake_load):
|
||||
_mod._load_config()
|
||||
_mod._load_config()
|
||||
_mod._load_config()
|
||||
|
||||
assert call_count == 1
|
||||
|
||||
def test_cache_bypassed_by_override(self, monkeypatch):
|
||||
"""When _config_override is set, the cache is ignored entirely."""
|
||||
from specify_cli.authentication import http as _mod
|
||||
sentinel = [_github_entry()]
|
||||
monkeypatch.setattr(_mod, "_config_override", sentinel)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
result = _mod._load_config()
|
||||
assert result is sentinel
|
||||
# Cache must not have been populated when override is active
|
||||
assert _mod._config_cache is None
|
||||
|
||||
def test_failed_load_warns_once_and_caches_empty(self, monkeypatch):
|
||||
"""A bad auth.json emits exactly one warning and subsequent calls use cache."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
import warnings as _warnings
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fail_load(path=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("bad config")
|
||||
|
||||
with patch.object(_mod, "load_auth_config", side_effect=fail_load):
|
||||
with _warnings.catch_warnings(record=True) as w:
|
||||
_warnings.simplefilter("always")
|
||||
result1 = _mod._load_config()
|
||||
result2 = _mod._load_config()
|
||||
result3 = _mod._load_config()
|
||||
|
||||
user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
|
||||
assert len(user_warnings) == 1, "Expected exactly one warning"
|
||||
# Loader called only once — subsequent calls used cache
|
||||
assert call_count == 1
|
||||
# All calls returned the cached empty list
|
||||
assert result1 == result2 == result3 == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redirect stripping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRedirectStripping:
|
||||
def test_redirect_within_hosts_preserves_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com", "codeload.github.com"))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://codeload.github.com/org/repo/zip")
|
||||
assert new_req is not None
|
||||
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth == "Bearer tok"
|
||||
|
||||
def test_redirect_outside_hosts_strips_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://objects.githubusercontent.com/asset")
|
||||
assert new_req is not None
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
|
||||
"""Auth survives a multi-hop redirect chain within allowed hosts."""
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com")
|
||||
handler = _StripAuthOnRedirect(hosts)
|
||||
|
||||
# First hop: github.com → codeload.github.com
|
||||
req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://codeload.github.com/org/repo/zip")
|
||||
assert req2 is not None
|
||||
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization")
|
||||
assert auth2 == "Bearer tok"
|
||||
|
||||
# Second hop: codeload.github.com → objects-origin.githubusercontent.com
|
||||
req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://objects-origin.githubusercontent.com/asset")
|
||||
assert req3 is not None
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchLatestReleaseTagDelegation:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def _capture_request(self):
|
||||
import json as _json
|
||||
from unittest.mock import MagicMock
|
||||
captured: dict = {}
|
||||
def side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
|
||||
resp = MagicMock(); resp.read.return_value = body
|
||||
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
|
||||
return cm
|
||||
return captured, side_effect
|
||||
|
||||
def test_gh_token_forwarded_when_configured(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") is None
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
|
||||
@@ -2453,6 +2453,10 @@ class TestExtensionCatalog:
|
||||
(project_dir / ".specify").mkdir()
|
||||
return ExtensionCatalog(project_dir)
|
||||
|
||||
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
|
||||
"""Without a token, requests carry no Authorization header."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
@@ -2473,6 +2477,7 @@ class TestExtensionCatalog:
|
||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||
@@ -2481,6 +2486,7 @@ class TestExtensionCatalog:
|
||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -2489,49 +2495,40 @@ class TestExtensionCatalog:
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||
|
||||
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||
def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch):
|
||||
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||
|
||||
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
|
||||
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||
def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch):
|
||||
"""Auth is NOT attached to hosts not listed in auth.json."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch):
|
||||
"""No auth header when no auth.json config exists."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the URL path."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the query string."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip")
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for api.github.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -2539,49 +2536,17 @@ class TestExtensionCatalog:
|
||||
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_redirect_preserves_auth_for_github_to_codeload(self):
|
||||
"""Auth header is preserved when GitHub redirects to codeload.github.com."""
|
||||
from specify_cli._github_http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect()
|
||||
original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip"
|
||||
redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1"
|
||||
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
|
||||
fp = io.BytesIO(b"")
|
||||
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
|
||||
assert new_req is not None
|
||||
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth == "Bearer ghp_test"
|
||||
|
||||
def test_redirect_strips_auth_for_github_to_external(self):
|
||||
"""Auth header is stripped when GitHub redirects to a non-GitHub host."""
|
||||
from specify_cli._github_http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect()
|
||||
original_url = "https://github.com/org/repo/releases/download/v1/asset.zip"
|
||||
redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345"
|
||||
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
|
||||
fp = io.BytesIO(b"")
|
||||
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
|
||||
assert new_req is not None
|
||||
auth_header = new_req.headers.get("Authorization")
|
||||
auth_unredirected = new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth_header is None
|
||||
assert auth_unredirected is None
|
||||
|
||||
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||
"""_fetch_single_catalog passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
catalog_data = {"schema_version": "1.0", "extensions": {}}
|
||||
@@ -2589,6 +2554,7 @@ class TestExtensionCatalog:
|
||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json"
|
||||
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
@@ -2606,17 +2572,18 @@ class TestExtensionCatalog:
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header via opener for GitHub URLs."""
|
||||
"""download_extension passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import zipfile, io
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
# Build a minimal valid ZIP in memory
|
||||
@@ -2631,7 +2598,6 @@ class TestExtensionCatalog:
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = {}
|
||||
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
@@ -2648,7 +2614,7 @@ class TestExtensionCatalog:
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
@@ -1224,6 +1224,10 @@ class TestExtensionPriorityResolution:
|
||||
class TestPresetCatalog:
|
||||
"""Test template catalog functionality."""
|
||||
|
||||
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
def test_default_catalog_url(self, project_dir):
|
||||
"""Test default catalog URL."""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
@@ -1418,6 +1422,7 @@ class TestPresetCatalog:
|
||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||
@@ -1426,6 +1431,7 @@ class TestPresetCatalog:
|
||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -1434,58 +1440,50 @@ class TestPresetCatalog:
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||
|
||||
def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||
def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch):
|
||||
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||
|
||||
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch):
|
||||
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||
def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch):
|
||||
"""Auth is NOT attached to hosts not listed in auth.json."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch):
|
||||
"""No auth header when no auth.json config exists."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the URL path."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the query string."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip")
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
|
||||
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||
"""_fetch_single_catalog passes Authorization header when configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
catalog_data = {"schema_version": "1.0", "presets": {}}
|
||||
@@ -1493,6 +1491,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json"
|
||||
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
@@ -1510,16 +1509,17 @@ class TestPresetCatalog:
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
|
||||
"""download_pack passes Authorization header via opener for GitHub URLs."""
|
||||
"""download_pack passes Authorization header when configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
import io
|
||||
@@ -1551,7 +1551,7 @@ class TestPresetCatalog:
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
@@ -23,7 +23,6 @@ from specify_cli import (
|
||||
_normalize_tag,
|
||||
app,
|
||||
)
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -31,6 +30,10 @@ runner = CliRunner()
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
_RATE_LIMITED_REASON = (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
|
||||
|
||||
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
@@ -66,11 +69,20 @@ class TestSelfUpgradeStub:
|
||||
]
|
||||
|
||||
def test_stub_makes_no_network_call(self):
|
||||
# If the stub ever starts calling urllib, this patch's side_effect
|
||||
# would fire and the assertion below would fail.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=AssertionError("stub must not hit the network"),
|
||||
# The stub must not hit the network via either urllib path:
|
||||
# unauthenticated requests use urlopen() directly; authenticated ones
|
||||
# go through build_opener(...).open(). Both are patched so that any
|
||||
# accidental network call raises immediately.
|
||||
network_error = AssertionError("stub must not hit the network")
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=network_error,
|
||||
),
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
side_effect=network_error,
|
||||
),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
@@ -138,7 +150,7 @@ class TestNormalizeTag:
|
||||
class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -151,7 +163,7 @@ class TestUserStory1:
|
||||
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -163,7 +175,7 @@ class TestUserStory1:
|
||||
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -174,7 +186,7 @@ class TestUserStory1:
|
||||
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -186,7 +198,7 @@ class TestUserStory1:
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -200,7 +212,7 @@ class TestUserStory1:
|
||||
class TestFailureCategorization:
|
||||
def test_urlerror_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("no route to host"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -209,7 +221,7 @@ class TestFailureCategorization:
|
||||
|
||||
def test_timeout_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=TimeoutError(),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -218,17 +230,17 @@ class TestFailureCategorization:
|
||||
|
||||
def test_403_maps_to_rate_limited(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=_http_error(403, "rate limited"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||
assert reason == _RATE_LIMITED_REASON
|
||||
|
||||
@pytest.mark.parametrize("code", [404, 500, 502])
|
||||
def test_other_http_uses_code_string(self, code):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=_http_error(code, "oops"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -238,7 +250,7 @@ class TestFailureCategorization:
|
||||
def test_generic_exception_propagates(self):
|
||||
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
@@ -247,7 +259,7 @@ class TestFailureCategorization:
|
||||
|
||||
_FAILURE_CASES = [
|
||||
("offline or timeout", urllib.error.URLError("down")),
|
||||
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
|
||||
(_RATE_LIMITED_REASON, _http_error(403)),
|
||||
("HTTP 500", _http_error(500)),
|
||||
]
|
||||
|
||||
@@ -258,22 +270,21 @@ class TestUserStory2:
|
||||
self, expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert "Installed: 0.7.4" in output
|
||||
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
|
||||
if expected_reason == _RATE_LIMITED_REASON:
|
||||
assert "Could not check latest release: rate limited" in output
|
||||
assert "GH_TOKEN" in output
|
||||
assert "GITHUB_TOKEN" in output
|
||||
assert "~/.specify/auth.json" in output
|
||||
else:
|
||||
assert f"Could not check latest release: {expected_reason}" in output
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
assert result.exit_code == 0
|
||||
@@ -283,7 +294,7 @@ class TestUserStory2:
|
||||
self, _expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = (result.output or "") + (result.stderr or "")
|
||||
@@ -302,12 +313,20 @@ def _capture_request_via_urlopen():
|
||||
return captured, _side_effect
|
||||
|
||||
|
||||
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
|
||||
class TestUserStory3:
|
||||
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||
@@ -315,8 +334,11 @@ class TestUserStory3:
|
||||
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -325,7 +347,7 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -333,8 +355,9 @@ class TestUserStory3:
|
||||
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -342,8 +365,9 @@ class TestUserStory3:
|
||||
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -351,8 +375,11 @@ class TestUserStory3:
|
||||
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -364,7 +391,7 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
@@ -377,7 +404,7 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
|
||||
Reference in New Issue
Block a user