mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 07:04:44 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef787ea8c |
2
.github/workflows/catalog-assign.yml
vendored
2
.github/workflows/catalog-assign.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v9
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const issue = context.payload.issue;
|
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' ]
|
language: [ 'actions', 'python' ]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
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
|
fetch-depth: 0 # Fetch all history for git info
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.x'
|
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
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
- name: Run markdownlint-cli2
|
- name: Run markdownlint-cli2
|
||||||
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
|
uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23
|
||||||
with:
|
with:
|
||||||
globs: |
|
globs: |
|
||||||
'**/*.md'
|
'**/*.md'
|
||||||
|
|||||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
python-version: ["3.11", "3.12", "3.13"]
|
python-version: ["3.11", "3.12", "3.13"]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
|
|||||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,41 +2,6 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- insert new changelog below this comment -->
|
||||||
|
|
||||||
## [0.8.9] - 2026-05-12
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- docs: revamp landing page with four-pillar card layout (#2531)
|
|
||||||
- feat(extensions): update governance ecosystem extensions to latest versions (#2514)
|
|
||||||
- Add changelog extension (#2177)
|
|
||||||
- Add install directory to docfx.json file references (#2522)
|
|
||||||
- feat(catalog): add BrownKit (brownkit) community extension (#2510) (#2520)
|
|
||||||
- fix(kiro-cli): replace literal $ARGUMENTS with prose fallback (#2482)
|
|
||||||
- Preset: Add game-narrative-writing preset to community catalog (#2454)
|
|
||||||
- docs: clarify CLI upgrade discovery (#2519)
|
|
||||||
- fix: make template metadata line breaks markdownlint-safe (#2505)
|
|
||||||
- refactor(catalogs): extract integration catalog config loading (#2497)
|
|
||||||
- test(presets): silence expected UserWarnings in self-test composition… (#2373)
|
|
||||||
- chore: release 0.8.8, begin 0.8.9.dev0 development (#2516)
|
|
||||||
|
|
||||||
## [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
|
## [0.8.7] - 2026-05-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||||
@@ -208,7 +207,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
|||||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||||
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
|
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
|
||||||
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
|
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
|
||||||
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
|
|
||||||
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
|
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
|
||||||
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
|
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
|
||||||
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
|
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
|
||||||
@@ -237,7 +235,6 @@ 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 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) |
|
| 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) |
|
| 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 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) |
|
| 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) |
|
| 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) |
|
||||||
@@ -266,12 +263,10 @@ The following community-contributed extensions are available in [`catalog.commun
|
|||||||
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||||
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
|
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
|
||||||
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
|
| 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 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 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 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 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 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 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-) |
|
| 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-) |
|
||||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
|||||||
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||||
| 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) |
|
| 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) |
|
||||||
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# What is Spec-Driven Development?
|
|
||||||
|
|
||||||
Spec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the "real work" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them.
|
|
||||||
|
|
||||||
## Core Philosophy
|
|
||||||
|
|
||||||
Spec-Driven Development is a structured process that emphasizes:
|
|
||||||
|
|
||||||
- **Intent-driven development** where specifications define the "*what*" before the "*how*"
|
|
||||||
- **Rich specification creation** using guardrails and organizational principles
|
|
||||||
- **Multi-step refinement** rather than one-shot code generation from prompts
|
|
||||||
- **Heavy reliance** on advanced AI model capabilities for specification interpretation
|
|
||||||
|
|
||||||
## Development Phases
|
|
||||||
|
|
||||||
| Phase | Focus | Key Activities |
|
|
||||||
|-------|-------|----------------|
|
|
||||||
| **0-to-1 Development** ("Greenfield") | Generate from scratch | <ul><li>Start with high-level requirements</li><li>Generate specifications</li><li>Plan implementation steps</li><li>Build production-ready applications</li></ul> |
|
|
||||||
| **Creative Exploration** | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul> |
|
|
||||||
| **Iterative Enhancement** ("Brownfield") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul> |
|
|
||||||
|
|
||||||
## Experimental Goals
|
|
||||||
|
|
||||||
Our research and experimentation focus on:
|
|
||||||
|
|
||||||
### Technology Independence
|
|
||||||
|
|
||||||
- Create applications using diverse technology stacks
|
|
||||||
- Validate the hypothesis that Spec-Driven Development is a process not tied to specific technologies, programming languages, or frameworks
|
|
||||||
|
|
||||||
### Enterprise Constraints
|
|
||||||
|
|
||||||
- Demonstrate mission-critical application development
|
|
||||||
- Incorporate organizational constraints (cloud providers, tech stacks, engineering practices)
|
|
||||||
- Support enterprise design systems and compliance requirements
|
|
||||||
|
|
||||||
### User-Centric Development
|
|
||||||
|
|
||||||
- Build applications for different user cohorts and preferences
|
|
||||||
- Support various development approaches (from vibe-coding to AI-native development)
|
|
||||||
|
|
||||||
### Creative & Iterative Processes
|
|
||||||
|
|
||||||
- Validate the concept of parallel implementation exploration
|
|
||||||
- Provide robust iterative feature development workflows
|
|
||||||
- Extend processes to handle upgrades and modernization tasks
|
|
||||||
@@ -6,9 +6,7 @@
|
|||||||
"*.md",
|
"*.md",
|
||||||
"toc.yml",
|
"toc.yml",
|
||||||
"community/*.md",
|
"community/*.md",
|
||||||
"concepts/*.md",
|
"reference/*.md"
|
||||||
"reference/*.md",
|
|
||||||
"install/*.md"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,8 +49,7 @@
|
|||||||
"fileMetadataFiles": [],
|
"fileMetadataFiles": [],
|
||||||
"template": [
|
"template": [
|
||||||
"default",
|
"default",
|
||||||
"modern",
|
"modern"
|
||||||
"template"
|
|
||||||
],
|
],
|
||||||
"postProcessors": [],
|
"postProcessors": [],
|
||||||
"markdownEngineName": "markdig",
|
"markdownEngineName": "markdig",
|
||||||
@@ -70,11 +67,6 @@
|
|||||||
"repo": "https://github.com/github/spec-kit",
|
"repo": "https://github.com/github/spec-kit",
|
||||||
"branch": "main"
|
"branch": "main"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"fileMetadata": {
|
|
||||||
"_layout": {
|
|
||||||
"index.md": "landing"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
docs/index.md
169
docs/index.md
@@ -1,152 +1,67 @@
|
|||||||
<div class="landing-hero">
|
# Spec Kit
|
||||||
|
|
||||||
# GitHub Spec Kit
|
*Build high-quality software faster.*
|
||||||
|
|
||||||
**Define what to build before building it — with any AI coding agent.**
|
**An effort to allow organizations to focus on product scenarios rather than writing undifferentiated code with the help of Spec-Driven Development.**
|
||||||
|
|
||||||
Spec Kit is a toolkit for [Spec-Driven Development](concepts/sdd.md) (SDD), a methodology that puts specifications at the center of AI-assisted software development. Instead of jumping straight to code, you describe *what* to build, refine it through structured phases, and let your AI coding agent implement it.
|
## What is Spec-Driven Development?
|
||||||
|
|
||||||
<a href="installation.md" class="btn btn-primary btn-lg">Install Spec Kit</a>
|
Spec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the "real work" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them.
|
||||||
<a href="quickstart.md" class="btn btn-outline-primary btn-lg">Quick Start</a>
|
|
||||||
|
|
||||||
</div>
|
## Getting Started
|
||||||
|
|
||||||
---
|
- [Installation Guide](installation.md)
|
||||||
|
- [Quick Start Guide](quickstart.md)
|
||||||
|
- [Upgrade Guide](upgrade.md)
|
||||||
|
- [Local Development](local-development.md)
|
||||||
|
|
||||||
<div class="pillar-grid">
|
## Core Philosophy
|
||||||
|
|
||||||
<div class="pillar-card">
|
Spec-Driven Development is a structured process that emphasizes:
|
||||||
|
|
||||||
### Spec-driven by default
|
- **Intent-driven development** where specifications define the "*what*" before the "*how*"
|
||||||
|
- **Rich specification creation** using guardrails and organizational principles
|
||||||
|
- **Multi-step refinement** rather than one-shot code generation from prompts
|
||||||
|
- **Heavy reliance** on advanced AI model capabilities for specification interpretation
|
||||||
|
|
||||||
The core SDD process ships ready to use: **Spec → Plan → Tasks → Implement**.
|
## Development Phases
|
||||||
|
|
||||||
Define what to build before building it. Rich templates, quality checklists, and cross-artifact analysis come out of the box. Each phase produces a Markdown artifact that feeds the next — giving your AI coding agent structured context instead of ad-hoc prompts.
|
| Phase | Focus | Key Activities |
|
||||||
|
|-------|-------|----------------|
|
||||||
|
| **0-to-1 Development** ("Greenfield") | Generate from scratch | <ul><li>Start with high-level requirements</li><li>Generate specifications</li><li>Plan implementation steps</li><li>Build production-ready applications</li></ul> |
|
||||||
|
| **Creative Exploration** | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul> |
|
||||||
|
| **Iterative Enhancement** ("Brownfield") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul> |
|
||||||
|
|
||||||
<a href="quickstart.md" class="pillar-link">Walk through the workflow →</a>
|
## Experimental Goals
|
||||||
|
|
||||||
</div>
|
Our research and experimentation focus on:
|
||||||
|
|
||||||
<div class="pillar-card">
|
### Technology Independence
|
||||||
|
|
||||||
### Use any coding agent
|
- Create applications using diverse technology stacks
|
||||||
|
- Validate the hypothesis that Spec-Driven Development is a process not tied to specific technologies, programming languages, or frameworks
|
||||||
|
|
||||||
<span class="pillar-stat">30 integrations</span> — Copilot, Gemini, Codex, Windsurf, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
|
### Enterprise Constraints
|
||||||
|
|
||||||
Run `specify init` with your agent of choice and Spec Kit sets up the right command files, context rules, and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool.
|
- Demonstrate mission-critical application development
|
||||||
|
- Incorporate organizational constraints (cloud providers, tech stacks, engineering practices)
|
||||||
|
- Support enterprise design systems and compliance requirements
|
||||||
|
|
||||||
<a href="reference/integrations.md" class="pillar-link">See all integrations →</a>
|
### User-Centric Development
|
||||||
|
|
||||||
</div>
|
- Build applications for different user cohorts and preferences
|
||||||
|
- Support various development approaches (from vibe-coding to AI-native development)
|
||||||
|
|
||||||
<div class="pillar-card">
|
### Creative & Iterative Processes
|
||||||
|
|
||||||
### Make it your own
|
- Validate the concept of parallel implementation exploration
|
||||||
|
- Provide robust iterative feature development workflows
|
||||||
|
- Extend processes to handle upgrades and modernization tasks
|
||||||
|
|
||||||
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing — including entirely different SDD processes:
|
## Contributing
|
||||||
|
|
||||||
- **AIDE** — 7-step AI-driven engineering lifecycle
|
Please see our [Contributing Guide](https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md) for information on how to contribute to this project.
|
||||||
- **Canon** — baseline-driven workflows (spec-first, code-first, spec-drift)
|
|
||||||
- **Product Forge** — product-management-oriented SDD
|
|
||||||
- **FX→.NET** — end-to-end .NET Framework migration across 7 phases
|
|
||||||
- **MAQA** — multi-agent orchestration with quality assurance gates
|
|
||||||
|
|
||||||
Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
|
## Support
|
||||||
|
|
||||||
<a href="community/presets.md" class="pillar-link">Browse community presets →</a>
|
For support, please check our [Support Guide](https://github.com/github/spec-kit/blob/main/SUPPORT.md) or open an issue on GitHub.
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pillar-card">
|
|
||||||
|
|
||||||
### Integrate into your organization
|
|
||||||
|
|
||||||
Works offline, behind firewalls, and on **Windows, macOS, and Linux**. Host your own extension and preset catalogs so your organization controls what gets installed.
|
|
||||||
|
|
||||||
Community extensions like CI Guard and Architecture Guard add compliance gates and governance that fit the way your team already works.
|
|
||||||
|
|
||||||
<a href="installation.md" class="pillar-link">Installation guide →</a>
|
|
||||||
<a href="reference/extensions.md" class="pillar-link">Extensions reference →</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="community-section">
|
|
||||||
|
|
||||||
## Built by the community
|
|
||||||
|
|
||||||
**200+ contributors** power the Spec Kit ecosystem — from core integrations to entirely new development processes. Anyone can create and publish an extension, preset, or workflow.
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">96K+</span>
|
|
||||||
<span class="stat-label">GitHub stars</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">200+</span>
|
|
||||||
<span class="stat-label">Contributors</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">30</span>
|
|
||||||
<span class="stat-label">Integrations</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">91</span>
|
|
||||||
<span class="stat-label">Extensions</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">18</span>
|
|
||||||
<span class="stat-label">Presets</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-number">4</span>
|
|
||||||
<span class="stat-label">Friends projects</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="community/presets.md">Presets</a> · <a href="community/walkthroughs.md">Walkthroughs</a> · <a href="community/friends.md">Friends</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Explore the docs
|
|
||||||
|
|
||||||
<div class="nav-cards">
|
|
||||||
<a href="quickstart.md" class="nav-card">
|
|
||||||
<strong>Getting Started</strong>
|
|
||||||
<span>Install, configure, and run your first SDD workflow</span>
|
|
||||||
</a>
|
|
||||||
<a href="reference/overview.md" class="nav-card">
|
|
||||||
<strong>Reference</strong>
|
|
||||||
<span>Core commands, integrations, extensions, presets, and workflows</span>
|
|
||||||
</a>
|
|
||||||
<a href="community/presets.md" class="nav-card">
|
|
||||||
<strong>Community</strong>
|
|
||||||
<span>Presets, walkthroughs, and friend projects</span>
|
|
||||||
</a>
|
|
||||||
<a href="local-development.md" class="nav-card">
|
|
||||||
<strong>Development</strong>
|
|
||||||
<span>Contribute to Spec Kit</span>
|
|
||||||
</a>
|
|
||||||
<a href="concepts/sdd.md" class="nav-card">
|
|
||||||
<strong>What is SDD?</strong>
|
|
||||||
<span>The philosophy behind Spec-Driven Development</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="footer-cta">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uvx --from git+https://github.com/github/spec-kit.git
|
|
||||||
specify init my-project --integration copilot
|
|
||||||
```
|
|
||||||
|
|
||||||
Ready to start? Follow the [Quick Start Guide](quickstart.md).
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -23,7 +23,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
|||||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
| [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 |
|
| [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` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
|
| [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 |
|
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
|
||||||
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
|
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
|
||||||
| [opencode](https://opencode.ai/) | `opencode` | |
|
| [opencode](https://opencode.ai/) | `opencode` | |
|
||||||
@@ -65,8 +65,6 @@ Installing an additional integration does not change the default integration. Us
|
|||||||
|
|
||||||
> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init <project> --integration <key>` instead.
|
> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init <project> --integration <key>` instead.
|
||||||
|
|
||||||
**Version note:** Controlled multi-install support was introduced in Spec Kit 0.8.5. If `specify integration install <key>` says another integration is already installed and only suggests `switch` or `uninstall`, check your local CLI with `specify version` and upgrade it. Running a one-shot command such as `uvx --from git+https://github.com/github/spec-kit.git specify ...` uses a temporary copy for that command only; it does not update the persistent `specify` executable on your `PATH`.
|
|
||||||
|
|
||||||
## Uninstall an Integration
|
## Uninstall an Integration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
264
docs/template/public/main.css
vendored
264
docs/template/public/main.css
vendored
@@ -1,264 +0,0 @@
|
|||||||
/* Spec Kit landing page — GitHub Primer colors */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* GitHub Primer palette */
|
|
||||||
--gh-blue: #0969da;
|
|
||||||
--gh-green: #1a7f37;
|
|
||||||
--gh-purple: #8250df;
|
|
||||||
--gh-coral: #cf222e;
|
|
||||||
--gh-orange: #bf8700;
|
|
||||||
--gh-blue-subtle: #ddf4ff;
|
|
||||||
--gh-green-subtle: #dafbe1;
|
|
||||||
--gh-purple-subtle: #fbefff;
|
|
||||||
--gh-coral-subtle: #ffebe9;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] {
|
|
||||||
--gh-blue: #58a6ff;
|
|
||||||
--gh-green: #3fb950;
|
|
||||||
--gh-purple: #bc8cff;
|
|
||||||
--gh-coral: #f85149;
|
|
||||||
--gh-orange: #d29922;
|
|
||||||
--gh-blue-subtle: #0d1d30;
|
|
||||||
--gh-green-subtle: #0d1d14;
|
|
||||||
--gh-purple-subtle: #1c0d2e;
|
|
||||||
--gh-coral-subtle: #2d0f0d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override Bootstrap primary with GitHub blue */
|
|
||||||
body[data-layout="landing"] {
|
|
||||||
--bs-primary: var(--gh-blue);
|
|
||||||
--bs-primary-rgb: 9, 105, 218;
|
|
||||||
--bs-link-color: var(--gh-blue);
|
|
||||||
--bs-link-hover-color: var(--gh-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] body[data-layout="landing"],
|
|
||||||
body[data-layout="landing"][data-bs-theme="dark"] {
|
|
||||||
--bs-primary-rgb: 88, 166, 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero section */
|
|
||||||
.landing-hero {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-hero h1 {
|
|
||||||
font-size: 2.6rem;
|
|
||||||
font-weight: 800;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: linear-gradient(135deg, var(--gh-blue), var(--gh-purple));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-hero p {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-hero .btn-primary {
|
|
||||||
background-color: var(--gh-blue);
|
|
||||||
border-color: var(--gh-blue);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-hero .btn-primary:hover {
|
|
||||||
background-color: #0860ca;
|
|
||||||
border-color: #0860ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-hero .btn-outline-primary {
|
|
||||||
color: var(--gh-blue);
|
|
||||||
border-color: var(--gh-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-hero .btn-outline-primary:hover {
|
|
||||||
background-color: var(--gh-blue);
|
|
||||||
border-color: var(--gh-blue);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pillar cards grid */
|
|
||||||
.pillar-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.pillar-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pillar-card {
|
|
||||||
border: 1px solid var(--bs-border-color);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: var(--bs-body-bg);
|
|
||||||
transition: box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
|
||||||
border-top: 3px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Each pillar gets a distinct GitHub color accent */
|
|
||||||
.pillar-card:nth-child(1) { border-top-color: var(--gh-green); }
|
|
||||||
.pillar-card:nth-child(2) { border-top-color: var(--gh-blue); }
|
|
||||||
.pillar-card:nth-child(3) { border-top-color: var(--gh-purple); }
|
|
||||||
.pillar-card:nth-child(4) { border-top-color: var(--gh-coral); }
|
|
||||||
|
|
||||||
.pillar-card:nth-child(1):hover { box-shadow: 0 4px 16px rgba(26, 127, 55, 0.12); }
|
|
||||||
.pillar-card:nth-child(2):hover { box-shadow: 0 4px 16px rgba(9, 105, 218, 0.12); }
|
|
||||||
.pillar-card:nth-child(3):hover { box-shadow: 0 4px 16px rgba(130, 80, 223, 0.12); }
|
|
||||||
.pillar-card:nth-child(4):hover { box-shadow: 0 4px 16px rgba(207, 34, 46, 0.12); }
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] .pillar-card:nth-child(1):hover { box-shadow: 0 4px 16px rgba(63, 185, 80, 0.15); }
|
|
||||||
[data-bs-theme="dark"] .pillar-card:nth-child(2):hover { box-shadow: 0 4px 16px rgba(88, 166, 255, 0.15); }
|
|
||||||
[data-bs-theme="dark"] .pillar-card:nth-child(3):hover { box-shadow: 0 4px 16px rgba(188, 140, 255, 0.15); }
|
|
||||||
[data-bs-theme="dark"] .pillar-card:nth-child(4):hover { box-shadow: 0 4px 16px rgba(248, 81, 73, 0.15); }
|
|
||||||
|
|
||||||
.pillar-card h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pillar headings pick up their card's accent color */
|
|
||||||
.pillar-card:nth-child(1) h3 { color: var(--gh-green); }
|
|
||||||
.pillar-card:nth-child(2) h3 { color: var(--gh-blue); }
|
|
||||||
.pillar-card:nth-child(3) h3 { color: var(--gh-purple); }
|
|
||||||
.pillar-card:nth-child(4) h3 { color: var(--gh-coral); }
|
|
||||||
|
|
||||||
.pillar-card .pillar-stat {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gh-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pillar-card:nth-child(3) .pillar-stat {
|
|
||||||
color: var(--gh-purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pillar-card p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pillar-card ul {
|
|
||||||
padding-left: 1.2rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pillar-card .pillar-link {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pillar-card:nth-child(1) .pillar-link { color: var(--gh-blue); }
|
|
||||||
.pillar-card:nth-child(2) .pillar-link { color: var(--gh-green); }
|
|
||||||
.pillar-card:nth-child(3) .pillar-link { color: var(--gh-purple); }
|
|
||||||
.pillar-card:nth-child(4) .pillar-link { color: var(--gh-coral); }
|
|
||||||
|
|
||||||
/* Community stats section */
|
|
||||||
.community-section {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
max-width: 700px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item .stat-number {
|
|
||||||
display: block;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--gh-blue);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item .stat-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nav cards */
|
|
||||||
.nav-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.nav-cards {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-card {
|
|
||||||
border: 1px solid var(--bs-border-color);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
transition: box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
|
||||||
display: block;
|
|
||||||
border-left: 3px solid var(--gh-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-card:hover {
|
|
||||||
border-color: var(--gh-blue);
|
|
||||||
border-left-color: var(--gh-blue);
|
|
||||||
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.1);
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] .nav-card:hover {
|
|
||||||
box-shadow: 0 2px 8px rgba(88, 166, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-card strong {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
color: var(--gh-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-card span {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer CTA */
|
|
||||||
.footer-cta {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-cta code {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
}
|
|
||||||
@@ -30,12 +30,6 @@
|
|||||||
- name: Workflows
|
- name: Workflows
|
||||||
href: reference/workflows.md
|
href: reference/workflows.md
|
||||||
|
|
||||||
# Concepts
|
|
||||||
- name: Concepts
|
|
||||||
items:
|
|
||||||
- name: What is SDD?
|
|
||||||
href: concepts/sdd.md
|
|
||||||
|
|
||||||
# Development workflows
|
# Development workflows
|
||||||
- name: Development
|
- name: Development
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -19,12 +19,6 @@
|
|||||||
|
|
||||||
The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes.
|
The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes.
|
||||||
|
|
||||||
Before upgrading, you can check whether a newer released version is available:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
specify self check
|
|
||||||
```
|
|
||||||
|
|
||||||
### If you installed with `uv tool install`
|
### If you installed with `uv tool install`
|
||||||
|
|
||||||
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
|
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
|
||||||
@@ -41,8 +35,6 @@ Specify the desired release tag:
|
|||||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
|
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
|
||||||
```
|
```
|
||||||
|
|
||||||
`uvx` runs a temporary copy of Spec Kit for that single command. It does not update a persistent `specify` installed with `uv tool install`, `pipx`, or another tool manager. If a newer feature works through `uvx` but your local `specify` still reports an older version, upgrade the persistent CLI with the command that matches your install method.
|
|
||||||
|
|
||||||
### If you installed with `pipx`
|
### If you installed with `pipx`
|
||||||
|
|
||||||
Upgrade to a specific release:
|
Upgrade to a specific release:
|
||||||
@@ -57,7 +49,7 @@ pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
|||||||
specify check
|
specify check
|
||||||
```
|
```
|
||||||
|
|
||||||
This shows installed tools and confirms the CLI is working. Use `specify version` to confirm which persistent CLI version is currently on your `PATH`.
|
This shows installed tools and confirms the CLI is working.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-05-12T21:40:51Z",
|
"updated_at": "2026-05-07T05:51:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"aide": {
|
"aide": {
|
||||||
@@ -100,43 +100,6 @@
|
|||||||
"created_at": "2026-05-04T00:00:00Z",
|
"created_at": "2026-05-04T00:00:00Z",
|
||||||
"updated_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": {
|
"architect-preview": {
|
||||||
"name": "Architect Impact Previewer",
|
"name": "Architect Impact Previewer",
|
||||||
"id": "architect-preview",
|
"id": "architect-preview",
|
||||||
@@ -174,8 +137,8 @@
|
|||||||
"id": "architecture-guard",
|
"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.",
|
"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",
|
"author": "DyanGalih",
|
||||||
"version": "1.8.4",
|
"version": "1.6.7",
|
||||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.4.zip",
|
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.6.7.zip",
|
||||||
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||||
"homepage": "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",
|
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
|
||||||
@@ -185,8 +148,8 @@
|
|||||||
"speckit_version": ">=0.1.0"
|
"speckit_version": ">=0.1.0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"commands": 10,
|
"commands": 6,
|
||||||
"hooks": 3
|
"hooks": 0
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
"architecture",
|
"architecture",
|
||||||
@@ -200,7 +163,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-05-05T07:26:00Z",
|
"created_at": "2026-05-05T07:26:00Z",
|
||||||
"updated_at": "2026-05-11T14:58:00Z"
|
"updated_at": "2026-05-06T22:28:55Z"
|
||||||
},
|
},
|
||||||
"archive": {
|
"archive": {
|
||||||
"name": "Archive Extension",
|
"name": "Archive Extension",
|
||||||
@@ -368,38 +331,6 @@
|
|||||||
"created_at": "2026-04-10T00:00:00Z",
|
"created_at": "2026-04-10T00:00:00Z",
|
||||||
"updated_at": "2026-04-10T00:00:00Z"
|
"updated_at": "2026-04-10T00:00:00Z"
|
||||||
},
|
},
|
||||||
"brownkit": {
|
|
||||||
"name": "BrownKit \u2014 Brownfield Discovery for Spec-Kit",
|
|
||||||
"id": "brownkit",
|
|
||||||
"description": "Evidence-driven capability discovery, security and QA risk assessment for existing codebases.",
|
|
||||||
"author": "Maksim Shautsou",
|
|
||||||
"version": "1.0.1",
|
|
||||||
"download_url": "https://github.com/MaksimShevtsov/BrownKit/archive/refs/tags/v1.0.1.zip",
|
|
||||||
"repository": "https://github.com/MaksimShevtsov/BrownKit",
|
|
||||||
"homepage": "https://github.com/MaksimShevtsov/BrownKit",
|
|
||||||
"documentation": "https://github.com/MaksimShevtsov/BrownKit/blob/main/README.md",
|
|
||||||
"changelog": "https://github.com/MaksimShevtsov/BrownKit/blob/main/CHANGELOG.md",
|
|
||||||
"license": "MIT",
|
|
||||||
"requires": {
|
|
||||||
"speckit_version": ">=0.1.0"
|
|
||||||
},
|
|
||||||
"provides": {
|
|
||||||
"commands": 10,
|
|
||||||
"hooks": 5
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"brownfield",
|
|
||||||
"discovery",
|
|
||||||
"security",
|
|
||||||
"qa",
|
|
||||||
"capabilities"
|
|
||||||
],
|
|
||||||
"verified": false,
|
|
||||||
"downloads": 0,
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": "2026-05-10T00:00:00Z",
|
|
||||||
"updated_at": "2026-05-10T00:00:00Z"
|
|
||||||
},
|
|
||||||
"bugfix": {
|
"bugfix": {
|
||||||
"name": "Bugfix Workflow",
|
"name": "Bugfix Workflow",
|
||||||
"id": "bugfix",
|
"id": "bugfix",
|
||||||
@@ -499,38 +430,6 @@
|
|||||||
"created_at": "2026-04-16T00:00:00Z",
|
"created_at": "2026-04-16T00:00:00Z",
|
||||||
"updated_at": "2026-04-16T00:00:00Z"
|
"updated_at": "2026-04-16T00:00:00Z"
|
||||||
},
|
},
|
||||||
"changelog": {
|
|
||||||
"name": "Spec Changelog",
|
|
||||||
"id": "changelog",
|
|
||||||
"description": "Auto-generate changelogs and release notes from spec git history and requirement diffs.",
|
|
||||||
"author": "Quratulain-bilal",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-changelog/archive/refs/tags/v1.0.0.zip",
|
|
||||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-changelog",
|
|
||||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-changelog",
|
|
||||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-changelog/blob/main/README.md",
|
|
||||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-changelog/blob/main/CHANGELOG.md",
|
|
||||||
"license": "MIT",
|
|
||||||
"requires": {
|
|
||||||
"speckit_version": ">=0.4.0"
|
|
||||||
},
|
|
||||||
"provides": {
|
|
||||||
"commands": 4,
|
|
||||||
"hooks": 1
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"changelog",
|
|
||||||
"release-notes",
|
|
||||||
"documentation",
|
|
||||||
"git-history",
|
|
||||||
"notifications"
|
|
||||||
],
|
|
||||||
"verified": false,
|
|
||||||
"downloads": 0,
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": "2026-04-11T18:00:00Z",
|
|
||||||
"updated_at": "2026-04-11T18:00:00Z"
|
|
||||||
},
|
|
||||||
"ci-guard": {
|
"ci-guard": {
|
||||||
"name": "CI Guard",
|
"name": "CI Guard",
|
||||||
"id": "ci-guard",
|
"id": "ci-guard",
|
||||||
@@ -1480,35 +1379,6 @@
|
|||||||
"created_at": "2026-04-28T00:00:00Z",
|
"created_at": "2026-04-28T00:00:00Z",
|
||||||
"updated_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": {
|
"memory-loader": {
|
||||||
"name": "Memory Loader",
|
"name": "Memory Loader",
|
||||||
"id": "memory-loader",
|
"id": "memory-loader",
|
||||||
@@ -1545,8 +1415,8 @@
|
|||||||
"id": "memory-md",
|
"id": "memory-md",
|
||||||
"description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context",
|
"description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context",
|
||||||
"author": "DyanGalih",
|
"author": "DyanGalih",
|
||||||
"version": "0.8.5",
|
"version": "0.7.9",
|
||||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.8.5.zip",
|
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.9.zip",
|
||||||
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||||
"homepage": "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",
|
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
|
||||||
@@ -1556,8 +1426,8 @@
|
|||||||
"speckit_version": ">=0.2.0"
|
"speckit_version": ">=0.2.0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"commands": 7,
|
"commands": 6,
|
||||||
"hooks": 2
|
"hooks": 0
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
"memory",
|
"memory",
|
||||||
@@ -1571,7 +1441,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-04-23T00:00:00Z",
|
"created_at": "2026-04-23T00:00:00Z",
|
||||||
"updated_at": "2026-05-11T14:58:00Z"
|
"updated_at": "2026-05-06T22:28:55Z"
|
||||||
},
|
},
|
||||||
"memorylint": {
|
"memorylint": {
|
||||||
"name": "MemoryLint",
|
"name": "MemoryLint",
|
||||||
@@ -2209,38 +2079,6 @@
|
|||||||
"created_at": "2026-04-20T00:00:00Z",
|
"created_at": "2026-04-20T00:00:00Z",
|
||||||
"updated_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": {
|
"scope": {
|
||||||
"name": "Spec Scope",
|
"name": "Spec Scope",
|
||||||
"id": "scope",
|
"id": "scope",
|
||||||
@@ -2279,8 +2117,8 @@
|
|||||||
"id": "security-review",
|
"id": "security-review",
|
||||||
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
||||||
"author": "DyanGalih",
|
"author": "DyanGalih",
|
||||||
"version": "1.5.0",
|
"version": "1.4.5",
|
||||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.0.zip",
|
"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",
|
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||||
"homepage": "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",
|
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
||||||
@@ -2290,8 +2128,8 @@
|
|||||||
"speckit_version": ">=0.1.0"
|
"speckit_version": ">=0.1.0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"commands": 9,
|
"commands": 7,
|
||||||
"hooks": 3
|
"hooks": 0
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
"security",
|
"security",
|
||||||
@@ -2304,7 +2142,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-04-03T03:24:03Z",
|
"created_at": "2026-04-03T03:24:03Z",
|
||||||
"updated_at": "2026-05-11T14:58:00Z"
|
"updated_at": "2026-05-06T22:28:55Z"
|
||||||
},
|
},
|
||||||
"sf": {
|
"sf": {
|
||||||
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
||||||
|
|||||||
@@ -256,43 +256,6 @@
|
|||||||
"created_at": "2026-04-09T08:00:00Z",
|
"created_at": "2026-04-09T08:00:00Z",
|
||||||
"updated_at": "2026-04-27T08:00:00Z"
|
"updated_at": "2026-04-27T08:00:00Z"
|
||||||
},
|
},
|
||||||
"game-narrative-writing": {
|
|
||||||
"name": "Game Narrative Writing",
|
|
||||||
"id": "game-narrative-writing",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Spec-Driven Development for interactive game-narrative pre-production in video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture.",
|
|
||||||
"author": "Andreas Daumann",
|
|
||||||
"repository": "https://github.com/adaumann/speckit-preset-game-narrative-writing",
|
|
||||||
"download_url": "https://github.com/adaumann/speckit-preset-game-narrative-writing/archive/refs/tags/v1.0.0.zip",
|
|
||||||
"homepage": "https://github.com/adaumann/speckit-preset-game-narrative-writing",
|
|
||||||
"documentation": "https://github.com/adaumann/speckit-preset-game-narrative-writing/blob/main/game-narrative-writing/README.md",
|
|
||||||
"license": "MIT",
|
|
||||||
"requires": {
|
|
||||||
"speckit_version": ">=0.5.0"
|
|
||||||
},
|
|
||||||
"provides": {
|
|
||||||
"templates": 22,
|
|
||||||
"commands": 36,
|
|
||||||
"scripts": 2
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"game-writing",
|
|
||||||
"interactive-fiction",
|
|
||||||
"twine",
|
|
||||||
"ink",
|
|
||||||
"renpy",
|
|
||||||
"point-and-click",
|
|
||||||
"branching-narrative",
|
|
||||||
"choice-if",
|
|
||||||
"visual-novel",
|
|
||||||
"mechanic-hooks",
|
|
||||||
"game-narrative",
|
|
||||||
"export",
|
|
||||||
"series"
|
|
||||||
],
|
|
||||||
"created_at": "2026-05-05T08:00:00Z",
|
|
||||||
"updated_at": "2026-05-05T08:00:00Z"
|
|
||||||
},
|
|
||||||
"isaqb-architecture-governance": {
|
"isaqb-architecture-governance": {
|
||||||
"name": "iSAQB Architecture Governance",
|
"name": "iSAQB Architecture Governance",
|
||||||
"id": "isaqb-architecture-governance",
|
"id": "isaqb-architecture-governance",
|
||||||
@@ -348,37 +311,6 @@
|
|||||||
"created_at": "2026-04-15T00:00:00Z",
|
"created_at": "2026-04-15T00:00:00Z",
|
||||||
"updated_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": {
|
"multi-repo-branching": {
|
||||||
"name": "Multi-Repo Branching",
|
"name": "Multi-Repo Branching",
|
||||||
"id": "multi-repo-branching",
|
"id": "multi-repo-branching",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.8.9"
|
version = "0.8.7"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -769,8 +769,6 @@ def _install_shared_infra(
|
|||||||
tracker: StepTracker | None = None,
|
tracker: StepTracker | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
invoke_separator: str = ".",
|
invoke_separator: str = ".",
|
||||||
refresh_managed: bool = False,
|
|
||||||
refresh_hint: str | None = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Install shared infrastructure files into *project_path*.
|
"""Install shared infrastructure files into *project_path*.
|
||||||
|
|
||||||
@@ -782,23 +780,9 @@ def _install_shared_infra(
|
|||||||
placeholders using *invoke_separator* (``"."`` for markdown agents,
|
placeholders using *invoke_separator* (``"."`` for markdown agents,
|
||||||
``"-"`` for skills agents).
|
``"-"`` for skills agents).
|
||||||
|
|
||||||
Overwrite policy:
|
When *force* is ``True``, existing files are overwritten with the
|
||||||
|
latest bundled versions. When ``False`` (default), only missing
|
||||||
* ``force=True`` — overwrite every existing file (still skips symlinks
|
files are added and existing ones are skipped.
|
||||||
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.
|
Returns ``True`` on success.
|
||||||
"""
|
"""
|
||||||
@@ -811,8 +795,6 @@ def _install_shared_infra(
|
|||||||
console=console,
|
console=console,
|
||||||
force=force,
|
force=force,
|
||||||
invoke_separator=invoke_separator,
|
invoke_separator=invoke_separator,
|
||||||
refresh_managed=refresh_managed,
|
|
||||||
refresh_hint=refresh_hint,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -822,8 +804,6 @@ def _install_shared_infra_or_exit(
|
|||||||
tracker: StepTracker | None = None,
|
tracker: StepTracker | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
invoke_separator: str = ".",
|
invoke_separator: str = ".",
|
||||||
refresh_managed: bool = False,
|
|
||||||
refresh_hint: str | None = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
return _install_shared_infra(
|
return _install_shared_infra(
|
||||||
@@ -832,8 +812,6 @@ def _install_shared_infra_or_exit(
|
|||||||
tracker=tracker,
|
tracker=tracker,
|
||||||
force=force,
|
force=force,
|
||||||
invoke_separator=invoke_separator,
|
invoke_separator=invoke_separator,
|
||||||
refresh_managed=refresh_managed,
|
|
||||||
refresh_hint=refresh_hint,
|
|
||||||
)
|
)
|
||||||
except (ValueError, OSError) as exc:
|
except (ValueError, OSError) as exc:
|
||||||
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
|
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
|
||||||
@@ -1784,14 +1762,22 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
|||||||
On anything else — including a malformed response body — the exception
|
On anything else — including a malformed response body — the exception
|
||||||
propagates; there is no catch-all (research D-006).
|
propagates; there is no catch-all (research D-006).
|
||||||
"""
|
"""
|
||||||
from .authentication.http import open_url
|
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}")
|
||||||
try:
|
try:
|
||||||
with open_url(
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
GITHUB_API_LATEST,
|
|
||||||
timeout=5,
|
|
||||||
extra_headers={"Accept": "application/vnd.github+json"},
|
|
||||||
) as resp:
|
|
||||||
payload = json.loads(resp.read().decode("utf-8"))
|
payload = json.loads(resp.read().decode("utf-8"))
|
||||||
tag = payload.get("tag_name")
|
tag = payload.get("tag_name")
|
||||||
if not isinstance(tag, str) or not tag:
|
if not isinstance(tag, str) or not tag:
|
||||||
@@ -1800,9 +1786,7 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
|||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
# Order matters: HTTPError is a subclass of URLError.
|
# Order matters: HTTPError is a subclass of URLError.
|
||||||
if e.code == 403:
|
if e.code == 403:
|
||||||
return None, (
|
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
|
||||||
)
|
|
||||||
return None, f"HTTP {e.code}"
|
return None, f"HTTP {e.code}"
|
||||||
except (urllib.error.URLError, OSError):
|
except (urllib.error.URLError, OSError):
|
||||||
return None, "offline or timeout"
|
return None, "offline or timeout"
|
||||||
@@ -2605,8 +2589,7 @@ def integration_uninstall(
|
|||||||
def integration_switch(
|
def integration_switch(
|
||||||
target: str = typer.Argument(help="Integration key to switch to"),
|
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)"),
|
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 of the previous integration"),
|
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"),
|
||||||
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'),
|
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
||||||
):
|
):
|
||||||
"""Switch from the current integration to a different one."""
|
"""Switch from the current integration to a different one."""
|
||||||
@@ -2777,27 +2760,14 @@ def integration_switch(
|
|||||||
target_integration, current, target, integration_options
|
target_integration, current, target, integration_options
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh shared infrastructure to the current CLI version. Switching
|
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||||
# integrations is exactly when stale vendored shared scripts (e.g.
|
# _install_shared_infra merges missing files without overwriting).
|
||||||
# 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(
|
_install_shared_infra_or_exit(
|
||||||
project_root,
|
project_root,
|
||||||
selected_script,
|
selected_script,
|
||||||
force=refresh_shared_infra,
|
|
||||||
refresh_managed=True,
|
|
||||||
invoke_separator=_invoke_separator_for_integration(
|
invoke_separator=_invoke_separator_for_integration(
|
||||||
target_integration, current, target, parsed_options
|
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":
|
if os.name != "nt":
|
||||||
ensure_executable_scripts(project_root)
|
ensure_executable_scripts(project_root)
|
||||||
@@ -3411,9 +3381,7 @@ def preset_add(
|
|||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
zip_path = Path(tmpdir) / "preset.zip"
|
zip_path = Path(tmpdir) / "preset.zip"
|
||||||
try:
|
try:
|
||||||
from specify_cli.authentication.http import open_url as _open_url
|
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||||
|
|
||||||
with _open_url(from_url, timeout=60) as response:
|
|
||||||
zip_path.write_bytes(response.read())
|
zip_path.write_bytes(response.read())
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||||
@@ -4317,9 +4285,7 @@ def extension_add(
|
|||||||
zip_path = download_dir / f"{extension}-url-download.zip"
|
zip_path = download_dir / f"{extension}-url-download.zip"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from specify_cli.authentication.http import open_url as _open_url
|
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||||
|
|
||||||
with _open_url(from_url, timeout=60) as response:
|
|
||||||
zip_data = response.read()
|
zip_data = response.read()
|
||||||
zip_path.write_bytes(zip_data)
|
zip_path.write_bytes(zip_data)
|
||||||
|
|
||||||
@@ -5534,7 +5500,7 @@ def workflow_add(
|
|||||||
if source.startswith("http://") or source.startswith("https://"):
|
if source.startswith("http://") or source.startswith("https://"):
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from specify_cli.authentication.http import open_url as _open_url
|
from urllib.request import urlopen # noqa: S310
|
||||||
|
|
||||||
parsed_src = urlparse(source)
|
parsed_src = urlparse(source)
|
||||||
src_host = parsed_src.hostname or ""
|
src_host = parsed_src.hostname or ""
|
||||||
@@ -5551,7 +5517,7 @@ def workflow_add(
|
|||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
try:
|
try:
|
||||||
with _open_url(source, timeout=30) as resp:
|
with urlopen(source, timeout=30) as resp: # noqa: S310
|
||||||
final_url = resp.geturl()
|
final_url = resp.geturl()
|
||||||
final_parsed = urlparse(final_url)
|
final_parsed = urlparse(final_url)
|
||||||
final_host = final_parsed.hostname or ""
|
final_host = final_parsed.hostname or ""
|
||||||
@@ -5647,10 +5613,10 @@ def workflow_add(
|
|||||||
workflow_file = workflow_dir / "workflow.yml"
|
workflow_file = workflow_dir / "workflow.yml"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from specify_cli.authentication.http import open_url as _open_url
|
from urllib.request import urlopen # noqa: S310 — URL comes from catalog
|
||||||
|
|
||||||
workflow_dir.mkdir(parents=True, exist_ok=True)
|
workflow_dir.mkdir(parents=True, exist_ok=True)
|
||||||
with _open_url(workflow_url, timeout=30) as response:
|
with urlopen(workflow_url, timeout=30) as response: # noqa: S310
|
||||||
# Validate final URL after redirects
|
# Validate final URL after redirects
|
||||||
final_url = response.geturl()
|
final_url = response.geturl()
|
||||||
final_parsed = urlparse(final_url)
|
final_parsed = urlparse(final_url)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"""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
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""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}"}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
"""Shared catalog stack config primitives.
|
|
||||||
|
|
||||||
Catalog-backed features use the same local config shape and URL validation
|
|
||||||
rules. This module keeps those narrow primitives in one place while individual
|
|
||||||
catalog types keep their active source resolution, fetch, cache, and
|
|
||||||
domain-specific validation behavior.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import ClassVar
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CatalogEntry:
|
|
||||||
"""Represents a single catalog source in a catalog stack."""
|
|
||||||
|
|
||||||
url: str
|
|
||||||
name: str
|
|
||||||
priority: int
|
|
||||||
install_allowed: bool
|
|
||||||
description: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class CatalogStackBase:
|
|
||||||
"""Base class for ordered catalog-source resolution.
|
|
||||||
|
|
||||||
Subclasses provide catalog-specific metadata and exception classes. Fetching
|
|
||||||
and schema validation stay in each concrete catalog because those formats
|
|
||||||
differ across integrations, extensions, presets, and workflows.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ENTRY_CLASS: ClassVar[type[CatalogEntry]] = CatalogEntry
|
|
||||||
ERROR_TYPE: ClassVar[type[Exception]] = ValueError
|
|
||||||
VALIDATION_ERROR_TYPE: ClassVar[type[Exception]] = ValueError
|
|
||||||
|
|
||||||
CONFIG_FILENAME: ClassVar[str]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _error(cls, message: str) -> Exception:
|
|
||||||
return cls.ERROR_TYPE(message)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _validation_error(cls, message: str) -> Exception:
|
|
||||||
return cls.VALIDATION_ERROR_TYPE(message)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _entry(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
url: str,
|
|
||||||
name: str,
|
|
||||||
priority: int,
|
|
||||||
install_allowed: bool,
|
|
||||||
description: str = "",
|
|
||||||
) -> CatalogEntry:
|
|
||||||
return cls.ENTRY_CLASS(
|
|
||||||
url=url,
|
|
||||||
name=name,
|
|
||||||
priority=priority,
|
|
||||||
install_allowed=install_allowed,
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _validate_catalog_url(cls, url: str) -> None:
|
|
||||||
"""Validate that a catalog URL uses HTTPS, except localhost HTTP."""
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
parsed = urlparse(url)
|
|
||||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
||||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
|
||||||
raise cls._error(
|
|
||||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
|
||||||
"HTTP is only allowed for localhost."
|
|
||||||
)
|
|
||||||
if not parsed.netloc:
|
|
||||||
raise cls._error("Catalog URL must be a valid URL with a host.")
|
|
||||||
|
|
||||||
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:
|
|
||||||
"""Load catalog stack configuration from a YAML file.
|
|
||||||
|
|
||||||
Returns ``None`` when the file does not exist. Existing files fail
|
|
||||||
closed when they are malformed, empty, or contain no usable URLs.
|
|
||||||
"""
|
|
||||||
if not config_path.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
||||||
except (yaml.YAMLError, OSError, UnicodeError) as exc:
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Failed to read catalog config {config_path}: {exc}"
|
|
||||||
) from exc
|
|
||||||
if data is None:
|
|
||||||
data = {}
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
|
|
||||||
)
|
|
||||||
|
|
||||||
catalogs_data = data.get("catalogs", [])
|
|
||||||
if not isinstance(catalogs_data, list):
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Invalid catalog config {config_path}: 'catalogs' must be a list, "
|
|
||||||
f"got {type(catalogs_data).__name__}"
|
|
||||||
)
|
|
||||||
if not catalogs_data:
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
|
|
||||||
f"Remove the file to use built-in defaults, or add valid catalog entries."
|
|
||||||
)
|
|
||||||
|
|
||||||
entries: list[CatalogEntry] = []
|
|
||||||
skipped: list[int] = []
|
|
||||||
for idx, item in enumerate(catalogs_data):
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Invalid catalog config {config_path}: catalog entry at index {idx}: "
|
|
||||||
f"expected a mapping, got {type(item).__name__}"
|
|
||||||
)
|
|
||||||
url = str(item.get("url", "")).strip()
|
|
||||||
if not url:
|
|
||||||
skipped.append(idx)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
self._validate_catalog_url(url)
|
|
||||||
except self.ERROR_TYPE as exc:
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Invalid catalog URL in {config_path} at index {idx}: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
raw_priority = item.get("priority", idx + 1)
|
|
||||||
if isinstance(raw_priority, bool):
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Invalid catalog config {config_path}: "
|
|
||||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
|
||||||
f"expected integer, got {raw_priority!r}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
priority = int(raw_priority)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Invalid catalog config {config_path}: "
|
|
||||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
|
||||||
f"expected integer, got {raw_priority!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_install = item.get("install_allowed", False)
|
|
||||||
if isinstance(raw_install, str):
|
|
||||||
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
|
|
||||||
else:
|
|
||||||
install_allowed = bool(raw_install)
|
|
||||||
|
|
||||||
raw_name = item.get("name")
|
|
||||||
name = str(raw_name).strip() if raw_name is not None else ""
|
|
||||||
if not name:
|
|
||||||
name = f"catalog-{len(entries) + 1}"
|
|
||||||
|
|
||||||
entries.append(
|
|
||||||
self._entry(
|
|
||||||
url=url,
|
|
||||||
name=name,
|
|
||||||
priority=priority,
|
|
||||||
install_allowed=install_allowed,
|
|
||||||
description=str(item.get("description", "")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
entries.sort(key=lambda e: e.priority)
|
|
||||||
if not entries:
|
|
||||||
raise self._validation_error(
|
|
||||||
f"Catalog config {config_path} contains {len(catalogs_data)} "
|
|
||||||
f"entries but none have valid URLs (entries at indices {skipped} "
|
|
||||||
f"were skipped). Each catalog entry must have a 'url' field."
|
|
||||||
)
|
|
||||||
return entries
|
|
||||||
@@ -1707,20 +1707,20 @@ class ExtensionCatalog:
|
|||||||
raise ValidationError("Catalog URL must be a valid URL with a host.")
|
raise ValidationError("Catalog URL must be a valid URL with a host.")
|
||||||
|
|
||||||
def _make_request(self, url: str):
|
def _make_request(self, url: str):
|
||||||
"""Build a urllib Request, adding auth headers when a provider matches.
|
"""Build a urllib Request, adding a GitHub auth header when available.
|
||||||
|
|
||||||
Delegates to :func:`specify_cli.authentication.http.build_request`.
|
Delegates to :func:`specify_cli._github_http.build_github_request`.
|
||||||
"""
|
"""
|
||||||
from specify_cli.authentication.http import build_request
|
from specify_cli._github_http import build_github_request
|
||||||
return build_request(url)
|
return build_github_request(url)
|
||||||
|
|
||||||
def _open_url(self, url: str, timeout: int = 10):
|
def _open_url(self, url: str, timeout: int = 10):
|
||||||
"""Open a URL with provider-based auth, trying each configured provider.
|
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||||
|
|
||||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
Delegates to :func:`specify_cli._github_http.open_github_url`.
|
||||||
"""
|
"""
|
||||||
from specify_cli.authentication.http import open_url
|
from specify_cli._github_http import open_github_url
|
||||||
return open_url(url, timeout)
|
return open_github_url(url, timeout)
|
||||||
|
|
||||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
||||||
"""Load catalog stack configuration from a YAML file.
|
"""Load catalog stack configuration from a YAML file.
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
import yaml
|
import yaml
|
||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
|
|
||||||
from ..catalogs import CatalogEntry, CatalogStackBase
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Errors
|
# Errors
|
||||||
@@ -45,15 +43,21 @@ class IntegrationDescriptorError(Exception):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class IntegrationCatalogEntry(CatalogEntry):
|
class IntegrationCatalogEntry:
|
||||||
"""Represents a single catalog source in the catalog stack."""
|
"""Represents a single catalog source in the catalog stack."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
name: str
|
||||||
|
priority: int
|
||||||
|
install_allowed: bool
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# IntegrationCatalog
|
# IntegrationCatalog
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class IntegrationCatalog(CatalogStackBase):
|
class IntegrationCatalog:
|
||||||
"""Manages integration catalog fetching, caching, and searching."""
|
"""Manages integration catalog fetching, caching, and searching."""
|
||||||
|
|
||||||
DEFAULT_CATALOG_URL = (
|
DEFAULT_CATALOG_URL = (
|
||||||
@@ -63,15 +67,136 @@ class IntegrationCatalog(CatalogStackBase):
|
|||||||
"https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json"
|
"https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json"
|
||||||
)
|
)
|
||||||
CACHE_DURATION = 3600 # 1 hour
|
CACHE_DURATION = 3600 # 1 hour
|
||||||
CONFIG_FILENAME = "integration-catalogs.yml"
|
|
||||||
ENTRY_CLASS = IntegrationCatalogEntry
|
|
||||||
ERROR_TYPE = IntegrationCatalogError
|
|
||||||
VALIDATION_ERROR_TYPE = IntegrationValidationError
|
|
||||||
|
|
||||||
def __init__(self, project_root: Path) -> None:
|
def __init__(self, project_root: Path) -> None:
|
||||||
self.project_root = project_root
|
self.project_root = project_root
|
||||||
self.cache_dir = project_root / ".specify" / "integrations" / ".cache"
|
self.cache_dir = project_root / ".specify" / "integrations" / ".cache"
|
||||||
|
|
||||||
|
# -- URL validation ---------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_catalog_url(url: str) -> None:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||||
|
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||||
|
raise IntegrationCatalogError(
|
||||||
|
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||||
|
"HTTP is only allowed for localhost."
|
||||||
|
)
|
||||||
|
if not parsed.netloc:
|
||||||
|
raise IntegrationCatalogError(
|
||||||
|
"Catalog URL must be a valid URL with a host."
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Catalog stack ----------------------------------------------------
|
||||||
|
|
||||||
|
def _load_catalog_config(
|
||||||
|
self, config_path: Path
|
||||||
|
) -> Optional[List[IntegrationCatalogEntry]]:
|
||||||
|
"""Load catalog stack from a YAML file.
|
||||||
|
|
||||||
|
Returns None when the file does not exist.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
IntegrationValidationError: on any local-config / YAML problem
|
||||||
|
(parse failures, wrong shape, missing/invalid fields,
|
||||||
|
invalid catalog URLs, etc.). This is a subclass of
|
||||||
|
:class:`IntegrationCatalogError`, so any caller that already
|
||||||
|
catches ``IntegrationCatalogError`` keeps working — but
|
||||||
|
callers that want to distinguish *local config* problems
|
||||||
|
from *remote/network* problems can match the subclass.
|
||||||
|
"""
|
||||||
|
if not config_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
|
except (yaml.YAMLError, OSError, UnicodeError) as exc:
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Failed to read catalog config {config_path}: {exc}"
|
||||||
|
) from exc
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
|
||||||
|
)
|
||||||
|
catalogs_data = data.get("catalogs", [])
|
||||||
|
if not isinstance(catalogs_data, list):
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Invalid catalog config {config_path}: 'catalogs' must be a list, "
|
||||||
|
f"got {type(catalogs_data).__name__}"
|
||||||
|
)
|
||||||
|
if not catalogs_data:
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
|
||||||
|
f"Remove the file to use built-in defaults, or add valid catalog entries."
|
||||||
|
)
|
||||||
|
entries: List[IntegrationCatalogEntry] = []
|
||||||
|
skipped: List[int] = []
|
||||||
|
for idx, item in enumerate(catalogs_data):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Invalid catalog config {config_path}: catalog entry at index {idx}: "
|
||||||
|
f"expected a mapping, got {type(item).__name__}"
|
||||||
|
)
|
||||||
|
url = str(item.get("url", "")).strip()
|
||||||
|
if not url:
|
||||||
|
skipped.append(idx)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self._validate_catalog_url(url)
|
||||||
|
except IntegrationCatalogError as exc:
|
||||||
|
# ``_validate_catalog_url`` raises the base class for direct
|
||||||
|
# callers (e.g. ``add_catalog`` validating user input); when
|
||||||
|
# the bad URL came from a local config file, surface it as a
|
||||||
|
# validation error so CLI handlers can route it accordingly.
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Invalid catalog URL in {config_path} at index {idx}: {exc}"
|
||||||
|
) from exc
|
||||||
|
raw_priority = item.get("priority", idx + 1)
|
||||||
|
if isinstance(raw_priority, bool):
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Invalid catalog config {config_path}: "
|
||||||
|
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||||
|
f"expected integer, got {raw_priority!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
priority = int(raw_priority)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Invalid catalog config {config_path}: "
|
||||||
|
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||||
|
f"expected integer, got {raw_priority!r}"
|
||||||
|
)
|
||||||
|
raw_install = item.get("install_allowed", False)
|
||||||
|
if isinstance(raw_install, str):
|
||||||
|
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
|
||||||
|
else:
|
||||||
|
install_allowed = bool(raw_install)
|
||||||
|
raw_name = item.get("name")
|
||||||
|
name = str(raw_name).strip() if raw_name is not None else ""
|
||||||
|
if not name:
|
||||||
|
name = f"catalog-{len(entries) + 1}"
|
||||||
|
entries.append(
|
||||||
|
IntegrationCatalogEntry(
|
||||||
|
url=url,
|
||||||
|
name=name,
|
||||||
|
priority=priority,
|
||||||
|
install_allowed=install_allowed,
|
||||||
|
description=str(item.get("description", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entries.sort(key=lambda e: e.priority)
|
||||||
|
if not entries:
|
||||||
|
raise IntegrationValidationError(
|
||||||
|
f"Catalog config {config_path} contains {len(catalogs_data)} "
|
||||||
|
f"entries but none have valid URLs (entries at indices {skipped} "
|
||||||
|
f"were skipped). Each catalog entry must have a 'url' field."
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
def get_active_catalogs(self) -> List[IntegrationCatalogEntry]:
|
def get_active_catalogs(self) -> List[IntegrationCatalogEntry]:
|
||||||
"""Return the ordered list of active integration catalogs.
|
"""Return the ordered list of active integration catalogs.
|
||||||
|
|
||||||
@@ -140,6 +265,7 @@ class IntegrationCatalog(CatalogStackBase):
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Fetch one catalog, with per-URL caching."""
|
"""Fetch one catalog, with per-URL caching."""
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
|
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
|
||||||
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
|
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
|
||||||
@@ -163,9 +289,7 @@ class IntegrationCatalog(CatalogStackBase):
|
|||||||
pass # Cache cleanup is best-effort; ignore deletion failures.
|
pass # Cache cleanup is best-effort; ignore deletion failures.
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from specify_cli.authentication.http import open_url
|
with urllib.request.urlopen(entry.url, timeout=10) as resp:
|
||||||
|
|
||||||
with open_url(entry.url, timeout=10) as resp:
|
|
||||||
# Validate final URL after redirects
|
# Validate final URL after redirects
|
||||||
final_url = resp.geturl()
|
final_url = resp.geturl()
|
||||||
if final_url != entry.url:
|
if final_url != entry.url:
|
||||||
@@ -319,6 +443,8 @@ class IntegrationCatalog(CatalogStackBase):
|
|||||||
|
|
||||||
# -- Catalog-source management ----------------------------------------
|
# -- Catalog-source management ----------------------------------------
|
||||||
|
|
||||||
|
CONFIG_FILENAME = "integration-catalogs.yml"
|
||||||
|
|
||||||
def get_catalog_configs(self) -> List[Dict[str, Any]]:
|
def get_catalog_configs(self) -> List[Dict[str, Any]]:
|
||||||
"""Return the active catalog stack as a list of dicts.
|
"""Return the active catalog stack as a list of dicts.
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,6 @@
|
|||||||
from ..base import MarkdownIntegration
|
from ..base import MarkdownIntegration
|
||||||
|
|
||||||
|
|
||||||
# Kiro CLI file-based prompts do NOT support any argument-substitution syntax,
|
|
||||||
# so a raw "$ARGUMENTS" token would reach the model verbatim and break the
|
|
||||||
# prompt (issue #1926, kirodotdev/Kiro#4141). Use a prose fallback so the
|
|
||||||
# rendered prompt instructs the model to take its argument from the user's
|
|
||||||
# next message.
|
|
||||||
_KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
|
|
||||||
|
|
||||||
|
|
||||||
class KiroCliIntegration(MarkdownIntegration):
|
class KiroCliIntegration(MarkdownIntegration):
|
||||||
key = "kiro-cli"
|
key = "kiro-cli"
|
||||||
config = {
|
config = {
|
||||||
@@ -23,7 +15,7 @@ class KiroCliIntegration(MarkdownIntegration):
|
|||||||
registrar_config = {
|
registrar_config = {
|
||||||
"dir": ".kiro/prompts",
|
"dir": ".kiro/prompts",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"args": _KIRO_ARG_FALLBACK,
|
"args": "$ARGUMENTS",
|
||||||
"extension": ".md",
|
"extension": ".md",
|
||||||
}
|
}
|
||||||
context_file = "AGENTS.md"
|
context_file = "AGENTS.md"
|
||||||
|
|||||||
@@ -1845,20 +1845,20 @@ class PresetCatalog:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _make_request(self, url: str):
|
def _make_request(self, url: str):
|
||||||
"""Build a urllib Request, adding auth headers when a provider matches.
|
"""Build a urllib Request, adding a GitHub auth header when available.
|
||||||
|
|
||||||
Delegates to :func:`specify_cli.authentication.http.build_request`.
|
Delegates to :func:`specify_cli._github_http.build_github_request`.
|
||||||
"""
|
"""
|
||||||
from specify_cli.authentication.http import build_request
|
from specify_cli._github_http import build_github_request
|
||||||
return build_request(url)
|
return build_github_request(url)
|
||||||
|
|
||||||
def _open_url(self, url: str, timeout: int = 10):
|
def _open_url(self, url: str, timeout: int = 10):
|
||||||
"""Open a URL with provider-based auth, trying each configured provider.
|
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||||
|
|
||||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
Delegates to :func:`specify_cli._github_http.open_github_url`.
|
||||||
"""
|
"""
|
||||||
from specify_cli.authentication.http import open_url
|
from specify_cli._github_http import open_github_url
|
||||||
return open_url(url, timeout)
|
return open_github_url(url, timeout)
|
||||||
|
|
||||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
|
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
|
||||||
"""Load catalog stack configuration from a YAML file.
|
"""Load catalog stack configuration from a YAML file.
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ from .integrations.base import IntegrationBase
|
|||||||
from .integrations.manifest import IntegrationManifest
|
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(
|
def load_speckit_manifest(
|
||||||
project_path: Path,
|
project_path: Path,
|
||||||
*,
|
*,
|
||||||
@@ -98,7 +89,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
|
|||||||
current = current / part
|
current = current / part
|
||||||
label = _shared_destination_label(project_path, current)
|
label = _shared_destination_label(project_path, current)
|
||||||
if current.is_symlink():
|
if current.is_symlink():
|
||||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||||
if current.exists():
|
if current.exists():
|
||||||
if not current.is_dir():
|
if not current.is_dir():
|
||||||
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
|
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
|
||||||
@@ -111,7 +102,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
|
|||||||
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
|
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
|
||||||
current.mkdir()
|
current.mkdir()
|
||||||
if current.is_symlink():
|
if current.is_symlink():
|
||||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||||
try:
|
try:
|
||||||
current.resolve().relative_to(root)
|
current.resolve().relative_to(root)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -128,7 +119,7 @@ def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None
|
|||||||
current = current / part
|
current = current / part
|
||||||
label = _shared_destination_label(project_path, current)
|
label = _shared_destination_label(project_path, current)
|
||||||
if current.is_symlink():
|
if current.is_symlink():
|
||||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||||
if not current.exists():
|
if not current.exists():
|
||||||
continue
|
continue
|
||||||
if not current.is_dir():
|
if not current.is_dir():
|
||||||
@@ -154,7 +145,7 @@ def _ensure_safe_shared_destination(
|
|||||||
_validate_safe_shared_directory(project_path, dest.parent)
|
_validate_safe_shared_directory(project_path, dest.parent)
|
||||||
label = _shared_destination_label(project_path, dest)
|
label = _shared_destination_label(project_path, dest)
|
||||||
if dest.is_symlink():
|
if dest.is_symlink():
|
||||||
raise SymlinkedSharedPathError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
|
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
|
||||||
|
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
try:
|
try:
|
||||||
@@ -251,147 +242,58 @@ def install_shared_infra(
|
|||||||
console: Any,
|
console: Any,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
invoke_separator: str = ".",
|
invoke_separator: str = ".",
|
||||||
refresh_managed: bool = False,
|
|
||||||
refresh_hint: str | None = None,
|
|
||||||
) -> bool:
|
) -> 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)
|
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] = []
|
skipped_files: list[str] = []
|
||||||
preserved_user_files: list[str] = []
|
|
||||||
symlinked_files: list[str] = []
|
|
||||||
planned_copies: list[tuple[Path, str, bytes, int]] = []
|
planned_copies: list[tuple[Path, str, bytes, int]] = []
|
||||||
planned_templates: list[tuple[Path, str, str]] = []
|
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)
|
scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
|
||||||
if scripts_src.is_dir():
|
if scripts_src.is_dir():
|
||||||
dest_scripts = project_path / ".specify" / "scripts"
|
dest_scripts = project_path / ".specify" / "scripts"
|
||||||
if _ensure_or_bucket_dir(dest_scripts):
|
_ensure_safe_shared_directory(project_path, dest_scripts)
|
||||||
variant_dir = "bash" if script_type == "sh" else "powershell"
|
variant_dir = "bash" if script_type == "sh" else "powershell"
|
||||||
variant_src = scripts_src / variant_dir
|
variant_src = scripts_src / variant_dir
|
||||||
if variant_src.is_dir():
|
if variant_src.is_dir():
|
||||||
dest_variant = dest_scripts / variant_dir
|
dest_variant = dest_scripts / variant_dir
|
||||||
if _ensure_or_bucket_dir(dest_variant):
|
_ensure_safe_shared_directory(project_path, dest_variant)
|
||||||
for src_path in variant_src.rglob("*"):
|
for src_path in variant_src.rglob("*"):
|
||||||
if not src_path.is_file():
|
if not src_path.is_file():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rel_path = src_path.relative_to(variant_src)
|
rel_path = src_path.relative_to(variant_src)
|
||||||
dst_path = dest_variant / rel_path
|
dst_path = dest_variant / rel_path
|
||||||
rel = dst_path.relative_to(project_path).as_posix()
|
_ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False)
|
||||||
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
|
if dst_path.exists() and not force:
|
||||||
continue
|
skipped_files.append(dst_path.relative_to(project_path).as_posix())
|
||||||
write, bucket = _decide_overwrite(rel, dst_path)
|
continue
|
||||||
if not write:
|
|
||||||
if bucket == "preserved":
|
|
||||||
preserved_user_files.append(rel)
|
|
||||||
else:
|
|
||||||
skipped_files.append(rel)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
_ensure_safe_shared_directory(project_path, dst_path.parent)
|
||||||
continue
|
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))
|
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)
|
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
||||||
if templates_src.is_dir():
|
if templates_src.is_dir():
|
||||||
dest_templates = project_path / ".specify" / "templates"
|
dest_templates = project_path / ".specify" / "templates"
|
||||||
if _ensure_or_bucket_dir(dest_templates):
|
_ensure_safe_shared_directory(project_path, dest_templates)
|
||||||
for src in templates_src.iterdir():
|
for src in templates_src.iterdir():
|
||||||
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
|
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dst = dest_templates / src.name
|
dst = dest_templates / src.name
|
||||||
rel = dst.relative_to(project_path).as_posix()
|
_ensure_safe_shared_destination(project_path, dst)
|
||||||
if not _safe_dest_or_bucket(dst, rel):
|
if dst.exists() and not force:
|
||||||
continue
|
skipped_files.append(dst.relative_to(project_path).as_posix())
|
||||||
write, bucket = _decide_overwrite(rel, dst)
|
continue
|
||||||
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 = src.read_text(encoding="utf-8")
|
||||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||||
planned_templates.append((dst, rel, content))
|
rel = dst.relative_to(project_path).as_posix()
|
||||||
|
planned_templates.append((dst, rel, content))
|
||||||
|
|
||||||
for dst_path, rel, content, mode in planned_copies:
|
for dst_path, rel, content, mode in planned_copies:
|
||||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
_ensure_safe_shared_directory(project_path, dst_path.parent)
|
||||||
continue
|
|
||||||
_write_shared_bytes(project_path, dst_path, content, mode=mode)
|
_write_shared_bytes(project_path, dst_path, content, mode=mode)
|
||||||
manifest.record_existing(rel)
|
manifest.record_existing(rel)
|
||||||
|
|
||||||
@@ -405,37 +307,11 @@ def install_shared_infra(
|
|||||||
)
|
)
|
||||||
for path in skipped_files:
|
for path in skipped_files:
|
||||||
console.print(f" {path}")
|
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(
|
console.print(
|
||||||
f"[yellow]⚠[/yellow] Skipped {len(symlinked_files)} symlinked shared "
|
"To refresh shared infrastructure, run "
|
||||||
"infrastructure path(s) — symlinks are never overwritten because they "
|
"[cyan]specify init --here --force[/cyan] or "
|
||||||
"may resolve outside the project root:"
|
"[cyan]specify integration upgrade --force[/cyan]."
|
||||||
)
|
)
|
||||||
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()
|
manifest.save()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ class WorkflowCatalog:
|
|||||||
|
|
||||||
# Fetch from URL — validate scheme before opening and after redirects
|
# Fetch from URL — validate scheme before opening and after redirects
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from specify_cli.authentication.http import open_url as _open_url
|
from urllib.request import urlopen
|
||||||
|
|
||||||
def _validate_catalog_url(url: str) -> None:
|
def _validate_catalog_url(url: str) -> None:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -337,7 +337,7 @@ class WorkflowCatalog:
|
|||||||
_validate_catalog_url(entry.url)
|
_validate_catalog_url(entry.url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with _open_url(entry.url, timeout=30) as resp:
|
with urlopen(entry.url, timeout=30) as resp: # noqa: S310
|
||||||
_validate_catalog_url(resp.geturl())
|
_validate_catalog_url(resp.geturl())
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Implementation Plan: [FEATURE]
|
# Implementation Plan: [FEATURE]
|
||||||
|
|
||||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||||
|
|
||||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||||
|
|
||||||
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||||
@@ -18,22 +17,14 @@
|
|||||||
the iteration process.
|
the iteration process.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||||
|
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
||||||
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||||
|
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
|
||||||
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
|
||||||
|
|
||||||
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
|
|
||||||
|
|
||||||
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
||||||
|
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
||||||
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
||||||
|
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||||
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
|
||||||
|
|
||||||
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
|
||||||
|
|
||||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||||
|
|
||||||
## Constitution Check
|
## Constitution Check
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
# Feature Specification: [FEATURE NAME]
|
# Feature Specification: [FEATURE NAME]
|
||||||
|
|
||||||
**Feature Branch**: `[###-feature-name]`
|
**Feature Branch**: `[###-feature-name]`
|
||||||
|
**Created**: [DATE]
|
||||||
**Created**: [DATE]
|
**Status**: Draft
|
||||||
|
|
||||||
**Status**: Draft
|
|
||||||
|
|
||||||
**Input**: User description: "$ARGUMENTS"
|
**Input**: User description: "$ARGUMENTS"
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
@@ -14,7 +11,7 @@
|
|||||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||||
|
|
||||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||||
Think of each story as a standalone slice of functionality that can be:
|
Think of each story as a standalone slice of functionality that can be:
|
||||||
- Developed independently
|
- Developed independently
|
||||||
@@ -88,7 +85,7 @@
|
|||||||
### Functional Requirements
|
### Functional Requirements
|
||||||
|
|
||||||
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||||
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||||
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||||
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ description: "Task list template for feature implementation"
|
|||||||
# Tasks: [FEATURE NAME]
|
# Tasks: [FEATURE NAME]
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||||
|
|
||||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||||
@@ -26,21 +25,21 @@ description: "Task list template for feature implementation"
|
|||||||
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||||
- Paths shown below assume single project - adjust based on plan.md structure
|
- Paths shown below assume single project - adjust based on plan.md structure
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
============================================================================
|
============================================================================
|
||||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||||
|
|
||||||
The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
|
The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
|
||||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||||
- Feature requirements from plan.md
|
- Feature requirements from plan.md
|
||||||
- Entities from data-model.md
|
- Entities from data-model.md
|
||||||
- Endpoints from contracts/
|
- Endpoints from contracts/
|
||||||
|
|
||||||
Tasks MUST be organized by user story so each story can be:
|
Tasks MUST be organized by user story so each story can be:
|
||||||
- Implemented independently
|
- Implemented independently
|
||||||
- Tested independently
|
- Tested independently
|
||||||
- Delivered as an MVP increment
|
- Delivered as an MVP increment
|
||||||
|
|
||||||
DO NOT keep these sample tasks in the generated tasks.md file.
|
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||||
============================================================================
|
============================================================================
|
||||||
-->
|
-->
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"""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,18 +66,3 @@ requires_bash = pytest.mark.skipif(
|
|||||||
def strip_ansi(text: str) -> str:
|
def strip_ansi(text: str) -> str:
|
||||||
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
||||||
return _ANSI_ESCAPE_RE.sub("", text)
|
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
|
assert "A new shared manifest will be created" in captured.out
|
||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||||
def test_shared_infra_buckets_symlinked_script_destination(self, tmp_path, capsys):
|
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
|
||||||
"""Symlinked script destinations are bucketed with a warning; the symlink target is preserved."""
|
"""Shared script refreshes must not follow destination symlinks."""
|
||||||
from specify_cli import _install_shared_infra
|
from specify_cli import _install_shared_infra
|
||||||
|
|
||||||
project = tmp_path / "symlink-script-test"
|
project = tmp_path / "symlink-script-test"
|
||||||
@@ -334,15 +334,14 @@ class TestInitIntegrationFlag:
|
|||||||
scripts_dir.mkdir(parents=True)
|
scripts_dir.mkdir(parents=True)
|
||||||
os.symlink(outside, scripts_dir / "common.sh")
|
os.symlink(outside, scripts_dir / "common.sh")
|
||||||
|
|
||||||
_install_shared_infra(project, "sh", force=True)
|
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||||
|
_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"
|
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||||
def test_shared_infra_buckets_symlinked_template_destination(self, tmp_path, capsys):
|
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
|
||||||
"""Symlinked template destinations are bucketed with a warning; the symlink target is preserved."""
|
"""Shared template installs must not follow destination symlinks."""
|
||||||
from specify_cli import _install_shared_infra
|
from specify_cli import _install_shared_infra
|
||||||
|
|
||||||
project = tmp_path / "symlink-template-test"
|
project = tmp_path / "symlink-template-test"
|
||||||
@@ -355,10 +354,9 @@ class TestInitIntegrationFlag:
|
|||||||
templates_dir.mkdir(parents=True)
|
templates_dir.mkdir(parents=True)
|
||||||
os.symlink(outside, templates_dir / "plan-template.md")
|
os.symlink(outside, templates_dir / "plan-template.md")
|
||||||
|
|
||||||
_install_shared_infra(project, "sh", force=True)
|
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||||
|
_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"
|
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||||
@@ -383,7 +381,7 @@ class TestInitIntegrationFlag:
|
|||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||||
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
|
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
|
||||||
"""Shared infra installs must not follow a symlinked .specify directory."""
|
"""Shared infra directory creation must not follow a symlinked .specify."""
|
||||||
from specify_cli import _install_shared_infra
|
from specify_cli import _install_shared_infra
|
||||||
|
|
||||||
project = tmp_path / "symlink-dir-test"
|
project = tmp_path / "symlink-dir-test"
|
||||||
@@ -392,10 +390,8 @@ class TestInitIntegrationFlag:
|
|||||||
outside.mkdir()
|
outside.mkdir()
|
||||||
os.symlink(outside, project / ".specify")
|
os.symlink(outside, project / ".specify")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="symlinked"):
|
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
|
||||||
_install_shared_infra(project, "sh", force=True)
|
_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 / "scripts").exists()
|
||||||
assert not (outside / "templates").exists()
|
assert not (outside / "templates").exists()
|
||||||
@@ -469,8 +465,8 @@ class TestInitIntegrationFlag:
|
|||||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||||
|
|
||||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||||
def test_shared_infra_install_buckets_unsafe_destinations_and_continues(self, tmp_path):
|
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
|
||||||
"""Symlinked destinations are bucketed with a warning; safe destinations in the same install still complete."""
|
"""Full shared infra installs validate destinations before writing any file."""
|
||||||
from specify_cli.shared_infra import install_shared_infra
|
from specify_cli.shared_infra import install_shared_infra
|
||||||
|
|
||||||
project = tmp_path / "preflight-install-test"
|
project = tmp_path / "preflight-install-test"
|
||||||
@@ -490,19 +486,19 @@ class TestInitIntegrationFlag:
|
|||||||
outside.write_text("# outside\n", encoding="utf-8")
|
outside.write_text("# outside\n", encoding="utf-8")
|
||||||
os.symlink(outside, scripts_dir / "z.sh")
|
os.symlink(outside, scripts_dir / "z.sh")
|
||||||
|
|
||||||
install_shared_infra(
|
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||||
project,
|
install_shared_infra(
|
||||||
"sh",
|
project,
|
||||||
version="test",
|
"sh",
|
||||||
core_pack=core_pack,
|
version="test",
|
||||||
repo_root=tmp_path / "unused",
|
core_pack=core_pack,
|
||||||
console=_NoopConsole(),
|
repo_root=tmp_path / "unused",
|
||||||
force=True,
|
console=_NoopConsole(),
|
||||||
)
|
force=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Symlinked z.sh is preserved (bucketed); regular a.sh is overwritten.
|
assert existing.read_text(encoding="utf-8") == "# old a\n"
|
||||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
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):
|
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
|
||||||
"""Nested script source files create safe destination parents at write time."""
|
"""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."""
|
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||||
|
|
||||||
def _patch_urlopen(self, monkeypatch, catalog_data):
|
def _patch_urlopen(self, monkeypatch, catalog_data):
|
||||||
"""Patch authentication.http.urllib.request.urlopen to return *catalog_data*."""
|
"""Patch urllib.request.urlopen to return *catalog_data*."""
|
||||||
|
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
def __init__(self, data, url=""):
|
def __init__(self, data, url=""):
|
||||||
self._data = json.dumps(data).encode()
|
self._data = json.dumps(data).encode()
|
||||||
self._url = url if isinstance(url, str) else url.full_url
|
self._url = url
|
||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
return self._data
|
return self._data
|
||||||
@@ -185,12 +185,11 @@ class TestCatalogFetch:
|
|||||||
def __exit__(self, *a):
|
def __exit__(self, *a):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def fake_urlopen(req, timeout=10):
|
def fake_urlopen(url, timeout=10):
|
||||||
url = req if isinstance(req, str) else req.full_url
|
|
||||||
return FakeResponse(catalog_data, url)
|
return FakeResponse(catalog_data, url)
|
||||||
|
|
||||||
import specify_cli.authentication.http as _auth_http
|
import urllib.request
|
||||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||||
|
|
||||||
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
|
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
@@ -487,12 +486,12 @@ class TestIntegrationListCatalog:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
import specify_cli.authentication.http as _auth_http
|
import urllib.request
|
||||||
|
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
def __init__(self, data, url=""):
|
def __init__(self, data, url=""):
|
||||||
self._data = json.dumps(data).encode()
|
self._data = json.dumps(data).encode()
|
||||||
self._url = url if isinstance(url, str) else url.full_url
|
self._url = url
|
||||||
def read(self):
|
def read(self):
|
||||||
return self._data
|
return self._data
|
||||||
def geturl(self):
|
def geturl(self):
|
||||||
@@ -502,8 +501,7 @@ class TestIntegrationListCatalog:
|
|||||||
def __exit__(self, *a):
|
def __exit__(self, *a):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen",
|
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
|
||||||
lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url))
|
|
||||||
|
|
||||||
old = os.getcwd()
|
old = os.getcwd()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,41 +1,10 @@
|
|||||||
"""Tests for KiroCliIntegration."""
|
"""Tests for KiroCliIntegration."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
|
|
||||||
from specify_cli.integrations import get_integration
|
|
||||||
from specify_cli.integrations.kiro_cli import _KIRO_ARG_FALLBACK
|
|
||||||
from specify_cli.integrations.manifest import IntegrationManifest
|
|
||||||
|
|
||||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||||
|
|
||||||
|
|
||||||
# Regex shapes that indicate a value is a placeholder token, not prose.
|
|
||||||
# Covers Bash ($VAR, ${VAR}, ${VAR:-default}), Mustache/Handlebars/Jinja
|
|
||||||
# ({{var}}, {{{var}}}), Liquid/Jinja control ({% ... %}), Python str.format /
|
|
||||||
# .NET ({var}, {0}), angle-bracket (<var>), and Windows-style (%VAR%).
|
|
||||||
# Anchored to the FULL STRING so legitimate prose mentioning a placeholder
|
|
||||||
# (e.g. "the {{magic}} of placeholders") is not flagged. The Liquid pattern
|
|
||||||
# is anchored to the START so multi-tag templates fire while mid-sentence
|
|
||||||
# {%-quotation does not.
|
|
||||||
_PLACEHOLDER_TOKEN_PATTERNS = (
|
|
||||||
re.compile(r"^\$\w+$"), # $ARGUMENTS, $args
|
|
||||||
re.compile(r"^\$\{\w+(?:[:\-+?][^}]*)?\}$"), # ${ARGS}, ${ARGS:-default}
|
|
||||||
re.compile(r"^\{\{\{?\s*\w+(\s*[|.][^}]*)?\s*\}?\}\}$"), # {{var}} {{{var}}} {{x|y}}
|
|
||||||
re.compile(r"^\{%"), # {% if x %}{{ x }}{% endif %}
|
|
||||||
re.compile(r"^<\w+>$"), # <args>
|
|
||||||
re.compile(r"^%\w+%$"), # %USERNAME%
|
|
||||||
re.compile(r"^\{(?:\d+|[a-zA-Z_]\w*)(?:[.\[][^}]*)?(?:![rsa])?(?::[^}]*)?\}$"), # {0}, {var}, {0:>5}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_placeholder_token(value: str) -> bool:
|
|
||||||
"""Return True if *value* matches a known placeholder-token shape."""
|
|
||||||
if not value:
|
|
||||||
return False
|
|
||||||
return any(p.search(value) for p in _PLACEHOLDER_TOKEN_PATTERNS)
|
|
||||||
|
|
||||||
|
|
||||||
class TestKiroCliIntegration(MarkdownIntegrationTests):
|
class TestKiroCliIntegration(MarkdownIntegrationTests):
|
||||||
KEY = "kiro-cli"
|
KEY = "kiro-cli"
|
||||||
FOLDER = ".kiro/"
|
FOLDER = ".kiro/"
|
||||||
@@ -43,85 +12,6 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
|
|||||||
REGISTRAR_DIR = ".kiro/prompts"
|
REGISTRAR_DIR = ".kiro/prompts"
|
||||||
CONTEXT_FILE = "AGENTS.md"
|
CONTEXT_FILE = "AGENTS.md"
|
||||||
|
|
||||||
def test_registrar_config(self):
|
|
||||||
"""Override base assertion: kiro-cli uses a prose fallback for args
|
|
||||||
because Kiro CLI file-based prompts do not natively substitute
|
|
||||||
``$ARGUMENTS`` (see issue #1926 / kirodotdev/Kiro#4141). The
|
|
||||||
regression-guard load is carried by the two layer tests below
|
|
||||||
(exact-fallback + placeholder-shape rejection)."""
|
|
||||||
i = get_integration(self.KEY)
|
|
||||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
|
||||||
assert i.registrar_config["format"] == "markdown"
|
|
||||||
assert i.registrar_config["extension"] == ".md"
|
|
||||||
|
|
||||||
def test_registrar_config_args_is_exact_prose_fallback(self):
|
|
||||||
"""Layer 1 — pin the exact fallback so wording drift requires a
|
|
||||||
deliberate paired commit (production constant + test update)."""
|
|
||||||
i = get_integration(self.KEY)
|
|
||||||
assert i.registrar_config["args"] == _KIRO_ARG_FALLBACK, (
|
|
||||||
f"args drifted from the pinned fallback constant. "
|
|
||||||
f"Got: {i.registrar_config['args']!r}; expected: {_KIRO_ARG_FALLBACK!r}. "
|
|
||||||
f"If the wording change is intentional, update _KIRO_ARG_FALLBACK and "
|
|
||||||
f"this test together."
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_registrar_config_args_does_not_look_like_a_placeholder_token(self):
|
|
||||||
"""Layer 2 — independent regression guard: even if someone bypasses
|
|
||||||
layer-1 by changing both constant and test, the value still must not
|
|
||||||
look like ANY placeholder token shape ($X, ${X}, {{X}}, <X>, %X%, {0},
|
|
||||||
{% %}). Catches the class of regression Copilot called out: a swap
|
|
||||||
from $ARGUMENTS to $INPUT or {{userMessage}} would fail this test
|
|
||||||
even if it accidentally passed layer 1."""
|
|
||||||
i = get_integration(self.KEY)
|
|
||||||
args = i.registrar_config["args"]
|
|
||||||
assert not _looks_like_placeholder_token(args), (
|
|
||||||
f"registrar_config['args'] = {args!r} matches a known placeholder-"
|
|
||||||
f"token shape — Kiro CLI does not substitute placeholders so this "
|
|
||||||
f"would reach the model verbatim and break the prompt (issue #1926). "
|
|
||||||
f"Use a prose fallback instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rendered_prompts_do_not_contain_raw_arguments(self, tmp_path):
|
|
||||||
"""Rendered Kiro prompt files must NOT contain the raw ``$ARGUMENTS``
|
|
||||||
token — Kiro CLI does not substitute it, so the literal would reach
|
|
||||||
the model and break the prompt (issue #1926)."""
|
|
||||||
integration = get_integration(self.KEY)
|
|
||||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
|
||||||
integration.setup(tmp_path, manifest, script_type="sh")
|
|
||||||
|
|
||||||
prompts_dir = tmp_path / self.REGISTRAR_DIR
|
|
||||||
rendered = list(prompts_dir.glob("*.md"))
|
|
||||||
assert rendered, "expected at least one rendered prompt file"
|
|
||||||
|
|
||||||
offenders = [
|
|
||||||
p.name for p in rendered if "$ARGUMENTS" in p.read_text(encoding="utf-8")
|
|
||||||
]
|
|
||||||
assert offenders == [], (
|
|
||||||
f"these rendered prompts still contain the raw $ARGUMENTS token: {offenders}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rendered_prompts_contain_kiro_arg_placeholder(self, tmp_path):
|
|
||||||
"""The chosen kiro-cli args fallback string must end up in at least
|
|
||||||
one rendered prompt (proves substitution actually fired, not just
|
|
||||||
that $ARGUMENTS was removed). Imports the fallback constant directly
|
|
||||||
instead of reading the field back so the test stays independent of
|
|
||||||
the integration's own config — even if the registrar_config['args']
|
|
||||||
regresses, this test still verifies the FALLBACK STRING is in the
|
|
||||||
rendered output."""
|
|
||||||
integration = get_integration(self.KEY)
|
|
||||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
|
||||||
integration.setup(tmp_path, manifest, script_type="sh")
|
|
||||||
|
|
||||||
expected = _KIRO_ARG_FALLBACK
|
|
||||||
prompts_dir = tmp_path / self.REGISTRAR_DIR
|
|
||||||
contents = "\n".join(
|
|
||||||
p.read_text(encoding="utf-8") for p in prompts_dir.glob("*.md")
|
|
||||||
)
|
|
||||||
assert expected in contents, (
|
|
||||||
f"none of the rendered prompts contain the configured args fallback "
|
|
||||||
f"({expected!r})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestKiroAlias:
|
class TestKiroAlias:
|
||||||
"""--ai kiro alias normalizes to kiro-cli and auto-promotes."""
|
"""--ai kiro alias normalizes to kiro-cli and auto-promotes."""
|
||||||
|
|||||||
@@ -901,152 +901,6 @@ class TestIntegrationSwitch:
|
|||||||
assert shared_script.exists()
|
assert shared_script.exists()
|
||||||
assert shared_script.read_text(encoding="utf-8") == shared_content
|
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):
|
def test_switch_from_nothing(self, tmp_path):
|
||||||
"""Switch when no integration is installed should just install the target."""
|
"""Switch when no integration is installed should just install the target."""
|
||||||
project = tmp_path / "bare"
|
project = tmp_path / "bare"
|
||||||
|
|||||||
@@ -1,860 +0,0 @@
|
|||||||
"""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,10 +2453,6 @@ class TestExtensionCatalog:
|
|||||||
(project_dir / ".specify").mkdir()
|
(project_dir / ".specify").mkdir()
|
||||||
return ExtensionCatalog(project_dir)
|
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):
|
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
|
||||||
"""Without a token, requests carry no Authorization header."""
|
"""Without a token, requests carry no Authorization header."""
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
@@ -2477,7 +2473,6 @@ class TestExtensionCatalog:
|
|||||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||||
@@ -2486,7 +2481,6 @@ class TestExtensionCatalog:
|
|||||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
@@ -2495,40 +2489,49 @@ class TestExtensionCatalog:
|
|||||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||||
|
|
||||||
def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch):
|
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
|
||||||
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||||
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||||
|
|
||||||
def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch):
|
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
|
||||||
"""Auth is NOT attached to hosts not listed in auth.json."""
|
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||||
assert "Authorization" not in req.headers
|
assert "Authorization" not in req.headers
|
||||||
|
|
||||||
def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch):
|
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
|
||||||
"""No auth header when no auth.json config exists."""
|
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
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")
|
||||||
assert "Authorization" not in req.headers
|
assert "Authorization" not in req.headers
|
||||||
|
|
||||||
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
|
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
|
||||||
"""GITHUB_TOKEN is attached for api.github.com URLs."""
|
"""GITHUB_TOKEN is attached for api.github.com URLs."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
|
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
@@ -2536,17 +2539,49 @@ class TestExtensionCatalog:
|
|||||||
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
|
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)."""
|
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
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):
|
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
|
||||||
"""_fetch_single_catalog passes Authorization header when a provider is configured."""
|
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
|
|
||||||
catalog_data = {"schema_version": "1.0", "extensions": {}}
|
catalog_data = {"schema_version": "1.0", "extensions": {}}
|
||||||
@@ -2554,7 +2589,6 @@ class TestExtensionCatalog:
|
|||||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||||
mock_response.__enter__ = lambda s: s
|
mock_response.__enter__ = lambda s: s
|
||||||
mock_response.__exit__ = MagicMock(return_value=False)
|
mock_response.__exit__ = MagicMock(return_value=False)
|
||||||
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json"
|
|
||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
mock_opener = MagicMock()
|
mock_opener = MagicMock()
|
||||||
@@ -2572,18 +2606,17 @@ class TestExtensionCatalog:
|
|||||||
install_allowed=True,
|
install_allowed=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||||
|
|
||||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
|
|
||||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||||
"""download_extension passes Authorization header when a provider is configured."""
|
"""download_extension passes Authorization header via opener for GitHub URLs."""
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
import zipfile, io
|
import zipfile, io
|
||||||
|
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = self._make_catalog(temp_dir)
|
catalog = self._make_catalog(temp_dir)
|
||||||
|
|
||||||
# Build a minimal valid ZIP in memory
|
# Build a minimal valid ZIP in memory
|
||||||
@@ -2598,6 +2631,7 @@ class TestExtensionCatalog:
|
|||||||
mock_response.__exit__ = MagicMock(return_value=False)
|
mock_response.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
mock_opener = MagicMock()
|
mock_opener = MagicMock()
|
||||||
|
|
||||||
def fake_open(req, timeout=None):
|
def fake_open(req, timeout=None):
|
||||||
@@ -2614,7 +2648,7 @@ class TestExtensionCatalog:
|
|||||||
}
|
}
|
||||||
|
|
||||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||||
|
|
||||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
|
|||||||
@@ -1224,10 +1224,6 @@ class TestExtensionPriorityResolution:
|
|||||||
class TestPresetCatalog:
|
class TestPresetCatalog:
|
||||||
"""Test template catalog functionality."""
|
"""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):
|
def test_default_catalog_url(self, project_dir):
|
||||||
"""Test default catalog URL."""
|
"""Test default catalog URL."""
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
@@ -1422,7 +1418,6 @@ class TestPresetCatalog:
|
|||||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||||
@@ -1431,7 +1426,6 @@ class TestPresetCatalog:
|
|||||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
@@ -1440,50 +1434,58 @@ class TestPresetCatalog:
|
|||||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||||
|
|
||||||
def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch):
|
def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch):
|
||||||
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||||
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||||
|
|
||||||
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
|
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
|
||||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs."""
|
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
|
|
||||||
def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch):
|
def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch):
|
||||||
"""Auth is NOT attached to hosts not listed in auth.json."""
|
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||||
assert "Authorization" not in req.headers
|
assert "Authorization" not in req.headers
|
||||||
|
|
||||||
def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch):
|
def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch):
|
||||||
"""No auth header when no auth.json config exists."""
|
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
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")
|
||||||
assert "Authorization" not in req.headers
|
assert "Authorization" not in req.headers
|
||||||
|
|
||||||
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
|
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
|
||||||
"""_fetch_single_catalog passes Authorization header when configured."""
|
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
|
|
||||||
catalog_data = {"schema_version": "1.0", "presets": {}}
|
catalog_data = {"schema_version": "1.0", "presets": {}}
|
||||||
@@ -1491,7 +1493,6 @@ class TestPresetCatalog:
|
|||||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||||
mock_response.__enter__ = lambda s: s
|
mock_response.__enter__ = lambda s: s
|
||||||
mock_response.__exit__ = MagicMock(return_value=False)
|
mock_response.__exit__ = MagicMock(return_value=False)
|
||||||
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json"
|
|
||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
mock_opener = MagicMock()
|
mock_opener = MagicMock()
|
||||||
@@ -1509,17 +1510,16 @@ class TestPresetCatalog:
|
|||||||
install_allowed=True,
|
install_allowed=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||||
|
|
||||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
|
|
||||||
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
|
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
|
||||||
"""download_pack passes Authorization header when configured."""
|
"""download_pack passes Authorization header via opener for GitHub URLs."""
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
catalog = PresetCatalog(project_dir)
|
catalog = PresetCatalog(project_dir)
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -1551,7 +1551,7 @@ class TestPresetCatalog:
|
|||||||
}
|
}
|
||||||
|
|
||||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||||
catalog.download_pack("test-pack", target_dir=project_dir)
|
catalog.download_pack("test-pack", target_dir=project_dir)
|
||||||
|
|
||||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
@@ -1949,16 +1949,7 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1
|
|||||||
|
|
||||||
|
|
||||||
class TestSelfTestPreset:
|
class TestSelfTestPreset:
|
||||||
"""Tests using the self-test preset that ships with the repo.
|
"""Tests using the self-test preset that ships with the repo."""
|
||||||
|
|
||||||
The self-test preset ships a wrap-strategy command (``speckit.wrap-test``)
|
|
||||||
without a corresponding core base layer; reconciliation deliberately
|
|
||||||
surfaces a UserWarning in that case. Tests install via
|
|
||||||
``install_self_test_preset`` (defined above), which scopes a narrow
|
|
||||||
``warnings.filterwarnings`` block to that specific message and
|
|
||||||
``UserWarning`` category — so the expected warning stays quiet without
|
|
||||||
masking unrelated warnings or real reconciliation failures.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_self_test_preset_exists(self):
|
def test_self_test_preset_exists(self):
|
||||||
"""Verify the self-test preset directory and manifest exist."""
|
"""Verify the self-test preset directory and manifest exist."""
|
||||||
@@ -2246,12 +2237,7 @@ class TestInitOptions:
|
|||||||
|
|
||||||
|
|
||||||
class TestPresetSkills:
|
class TestPresetSkills:
|
||||||
"""Tests for preset skill registration and unregistration.
|
"""Tests for preset skill registration and unregistration."""
|
||||||
|
|
||||||
Tests that install the self-test preset use ``install_self_test_preset``
|
|
||||||
which scopes a narrow filter to the expected wrap-strategy warning.
|
|
||||||
Reconciliation failures remain audible so real regressions surface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
|
def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
|
||||||
from specify_cli import save_init_options
|
from specify_cli import save_init_options
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from specify_cli import (
|
|||||||
_normalize_tag,
|
_normalize_tag,
|
||||||
app,
|
app,
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.conftest import strip_ansi
|
from tests.conftest import strip_ansi
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
@@ -30,10 +31,6 @@ runner = CliRunner()
|
|||||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-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:
|
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
@@ -69,20 +66,11 @@ class TestSelfUpgradeStub:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def test_stub_makes_no_network_call(self):
|
def test_stub_makes_no_network_call(self):
|
||||||
# The stub must not hit the network via either urllib path:
|
# If the stub ever starts calling urllib, this patch's side_effect
|
||||||
# unauthenticated requests use urlopen() directly; authenticated ones
|
# would fire and the assertion below would fail.
|
||||||
# go through build_opener(...).open(). Both are patched so that any
|
with patch(
|
||||||
# accidental network call raises immediately.
|
"specify_cli.urllib.request.urlopen",
|
||||||
network_error = AssertionError("stub must not hit the network")
|
side_effect=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"])
|
result = runner.invoke(app, ["self", "upgrade"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -150,7 +138,7 @@ class TestNormalizeTag:
|
|||||||
class TestUserStory1:
|
class TestUserStory1:
|
||||||
def test_newer_available_prints_update_and_install_command(self):
|
def test_newer_available_prints_update_and_install_command(self):
|
||||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
@@ -163,7 +151,7 @@ class TestUserStory1:
|
|||||||
|
|
||||||
def test_up_to_date_prints_current_only(self):
|
def test_up_to_date_prints_current_only(self):
|
||||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
@@ -175,7 +163,7 @@ class TestUserStory1:
|
|||||||
|
|
||||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
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(
|
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
@@ -186,7 +174,7 @@ class TestUserStory1:
|
|||||||
|
|
||||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
@@ -198,7 +186,7 @@ class TestUserStory1:
|
|||||||
|
|
||||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
@@ -212,7 +200,7 @@ class TestUserStory1:
|
|||||||
class TestFailureCategorization:
|
class TestFailureCategorization:
|
||||||
def test_urlerror_maps_to_offline(self):
|
def test_urlerror_maps_to_offline(self):
|
||||||
with patch(
|
with patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
side_effect=urllib.error.URLError("no route to host"),
|
side_effect=urllib.error.URLError("no route to host"),
|
||||||
):
|
):
|
||||||
tag, reason = _fetch_latest_release_tag()
|
tag, reason = _fetch_latest_release_tag()
|
||||||
@@ -221,7 +209,7 @@ class TestFailureCategorization:
|
|||||||
|
|
||||||
def test_timeout_maps_to_offline(self):
|
def test_timeout_maps_to_offline(self):
|
||||||
with patch(
|
with patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
side_effect=TimeoutError(),
|
side_effect=TimeoutError(),
|
||||||
):
|
):
|
||||||
tag, reason = _fetch_latest_release_tag()
|
tag, reason = _fetch_latest_release_tag()
|
||||||
@@ -230,17 +218,17 @@ class TestFailureCategorization:
|
|||||||
|
|
||||||
def test_403_maps_to_rate_limited(self):
|
def test_403_maps_to_rate_limited(self):
|
||||||
with patch(
|
with patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
side_effect=_http_error(403, "rate limited"),
|
side_effect=_http_error(403, "rate limited"),
|
||||||
):
|
):
|
||||||
tag, reason = _fetch_latest_release_tag()
|
tag, reason = _fetch_latest_release_tag()
|
||||||
assert tag is None
|
assert tag is None
|
||||||
assert reason == _RATE_LIMITED_REASON
|
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||||
|
|
||||||
@pytest.mark.parametrize("code", [404, 500, 502])
|
@pytest.mark.parametrize("code", [404, 500, 502])
|
||||||
def test_other_http_uses_code_string(self, code):
|
def test_other_http_uses_code_string(self, code):
|
||||||
with patch(
|
with patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
side_effect=_http_error(code, "oops"),
|
side_effect=_http_error(code, "oops"),
|
||||||
):
|
):
|
||||||
tag, reason = _fetch_latest_release_tag()
|
tag, reason = _fetch_latest_release_tag()
|
||||||
@@ -250,7 +238,7 @@ class TestFailureCategorization:
|
|||||||
def test_generic_exception_propagates(self):
|
def test_generic_exception_propagates(self):
|
||||||
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
|
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
|
||||||
with patch(
|
with patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
"specify_cli.urllib.request.urlopen",
|
||||||
side_effect=RuntimeError("boom"),
|
side_effect=RuntimeError("boom"),
|
||||||
):
|
):
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
@@ -259,7 +247,7 @@ class TestFailureCategorization:
|
|||||||
|
|
||||||
_FAILURE_CASES = [
|
_FAILURE_CASES = [
|
||||||
("offline or timeout", urllib.error.URLError("down")),
|
("offline or timeout", urllib.error.URLError("down")),
|
||||||
(_RATE_LIMITED_REASON, _http_error(403)),
|
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
|
||||||
("HTTP 500", _http_error(500)),
|
("HTTP 500", _http_error(500)),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -270,21 +258,22 @@ class TestUserStory2:
|
|||||||
self, expected_reason, side_effect
|
self, expected_reason, side_effect
|
||||||
):
|
):
|
||||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
output = strip_ansi(result.output)
|
output = strip_ansi(result.output)
|
||||||
assert "Installed: 0.7.4" in output
|
assert "Installed: 0.7.4" in output
|
||||||
if expected_reason == _RATE_LIMITED_REASON:
|
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
|
||||||
assert "Could not check latest release: rate limited" in output
|
assert "Could not check latest release: rate limited" in output
|
||||||
assert "~/.specify/auth.json" in output
|
assert "GH_TOKEN" in output
|
||||||
|
assert "GITHUB_TOKEN" in output
|
||||||
else:
|
else:
|
||||||
assert f"Could not check latest release: {expected_reason}" in output
|
assert f"Could not check latest release: {expected_reason}" in output
|
||||||
|
|
||||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -294,7 +283,7 @@ class TestUserStory2:
|
|||||||
self, _expected_reason, side_effect
|
self, _expected_reason, side_effect
|
||||||
):
|
):
|
||||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
combined = (result.output or "") + (result.stderr or "")
|
combined = (result.output or "") + (result.stderr or "")
|
||||||
@@ -313,20 +302,12 @@ def _capture_request_via_urlopen():
|
|||||||
return captured, _side_effect
|
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:
|
class TestUserStory3:
|
||||||
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
|
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
|
||||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
captured, side_effect = _capture_request_via_urlopen()
|
captured, side_effect = _capture_request_via_urlopen()
|
||||||
mock_opener = MagicMock()
|
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||||
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()
|
_fetch_latest_release_tag()
|
||||||
req = captured["request"]
|
req = captured["request"]
|
||||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||||
@@ -334,11 +315,8 @@ class TestUserStory3:
|
|||||||
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
|
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
|
||||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
captured, side_effect = _capture_request_via_urlopen()
|
captured, side_effect = _capture_request_via_urlopen()
|
||||||
mock_opener = MagicMock()
|
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||||
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()
|
_fetch_latest_release_tag()
|
||||||
req = captured["request"]
|
req = captured["request"]
|
||||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||||
@@ -347,7 +325,7 @@ class TestUserStory3:
|
|||||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
captured, side_effect = _capture_request_via_urlopen()
|
captured, side_effect = _capture_request_via_urlopen()
|
||||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||||
_fetch_latest_release_tag()
|
_fetch_latest_release_tag()
|
||||||
req = captured["request"]
|
req = captured["request"]
|
||||||
assert req.get_header("Authorization") is None
|
assert req.get_header("Authorization") is None
|
||||||
@@ -355,9 +333,8 @@ class TestUserStory3:
|
|||||||
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
|
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
|
||||||
monkeypatch.setenv("GH_TOKEN", "")
|
monkeypatch.setenv("GH_TOKEN", "")
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
captured, side_effect = _capture_request_via_urlopen()
|
captured, side_effect = _capture_request_via_urlopen()
|
||||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||||
_fetch_latest_release_tag()
|
_fetch_latest_release_tag()
|
||||||
req = captured["request"]
|
req = captured["request"]
|
||||||
assert req.get_header("Authorization") is None
|
assert req.get_header("Authorization") is None
|
||||||
@@ -365,9 +342,8 @@ class TestUserStory3:
|
|||||||
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
|
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
|
||||||
monkeypatch.setenv("GH_TOKEN", " ")
|
monkeypatch.setenv("GH_TOKEN", " ")
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
|
||||||
captured, side_effect = _capture_request_via_urlopen()
|
captured, side_effect = _capture_request_via_urlopen()
|
||||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||||
_fetch_latest_release_tag()
|
_fetch_latest_release_tag()
|
||||||
req = captured["request"]
|
req = captured["request"]
|
||||||
assert req.get_header("Authorization") is None
|
assert req.get_header("Authorization") is None
|
||||||
@@ -375,11 +351,8 @@ class TestUserStory3:
|
|||||||
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
|
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
|
||||||
monkeypatch.setenv("GH_TOKEN", " ")
|
monkeypatch.setenv("GH_TOKEN", " ")
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
|
||||||
captured, side_effect = _capture_request_via_urlopen()
|
captured, side_effect = _capture_request_via_urlopen()
|
||||||
mock_opener = MagicMock()
|
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||||
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()
|
_fetch_latest_release_tag()
|
||||||
req = captured["request"]
|
req = captured["request"]
|
||||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||||
@@ -391,7 +364,7 @@ class TestUserStory3:
|
|||||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||||
@@ -404,7 +377,7 @@ class TestUserStory3:
|
|||||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||||
):
|
):
|
||||||
result = runner.invoke(app, ["self", "check"])
|
result = runner.invoke(app, ["self", "check"])
|
||||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||||
|
|||||||
Reference in New Issue
Block a user