mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44de9235a8 | ||
|
|
efdff310a2 | ||
|
|
372b22a9bc | ||
|
|
765e60f1c4 | ||
|
|
92186124f3 | ||
|
|
20ef9a72a9 | ||
|
|
cba00ab9a5 | ||
|
|
a7f6800fcc | ||
|
|
cd951acb9e | ||
|
|
756d632129 | ||
|
|
0593565607 | ||
|
|
bf47e89249 | ||
|
|
81f772c60b | ||
|
|
e1b531c648 | ||
|
|
b5db159394 | ||
|
|
947b4398c7 | ||
|
|
28145b9a3a | ||
|
|
cec0d2db5e | ||
|
|
688ca1b3c5 | ||
|
|
2b4a33e1fd | ||
|
|
2be4ef713d | ||
|
|
282a1f7d1b | ||
|
|
b0674243d2 | ||
|
|
abb5fe7090 | ||
|
|
f0998348be | ||
|
|
5563269831 |
2
.github/workflows/catalog-assign.yml
vendored
2
.github/workflows/catalog-assign.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,14 +19,14 @@ jobs:
|
||||
language: [ 'actions', 'python' ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
with:
|
||||
dotnet-version: '8.x'
|
||||
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23
|
||||
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,6 +2,41 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -200,6 +200,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
@@ -207,6 +208,7 @@ 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -235,6 +237,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
|
||||
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
|
||||
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
|
||||
| MDE | Minimal model-driven engineering workflow with setup, next, and status commands | `process` | Read+Write | [spec-kit-mde](https://github.com/AI-MDE/spec-kit-mde) |
|
||||
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
|
||||
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
|
||||
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
@@ -263,10 +266,12 @@ 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) |
|
||||
| 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) |
|
||||
| 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 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 Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
|
||||
| Spec 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 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) |
|
||||
|
||||
@@ -16,8 +16,10 @@ 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
|
||||
46
docs/concepts/sdd.md
Normal file
46
docs/concepts/sdd.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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,7 +6,9 @@
|
||||
"*.md",
|
||||
"toc.yml",
|
||||
"community/*.md",
|
||||
"reference/*.md"
|
||||
"concepts/*.md",
|
||||
"reference/*.md",
|
||||
"install/*.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -49,7 +51,8 @@
|
||||
"fileMetadataFiles": [],
|
||||
"template": [
|
||||
"default",
|
||||
"modern"
|
||||
"modern",
|
||||
"template"
|
||||
],
|
||||
"postProcessors": [],
|
||||
"markdownEngineName": "markdig",
|
||||
@@ -67,6 +70,11 @@
|
||||
"repo": "https://github.com/github/spec-kit",
|
||||
"branch": "main"
|
||||
}
|
||||
},
|
||||
"fileMetadata": {
|
||||
"_layout": {
|
||||
"index.md": "landing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
169
docs/index.md
169
docs/index.md
@@ -1,67 +1,152 @@
|
||||
# Spec Kit
|
||||
<div class="landing-hero">
|
||||
|
||||
*Build high-quality software faster.*
|
||||
# GitHub Spec Kit
|
||||
|
||||
**An effort to allow organizations to focus on product scenarios rather than writing undifferentiated code with the help of Spec-Driven Development.**
|
||||
**Define what to build before building it — with any AI coding agent.**
|
||||
|
||||
## What is 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.
|
||||
|
||||
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="installation.md" class="btn btn-primary btn-lg">Install Spec Kit</a>
|
||||
<a href="quickstart.md" class="btn btn-outline-primary btn-lg">Quick Start</a>
|
||||
|
||||
## Getting Started
|
||||
</div>
|
||||
|
||||
- [Installation Guide](installation.md)
|
||||
- [Quick Start Guide](quickstart.md)
|
||||
- [Upgrade Guide](upgrade.md)
|
||||
- [Local Development](local-development.md)
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
<div class="pillar-grid">
|
||||
|
||||
Spec-Driven Development is a structured process that emphasizes:
|
||||
<div class="pillar-card">
|
||||
|
||||
- **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
|
||||
### Spec-driven by default
|
||||
|
||||
## Development Phases
|
||||
The core SDD process ships ready to use: **Spec → Plan → Tasks → Implement**.
|
||||
|
||||
| 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> |
|
||||
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.
|
||||
|
||||
## Experimental Goals
|
||||
<a href="quickstart.md" class="pillar-link">Walk through the workflow →</a>
|
||||
|
||||
Our research and experimentation focus on:
|
||||
</div>
|
||||
|
||||
### Technology Independence
|
||||
<div class="pillar-card">
|
||||
|
||||
- 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
|
||||
### Use any coding agent
|
||||
|
||||
### Enterprise Constraints
|
||||
<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.
|
||||
|
||||
- Demonstrate mission-critical application development
|
||||
- Incorporate organizational constraints (cloud providers, tech stacks, engineering practices)
|
||||
- Support enterprise design systems and compliance requirements
|
||||
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.
|
||||
|
||||
### User-Centric Development
|
||||
<a href="reference/integrations.md" class="pillar-link">See all integrations →</a>
|
||||
|
||||
- Build applications for different user cohorts and preferences
|
||||
- Support various development approaches (from vibe-coding to AI-native development)
|
||||
</div>
|
||||
|
||||
### Creative & Iterative Processes
|
||||
<div class="pillar-card">
|
||||
|
||||
- Validate the concept of parallel implementation exploration
|
||||
- Provide robust iterative feature development workflows
|
||||
- Extend processes to handle upgrades and modernization tasks
|
||||
### Make it your own
|
||||
|
||||
## Contributing
|
||||
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing — including entirely different SDD processes:
|
||||
|
||||
Please see our [Contributing Guide](https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md) for information on how to contribute to this project.
|
||||
- **AIDE** — 7-step AI-driven engineering lifecycle
|
||||
- **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
|
||||
|
||||
## Support
|
||||
Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
|
||||
|
||||
For support, please check our [Support Guide](https://github.com/github/spec-kit/blob/main/SUPPORT.md) or open an issue on GitHub.
|
||||
<a href="community/presets.md" class="pillar-link">Browse community presets →</a>
|
||||
|
||||
</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>
|
||||
|
||||
181
docs/reference/authentication.md
Normal file
181
docs/reference/authentication.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Authentication
|
||||
|
||||
Specify CLI uses **opt-in authentication** for HTTP requests to catalog
|
||||
sources, extension downloads, and release checks. No credentials are
|
||||
sent unless you explicitly configure them.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.specify/auth.json` to enable authentication:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Security:** Restrict the file to owner-only access:
|
||||
> ```bash
|
||||
> chmod 600 ~/.specify/auth.json
|
||||
> ```
|
||||
|
||||
Without this file, all HTTP requests are unauthenticated.
|
||||
|
||||
## Fields
|
||||
|
||||
Each entry in the `providers` array has the following fields:
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `hosts` | Yes | Array of hostnames this entry applies to. Supports exact hostnames, or a leading `*.` wildcard for subdomains only (for example, `*.visualstudio.com`). `*.visualstudio.com` matches `foo.visualstudio.com`, but not `visualstudio.com`. Other glob patterns such as `*github.com` or `gith?b.com` are not supported. |
|
||||
| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. |
|
||||
| `auth` | Yes | Auth scheme (see below). |
|
||||
| `token` | No | Token value (inline). Use `token_env` instead when possible. |
|
||||
| `token_env` | No | Environment variable name to read the token from. |
|
||||
|
||||
For `azure-ad` auth, additional fields are required:
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `tenant_id` | Yes | Azure AD tenant ID. |
|
||||
| `client_id` | Yes | Service principal client ID. |
|
||||
| `client_secret_env` | Yes | Environment variable containing the client secret. |
|
||||
|
||||
Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.
|
||||
|
||||
## Providers and auth schemes
|
||||
|
||||
### GitHub (`github`)
|
||||
|
||||
| Scheme | Header | Use for |
|
||||
|---|---|---|
|
||||
| `bearer` | `Authorization: Bearer <token>` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens |
|
||||
|
||||
**Example — PAT via environment variable:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN"
|
||||
}
|
||||
```
|
||||
|
||||
### Azure DevOps (`azure-devops`)
|
||||
|
||||
| Scheme | Header | Use for |
|
||||
|---|---|---|
|
||||
| `basic-pat` | `Authorization: Basic base64(:<PAT>)` | Personal Access Tokens |
|
||||
| `bearer` | `Authorization: Bearer <token>` | Pre-acquired OAuth / Azure AD tokens |
|
||||
| `azure-cli` | `Authorization: Bearer <token>` | Token acquired via `az account get-access-token` |
|
||||
| `azure-ad` | `Authorization: Bearer <token>` | Token acquired via OAuth2 client credentials flow |
|
||||
|
||||
**Example — PAT via environment variable:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "AZURE_DEVOPS_PAT"
|
||||
}
|
||||
```
|
||||
|
||||
**Example — Azure CLI (interactive login):**
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-cli"
|
||||
}
|
||||
```
|
||||
|
||||
Requires `az login` to have been run beforehand.
|
||||
|
||||
**Example — Azure AD service principal (CI/automation):**
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"client_secret_env": "AZURE_CLIENT_SECRET"
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple entries
|
||||
|
||||
You can configure multiple entries for different hosts or organizations:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN"
|
||||
},
|
||||
{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "AZURE_DEVOPS_PAT"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. For each outbound HTTP request, the URL hostname is matched against
|
||||
the `hosts` patterns in `auth.json`.
|
||||
2. If a match is found, the corresponding provider resolves the token
|
||||
and attaches the appropriate `Authorization` header.
|
||||
3. If the request receives a 401 or 403, the next matching entry is tried.
|
||||
4. After all matching entries are exhausted, an unauthenticated request
|
||||
is attempted as a final fallback.
|
||||
5. On redirects, the `Authorization` header is stripped if the redirect
|
||||
target leaves the entry's declared hosts — preventing credential
|
||||
leakage to CDNs or third-party services.
|
||||
|
||||
## Template
|
||||
|
||||
A reference `auth.json` with GitHub pre-configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"hosts": [
|
||||
"github.com",
|
||||
"api.github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"codeload.github.com"
|
||||
],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To use it:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.specify
|
||||
# Copy the JSON above into ~/.specify/auth.json
|
||||
chmod 600 ~/.specify/auth.json
|
||||
```
|
||||
@@ -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` | |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
||||
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration |
|
||||
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` |
|
||||
| [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` |
|
||||
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
|
||||
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
|
||||
| [opencode](https://opencode.ai/) | `opencode` | |
|
||||
@@ -65,6 +65,8 @@ 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.
|
||||
|
||||
**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
|
||||
|
||||
```bash
|
||||
|
||||
264
docs/template/public/main.css
vendored
Normal file
264
docs/template/public/main.css
vendored
Normal file
@@ -0,0 +1,264 @@
|
||||
/* 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,6 +30,12 @@
|
||||
- name: Workflows
|
||||
href: reference/workflows.md
|
||||
|
||||
# Concepts
|
||||
- name: Concepts
|
||||
items:
|
||||
- name: What is SDD?
|
||||
href: concepts/sdd.md
|
||||
|
||||
# Development workflows
|
||||
- name: Development
|
||||
items:
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
|
||||
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`
|
||||
|
||||
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
|
||||
@@ -35,6 +41,8 @@ Specify the desired release tag:
|
||||
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`
|
||||
|
||||
Upgrade to a specific release:
|
||||
@@ -49,7 +57,7 @@ pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
specify check
|
||||
```
|
||||
|
||||
This shows installed tools and confirms the CLI is working.
|
||||
This shows installed tools and confirms the CLI is working. Use `specify version` to confirm which persistent CLI version is currently on your `PATH`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-07T05:51:00Z",
|
||||
"updated_at": "2026-05-12T21:40:51Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -100,6 +100,43 @@
|
||||
"created_at": "2026-05-04T00:00:00Z",
|
||||
"updated_at": "2026-05-04T00:00:00Z"
|
||||
},
|
||||
"api-evolve": {
|
||||
"name": "API Evolve",
|
||||
"id": "api-evolve",
|
||||
"description": "Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-api-evolve",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-api-evolve",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 12,
|
||||
"hooks": 5
|
||||
},
|
||||
"tags": [
|
||||
"api",
|
||||
"contracts",
|
||||
"versioning",
|
||||
"openapi",
|
||||
"graphql",
|
||||
"grpc",
|
||||
"deprecation",
|
||||
"breaking-changes",
|
||||
"semver",
|
||||
"governance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"updated_at": "2026-05-07T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
@@ -137,8 +174,8 @@
|
||||
"id": "architecture-guard",
|
||||
"description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.6.7",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.6.7.zip",
|
||||
"version": "1.8.4",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.4.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
|
||||
@@ -148,8 +185,8 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 0
|
||||
"commands": 10,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
@@ -163,7 +200,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-05-06T22:28:55Z"
|
||||
"updated_at": "2026-05-11T14:58:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
@@ -331,6 +368,38 @@
|
||||
"created_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": {
|
||||
"name": "Bugfix Workflow",
|
||||
"id": "bugfix",
|
||||
@@ -430,6 +499,38 @@
|
||||
"created_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": {
|
||||
"name": "CI Guard",
|
||||
"id": "ci-guard",
|
||||
@@ -1379,6 +1480,35 @@
|
||||
"created_at": "2026-04-28T00:00:00Z",
|
||||
"updated_at": "2026-04-28T00:00:00Z"
|
||||
},
|
||||
"mde": {
|
||||
"name": "MDE",
|
||||
"id": "mde",
|
||||
"description": "A Spec Kit extension that exposes a minimal model-driven engineering workflow with setup, next, and status commands.",
|
||||
"author": "AI-MDE",
|
||||
"version": "0.5.1",
|
||||
"download_url": "https://github.com/AI-MDE/spec-kit-mde/archive/refs/tags/v0.5.1.zip",
|
||||
"repository": "https://github.com/AI-MDE/spec-kit-mde",
|
||||
"homepage": "https://github.com/AI-MDE/spec-kit-mde",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"mde",
|
||||
"model-driven-engineering",
|
||||
"workflow",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"memory-loader": {
|
||||
"name": "Memory Loader",
|
||||
"id": "memory-loader",
|
||||
@@ -1415,8 +1545,8 @@
|
||||
"id": "memory-md",
|
||||
"description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context",
|
||||
"author": "DyanGalih",
|
||||
"version": "0.7.9",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.9.zip",
|
||||
"version": "0.8.5",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.8.5.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
|
||||
@@ -1426,8 +1556,8 @@
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 0
|
||||
"commands": 7,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
@@ -1441,7 +1571,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-23T00:00:00Z",
|
||||
"updated_at": "2026-05-06T22:28:55Z"
|
||||
"updated_at": "2026-05-11T14:58:00Z"
|
||||
},
|
||||
"memorylint": {
|
||||
"name": "MemoryLint",
|
||||
@@ -2079,6 +2209,38 @@
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
},
|
||||
"schedule": {
|
||||
"name": "Spec Kit Schedule — CP-SAT Agent Orchestrator",
|
||||
"id": "schedule",
|
||||
"description": "Optimal multi-agent task scheduling via CP-SAT solver with DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output",
|
||||
"author": "Julio César Franco Ardila",
|
||||
"version": "0.6.2",
|
||||
"download_url": "https://github.com/jfranc38/spec-kit-schedule/archive/refs/tags/v0.6.2.zip",
|
||||
"repository": "https://github.com/jfranc38/spec-kit-schedule",
|
||||
"homepage": "https://github.com/jfranc38/spec-kit-schedule",
|
||||
"documentation": "https://github.com/jfranc38/spec-kit-schedule/blob/main/README.md",
|
||||
"changelog": "https://github.com/jfranc38/spec-kit-schedule/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"scheduling",
|
||||
"optimization",
|
||||
"multi-agent",
|
||||
"cp-sat",
|
||||
"operations-research"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-06T22:35:00Z",
|
||||
"updated_at": "2026-05-07T17:25:00Z"
|
||||
},
|
||||
"scope": {
|
||||
"name": "Spec Scope",
|
||||
"id": "scope",
|
||||
@@ -2117,8 +2279,8 @@
|
||||
"id": "security-review",
|
||||
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.4.5",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.5.zip",
|
||||
"version": "1.5.0",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.0.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
||||
@@ -2128,8 +2290,8 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 7,
|
||||
"hooks": 0
|
||||
"commands": 9,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
@@ -2142,7 +2304,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-03T03:24:03Z",
|
||||
"updated_at": "2026-05-06T22:28:55Z"
|
||||
"updated_at": "2026-05-11T14:58:00Z"
|
||||
},
|
||||
"sf": {
|
||||
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
||||
|
||||
@@ -256,6 +256,43 @@
|
||||
"created_at": "2026-04-09T08: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": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
@@ -311,6 +348,37 @@
|
||||
"created_at": "2026-04-15T00:00:00Z",
|
||||
"updated_at": "2026-04-15T00:00:00Z"
|
||||
},
|
||||
"mde": {
|
||||
"name": "Model Driven Engineering",
|
||||
"id": "mde",
|
||||
"version": "0.5.1",
|
||||
"description": "Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows.",
|
||||
"author": "Ralph Hanna",
|
||||
"repository": "https://github.com/AI-MDE/spec-kit-preset-mde",
|
||||
"download_url": "https://github.com/AI-MDE/spec-kit-preset-mde/archive/refs/tags/v0.5.1.zip",
|
||||
"homepage": "https://github.com/AI-MDE/spec-kit-preset-mde",
|
||||
"documentation": "https://github.com/AI-MDE/spec-kit-preset-mde/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"extensions": [
|
||||
"mde"
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"templates": 6,
|
||||
"commands": 11
|
||||
},
|
||||
"tags": [
|
||||
"model-driven-engineering",
|
||||
"software-lifecycle",
|
||||
"business-analysis",
|
||||
"business-application",
|
||||
"multi-layered-architecture"
|
||||
],
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"multi-repo-branching": {
|
||||
"name": "Multi-Repo Branching",
|
||||
"id": "multi-repo-branching",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.7"
|
||||
version = "0.8.9"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -769,6 +769,8 @@ def _install_shared_infra(
|
||||
tracker: StepTracker | None = None,
|
||||
force: bool = False,
|
||||
invoke_separator: str = ".",
|
||||
refresh_managed: bool = False,
|
||||
refresh_hint: str | None = None,
|
||||
) -> bool:
|
||||
"""Install shared infrastructure files into *project_path*.
|
||||
|
||||
@@ -780,9 +782,23 @@ def _install_shared_infra(
|
||||
placeholders using *invoke_separator* (``"."`` for markdown agents,
|
||||
``"-"`` for skills agents).
|
||||
|
||||
When *force* is ``True``, existing files are overwritten with the
|
||||
latest bundled versions. When ``False`` (default), only missing
|
||||
files are added and existing ones are skipped.
|
||||
Overwrite policy:
|
||||
|
||||
* ``force=True`` — overwrite every existing file (still skips symlinks
|
||||
to avoid following links outside the project root).
|
||||
* ``refresh_managed=True`` — overwrite only files whose on-disk hash
|
||||
still matches the previously recorded manifest hash (i.e. unmodified
|
||||
files installed by spec-kit). Files with diverging hashes are
|
||||
treated as user customizations and preserved with a warning.
|
||||
* Default — only add missing files; existing ones are skipped.
|
||||
|
||||
*refresh_hint* — caller-supplied rich-text fragment shown after the
|
||||
"Preserved customized files" warning to tell the user which flag/command
|
||||
they should re-run with to overwrite their customizations. Each caller
|
||||
passes the flag that's actually valid in its CLI surface (e.g.
|
||||
``--refresh-shared-infra`` for ``integration switch``,
|
||||
``--force`` for ``init``/``integration upgrade``). When ``None``, no
|
||||
remediation hint is printed for customizations.
|
||||
|
||||
Returns ``True`` on success.
|
||||
"""
|
||||
@@ -795,6 +811,8 @@ def _install_shared_infra(
|
||||
console=console,
|
||||
force=force,
|
||||
invoke_separator=invoke_separator,
|
||||
refresh_managed=refresh_managed,
|
||||
refresh_hint=refresh_hint,
|
||||
)
|
||||
|
||||
|
||||
@@ -804,6 +822,8 @@ def _install_shared_infra_or_exit(
|
||||
tracker: StepTracker | None = None,
|
||||
force: bool = False,
|
||||
invoke_separator: str = ".",
|
||||
refresh_managed: bool = False,
|
||||
refresh_hint: str | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
return _install_shared_infra(
|
||||
@@ -812,6 +832,8 @@ def _install_shared_infra_or_exit(
|
||||
tracker=tracker,
|
||||
force=force,
|
||||
invoke_separator=invoke_separator,
|
||||
refresh_managed=refresh_managed,
|
||||
refresh_hint=refresh_hint,
|
||||
)
|
||||
except (ValueError, OSError) as exc:
|
||||
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
|
||||
@@ -1762,22 +1784,14 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
On anything else — including a malformed response body — the exception
|
||||
propagates; there is no catch-all (research D-006).
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
GITHUB_API_LATEST,
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
token = None
|
||||
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
|
||||
candidate = os.environ.get(env_var)
|
||||
if candidate is not None:
|
||||
candidate = candidate.strip()
|
||||
if candidate:
|
||||
token = candidate
|
||||
break
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
from .authentication.http import open_url
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
with open_url(
|
||||
GITHUB_API_LATEST,
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
@@ -1786,7 +1800,9 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
except urllib.error.HTTPError as e:
|
||||
# Order matters: HTTPError is a subclass of URLError.
|
||||
if e.code == 403:
|
||||
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||
return None, (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
return None, f"HTTP {e.code}"
|
||||
except (urllib.error.URLError, OSError):
|
||||
return None, "offline or timeout"
|
||||
@@ -2589,7 +2605,8 @@ def integration_uninstall(
|
||||
def integration_switch(
|
||||
target: str = typer.Argument(help="Integration key to switch to"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"),
|
||||
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
||||
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
||||
):
|
||||
"""Switch from the current integration to a different one."""
|
||||
@@ -2760,14 +2777,27 @@ def integration_switch(
|
||||
target_integration, current, target, integration_options
|
||||
)
|
||||
|
||||
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||
# _install_shared_infra merges missing files without overwriting).
|
||||
# Refresh shared infrastructure to the current CLI version. Switching
|
||||
# integrations is exactly when stale vendored shared scripts (e.g.
|
||||
# update-agent-context.sh that pre-dates the target integration's
|
||||
# supported-agent list) would silently break the new integration.
|
||||
#
|
||||
# Use refresh_managed=True so only files that match their previously
|
||||
# recorded hash are overwritten — user customizations are detected via
|
||||
# hash divergence and preserved with a warning. Pass
|
||||
# --refresh-shared-infra to overwrite customizations as well. See #2293.
|
||||
_install_shared_infra_or_exit(
|
||||
project_root,
|
||||
selected_script,
|
||||
force=refresh_shared_infra,
|
||||
refresh_managed=True,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
target_integration, current, target, parsed_options
|
||||
),
|
||||
refresh_hint=(
|
||||
"To overwrite customizations, re-run with "
|
||||
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
ensure_executable_scripts(project_root)
|
||||
@@ -3381,7 +3411,9 @@ def preset_add(
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
try:
|
||||
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
with _open_url(from_url, timeout=60) as response:
|
||||
zip_path.write_bytes(response.read())
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||
@@ -4285,7 +4317,9 @@ def extension_add(
|
||||
zip_path = download_dir / f"{extension}-url-download.zip"
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(from_url, timeout=60) as response:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
with _open_url(from_url, timeout=60) as response:
|
||||
zip_data = response.read()
|
||||
zip_path.write_bytes(zip_data)
|
||||
|
||||
@@ -5500,7 +5534,7 @@ def workflow_add(
|
||||
if source.startswith("http://") or source.startswith("https://"):
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen # noqa: S310
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
parsed_src = urlparse(source)
|
||||
src_host = parsed_src.hostname or ""
|
||||
@@ -5517,7 +5551,7 @@ def workflow_add(
|
||||
|
||||
import tempfile
|
||||
try:
|
||||
with urlopen(source, timeout=30) as resp: # noqa: S310
|
||||
with _open_url(source, timeout=30) as resp:
|
||||
final_url = resp.geturl()
|
||||
final_parsed = urlparse(final_url)
|
||||
final_host = final_parsed.hostname or ""
|
||||
@@ -5613,10 +5647,10 @@ def workflow_add(
|
||||
workflow_file = workflow_dir / "workflow.yml"
|
||||
|
||||
try:
|
||||
from urllib.request import urlopen # noqa: S310 — URL comes from catalog
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
workflow_dir.mkdir(parents=True, exist_ok=True)
|
||||
with urlopen(workflow_url, timeout=30) as response: # noqa: S310
|
||||
with _open_url(workflow_url, timeout=30) as response:
|
||||
# Validate final URL after redirects
|
||||
final_url = response.geturl()
|
||||
final_parsed = urlparse(final_url)
|
||||
|
||||
50
src/specify_cli/authentication/__init__.py
Normal file
50
src/specify_cli/authentication/__init__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Authentication provider registry for multi-platform support.
|
||||
|
||||
Credentials are **opt-in only**. No authentication headers are sent unless
|
||||
the user creates ``~/.specify/auth.json`` mapping hosts to providers.
|
||||
Provider classes define *how* to authenticate (Bearer, Basic-PAT, etc.)
|
||||
while the config file defines *where* and *with what credentials*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import AuthProvider
|
||||
|
||||
# Maps provider key → AuthProvider class instance.
|
||||
AUTH_REGISTRY: dict[str, AuthProvider] = {}
|
||||
|
||||
|
||||
def _register(provider: AuthProvider) -> None:
|
||||
"""Register a provider instance in the global registry.
|
||||
|
||||
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
|
||||
"""
|
||||
key = provider.key
|
||||
if not key:
|
||||
raise ValueError("Cannot register provider with an empty key.")
|
||||
if key in AUTH_REGISTRY:
|
||||
raise KeyError(f"Provider with key {key!r} is already registered.")
|
||||
AUTH_REGISTRY[key] = provider
|
||||
|
||||
|
||||
def get_provider(key: str) -> AuthProvider | None:
|
||||
"""Return the provider for *key*, or ``None`` if not registered."""
|
||||
return AUTH_REGISTRY.get(key)
|
||||
|
||||
|
||||
# -- Register built-in providers -----------------------------------------
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
"""Register all built-in authentication providers (alphabetical)."""
|
||||
from .azure_devops import AzureDevOpsAuth
|
||||
from .github import GitHubAuth
|
||||
|
||||
_register(AzureDevOpsAuth())
|
||||
_register(GitHubAuth())
|
||||
|
||||
|
||||
_register_builtins()
|
||||
117
src/specify_cli/authentication/azure_devops.py
Normal file
117
src/specify_cli/authentication/azure_devops.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Azure DevOps authentication provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json as _json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import AuthProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import AuthConfigEntry
|
||||
|
||||
# Azure DevOps resource ID for OAuth / Azure AD token acquisition.
|
||||
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
|
||||
|
||||
|
||||
class AzureDevOpsAuth(AuthProvider):
|
||||
"""Azure DevOps authentication provider.
|
||||
|
||||
Supports four auth schemes:
|
||||
|
||||
* ``basic-pat`` — PAT with empty username, Base64-encoded as ``:<PAT>``
|
||||
* ``bearer`` — pre-acquired OAuth / Azure AD token
|
||||
* ``azure-cli`` — acquires a token via ``az account get-access-token``
|
||||
* ``azure-ad`` — acquires a token via OAuth2 client credentials flow
|
||||
"""
|
||||
|
||||
key = "azure-devops"
|
||||
supported_auth_schemes = ("basic-pat", "bearer", "azure-cli", "azure-ad")
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
"""Build the ``Authorization`` header for the given scheme."""
|
||||
if auth_scheme == "basic-pat":
|
||||
encoded = base64.b64encode(f":{token}".encode("ascii")).decode("ascii")
|
||||
return {"Authorization": f"Basic {encoded}"}
|
||||
if auth_scheme in ("bearer", "azure-cli", "azure-ad"):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
raise ValueError(
|
||||
f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}"
|
||||
)
|
||||
|
||||
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
|
||||
"""Resolve token, with special handling for azure-cli and azure-ad."""
|
||||
if entry.auth == "azure-cli":
|
||||
return self._acquire_via_az_cli()
|
||||
if entry.auth == "azure-ad":
|
||||
return self._acquire_via_client_credentials(entry)
|
||||
return super().resolve_token(entry)
|
||||
|
||||
# -- Token acquisition ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _acquire_via_az_cli() -> str | None:
|
||||
"""Run ``az account get-access-token`` and return the access token."""
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603, S607
|
||||
[
|
||||
"az",
|
||||
"account",
|
||||
"get-access-token",
|
||||
"--resource",
|
||||
_ADO_RESOURCE_ID,
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
payload = _json.loads(result.stdout)
|
||||
token = payload.get("accessToken", "").strip()
|
||||
return token or None
|
||||
except (OSError, subprocess.TimeoutExpired, _json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
|
||||
"""Acquire a token via OAuth2 client credentials flow."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
if not entry.tenant_id or not entry.client_id or not entry.client_secret_env:
|
||||
return None
|
||||
client_secret = os.environ.get(entry.client_secret_env, "").strip()
|
||||
if not client_secret:
|
||||
return None
|
||||
|
||||
url = (
|
||||
f"https://login.microsoftonline.com/{entry.tenant_id}"
|
||||
"/oauth2/v2.0/token"
|
||||
)
|
||||
from urllib.parse import urlencode
|
||||
body = urlencode({
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": entry.client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": f"{_ADO_RESOURCE_ID}/.default",
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
|
||||
payload = _json.loads(resp.read().decode("utf-8"))
|
||||
token = payload.get("access_token", "").strip()
|
||||
return token or None
|
||||
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
57
src/specify_cli/authentication/base.py
Normal file
57
src/specify_cli/authentication/base.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Abstract base class for authentication providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import AuthConfigEntry
|
||||
|
||||
|
||||
class AuthProvider(ABC):
|
||||
"""Abstract base class every authentication provider must implement.
|
||||
|
||||
Subclasses must set:
|
||||
|
||||
* ``key`` — unique provider identifier (e.g. ``"github"``, ``"azure-devops"``)
|
||||
* ``supported_auth_schemes`` — tuple of auth scheme strings this provider handles
|
||||
|
||||
And implement:
|
||||
|
||||
* ``auth_headers(token, auth_scheme)`` — build headers from a resolved token
|
||||
* ``resolve_token(entry)`` — obtain the token for a config entry
|
||||
"""
|
||||
|
||||
key: str = ""
|
||||
"""Unique provider identifier."""
|
||||
|
||||
supported_auth_schemes: tuple[str, ...] = ()
|
||||
"""Auth schemes this provider supports (e.g. ``("bearer",)``)."""
|
||||
|
||||
@abstractmethod
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
"""Build authentication headers for *token* using *auth_scheme*.
|
||||
|
||||
Must return a dict with at least an ``Authorization`` key.
|
||||
"""
|
||||
|
||||
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
|
||||
"""Resolve the token for *entry*.
|
||||
|
||||
Default implementation reads from ``entry.token`` directly
|
||||
or from the environment variable named by ``entry.token_env``.
|
||||
Override for schemes that acquire tokens dynamically
|
||||
(e.g. ``azure-cli``, ``azure-ad``).
|
||||
"""
|
||||
import os
|
||||
|
||||
if entry.token:
|
||||
return entry.token.strip() or None
|
||||
if entry.token_env:
|
||||
val = os.environ.get(entry.token_env)
|
||||
if val is not None:
|
||||
val = val.strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
209
src/specify_cli/authentication/config.py
Normal file
209
src/specify_cli/authentication/config.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Authentication configuration loader.
|
||||
|
||||
Reads ``~/.specify/auth.json`` to determine which hosts receive credentials
|
||||
and which provider/auth-scheme to use. No credentials are sent without
|
||||
an explicit opt-in via this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthConfigEntry:
|
||||
"""A single provider entry from ``auth.json``."""
|
||||
|
||||
hosts: tuple[str, ...]
|
||||
provider: str
|
||||
auth: str
|
||||
token: str | None = None
|
||||
token_env: str | None = None
|
||||
# Azure AD service-principal fields
|
||||
tenant_id: str | None = None
|
||||
client_id: str | None = None
|
||||
client_secret_env: str | None = None
|
||||
|
||||
|
||||
def _default_config_path() -> Path:
|
||||
"""Return ``~/.specify/auth.json``."""
|
||||
return Path.home() / ".specify" / "auth.json"
|
||||
|
||||
|
||||
def _is_valid_host_pattern(pattern: str) -> bool:
|
||||
"""Return True for safe host patterns: exact hostnames or ``*.suffix`` only.
|
||||
|
||||
Rejects patterns like ``*github.com`` (which would match
|
||||
``github.com.evil.com``) or multi-wildcard forms. Only these two
|
||||
forms are accepted:
|
||||
|
||||
* ``example.com`` — exact hostname
|
||||
* ``*.example.com`` — leading ``*.`` wildcard; matches subdomains
|
||||
such as ``myorg.example.com`` but not ``example.com`` itself
|
||||
"""
|
||||
if "*" not in pattern:
|
||||
return True # exact hostname — already validated as non-empty
|
||||
# Only *.suffix is allowed; no other wildcard positions
|
||||
return pattern.startswith("*.") and "*" not in pattern[2:]
|
||||
|
||||
|
||||
def load_auth_config(
|
||||
path: Path | None = None,
|
||||
) -> list[AuthConfigEntry]:
|
||||
"""Load and validate ``auth.json``, returning configured entries.
|
||||
|
||||
Returns an empty list when the file does not exist — this means
|
||||
all HTTP requests will be unauthenticated (opt-in model).
|
||||
|
||||
Raises ``ValueError`` on schema violations. Callers that want
|
||||
misconfigurations to fail fast can allow this exception to
|
||||
propagate; higher-level HTTP helpers may instead catch it,
|
||||
warn, and continue with unauthenticated requests.
|
||||
"""
|
||||
config_path = path or _default_config_path()
|
||||
|
||||
if not config_path.is_file():
|
||||
return []
|
||||
|
||||
# Warn (but don't fail) if the file is world-readable (POSIX only).
|
||||
if os.name != "nt":
|
||||
try:
|
||||
mode = config_path.stat().st_mode
|
||||
if mode & (stat.S_IRGRP | stat.S_IROTH):
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"{config_path} is readable by group/others. "
|
||||
"Consider restricting with: chmod 600 "
|
||||
f"{config_path}",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
except OSError:
|
||||
pass # stat failed — skip permission check
|
||||
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}")
|
||||
|
||||
providers_raw = raw.get("providers")
|
||||
if not isinstance(providers_raw, list):
|
||||
raise ValueError("auth.json must contain a 'providers' array")
|
||||
|
||||
entries: list[AuthConfigEntry] = []
|
||||
for i, entry_raw in enumerate(providers_raw):
|
||||
if not isinstance(entry_raw, dict):
|
||||
raise ValueError(f"providers[{i}]: must be a JSON object")
|
||||
|
||||
hosts = entry_raw.get("hosts")
|
||||
if not isinstance(hosts, list) or not hosts:
|
||||
raise ValueError(f"providers[{i}]: 'hosts' must be a non-empty array")
|
||||
if not all(isinstance(h, str) and h.strip() for h in hosts):
|
||||
raise ValueError(f"providers[{i}]: each host must be a non-empty string")
|
||||
# Normalize hosts: strip whitespace and lowercase
|
||||
hosts = [h.strip().lower() for h in hosts]
|
||||
# Reject dangerous wildcard forms (e.g. *github.com matches github.com.evil.com)
|
||||
for h in hosts:
|
||||
if not _is_valid_host_pattern(h):
|
||||
raise ValueError(
|
||||
f"providers[{i}]: invalid host pattern {h!r}. "
|
||||
"Only exact hostnames or '*.suffix' forms are allowed "
|
||||
"(e.g. 'github.com' or '*.visualstudio.com')."
|
||||
)
|
||||
|
||||
provider = entry_raw.get("provider", "")
|
||||
if not isinstance(provider, str) or not provider:
|
||||
raise ValueError(f"providers[{i}]: 'provider' must be a non-empty string")
|
||||
|
||||
auth = entry_raw.get("auth", "")
|
||||
if not isinstance(auth, str) or not auth:
|
||||
raise ValueError(f"providers[{i}]: 'auth' must be a non-empty string")
|
||||
|
||||
token = entry_raw.get("token")
|
||||
token_env = entry_raw.get("token_env")
|
||||
|
||||
# Validate token/token_env types
|
||||
if token is not None and (not isinstance(token, str) or not token.strip()):
|
||||
raise ValueError(f"providers[{i}]: 'token' must be a non-empty string")
|
||||
if token_env is not None and (not isinstance(token_env, str) or not token_env.strip()):
|
||||
raise ValueError(f"providers[{i}]: 'token_env' must be a non-empty string")
|
||||
|
||||
# Validate provider+scheme compatibility
|
||||
from . import get_provider as _get_provider
|
||||
_prov = _get_provider(provider)
|
||||
if _prov is None:
|
||||
from . import AUTH_REGISTRY
|
||||
raise ValueError(
|
||||
f"providers[{i}]: unknown provider {provider!r}; "
|
||||
f"registered: {sorted(AUTH_REGISTRY.keys())}"
|
||||
)
|
||||
if auth not in _prov.supported_auth_schemes:
|
||||
raise ValueError(
|
||||
f"providers[{i}]: provider {provider!r} does not support "
|
||||
f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}"
|
||||
)
|
||||
|
||||
# Validate token source based on auth scheme
|
||||
if auth in ("bearer", "basic-pat"):
|
||||
if not token and not token_env:
|
||||
raise ValueError(
|
||||
f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'"
|
||||
)
|
||||
elif auth == "azure-ad":
|
||||
tenant_id = entry_raw.get("tenant_id")
|
||||
client_id = entry_raw.get("client_id")
|
||||
client_secret_env = entry_raw.get("client_secret_env")
|
||||
if not all([tenant_id, client_id, client_secret_env]):
|
||||
raise ValueError(
|
||||
f"providers[{i}]: auth='azure-ad' requires "
|
||||
"'tenant_id', 'client_id', and 'client_secret_env'"
|
||||
)
|
||||
for field_name, field_val in [
|
||||
("tenant_id", tenant_id),
|
||||
("client_id", client_id),
|
||||
("client_secret_env", client_secret_env),
|
||||
]:
|
||||
if not isinstance(field_val, str) or not field_val.strip():
|
||||
raise ValueError(
|
||||
f"providers[{i}]: '{field_name}' must be a non-empty string"
|
||||
)
|
||||
# azure-cli needs no extra fields
|
||||
|
||||
entries.append(
|
||||
AuthConfigEntry(
|
||||
hosts=tuple(hosts),
|
||||
provider=provider,
|
||||
auth=auth,
|
||||
token=token,
|
||||
token_env=token_env,
|
||||
tenant_id=entry_raw.get("tenant_id"),
|
||||
client_id=entry_raw.get("client_id"),
|
||||
client_secret_env=entry_raw.get("client_secret_env"),
|
||||
)
|
||||
)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def find_entries_for_url(
|
||||
url: str, entries: list[AuthConfigEntry]
|
||||
) -> list[AuthConfigEntry]:
|
||||
"""Return entries whose ``hosts`` match the hostname of *url*."""
|
||||
hostname = (urlparse(url).hostname or "").lower()
|
||||
if not hostname:
|
||||
return []
|
||||
return [
|
||||
e
|
||||
for e in entries
|
||||
if any(
|
||||
pattern == hostname or fnmatch(hostname, pattern)
|
||||
for pattern in e.hosts
|
||||
)
|
||||
]
|
||||
24
src/specify_cli/authentication/github.py
Normal file
24
src/specify_cli/authentication/github.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""GitHub authentication provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import AuthProvider
|
||||
|
||||
|
||||
class GitHubAuth(AuthProvider):
|
||||
"""GitHub authentication provider.
|
||||
|
||||
Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs,
|
||||
OAuth tokens, and GitHub App installation tokens.
|
||||
"""
|
||||
|
||||
key = "github"
|
||||
supported_auth_schemes = ("bearer",)
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
"""Return ``Authorization: Bearer <token>``."""
|
||||
if auth_scheme != "bearer":
|
||||
raise ValueError(
|
||||
f"GitHubAuth does not support auth scheme {auth_scheme!r}"
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
149
src/specify_cli/authentication/http.py
Normal file
149
src/specify_cli/authentication/http.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Authenticated HTTP helpers driven by ``~/.specify/auth.json``.
|
||||
|
||||
No credentials are sent unless the user has created ``auth.json``.
|
||||
For each outbound URL the helper matches the hostname against
|
||||
configured entries, resolves the token via the appropriate provider
|
||||
class, and attaches auth headers. Redirect safety is enforced:
|
||||
the ``Authorization`` header is stripped when a redirect leaves the
|
||||
entry's declared hosts. On 401/403 the next matching entry is tried,
|
||||
then unauthenticated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from fnmatch import fnmatch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import get_provider
|
||||
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
|
||||
|
||||
|
||||
_config_override: list[AuthConfigEntry] | None = None
|
||||
_config_cache: list[AuthConfigEntry] | None = None # None = not yet loaded
|
||||
|
||||
|
||||
def _load_config() -> list[AuthConfigEntry]:
|
||||
"""Load auth config, using override if set (for testing).
|
||||
|
||||
The result is cached per-process so ``auth.json`` is read at most once,
|
||||
and any warning about a malformed file fires only once.
|
||||
"""
|
||||
global _config_cache
|
||||
if _config_override is not None:
|
||||
return _config_override
|
||||
if _config_cache is not None:
|
||||
return _config_cache
|
||||
try:
|
||||
_config_cache = load_auth_config()
|
||||
except (ValueError, OSError) as exc:
|
||||
import warnings
|
||||
config_path = _default_config_path()
|
||||
warnings.warn(
|
||||
f"Failed to load {config_path}: {exc}. "
|
||||
"All requests will be unauthenticated.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
_config_cache = []
|
||||
return _config_cache
|
||||
|
||||
|
||||
def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
"""Return True if *hostname* matches any pattern in *hosts*."""
|
||||
hostname = hostname.lower()
|
||||
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
|
||||
|
||||
def __init__(self, hosts: tuple[str, ...]) -> None:
|
||||
super().__init__()
|
||||
self._hosts = hosts
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
original_auth = (
|
||||
req.get_header("Authorization")
|
||||
or req.unredirected_hdrs.get("Authorization")
|
||||
)
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
hostname = (urlparse(newurl).hostname or "").lower()
|
||||
if _hostname_in_hosts(hostname, self._hosts):
|
||||
if original_auth:
|
||||
new_req.add_unredirected_header("Authorization", original_auth)
|
||||
else:
|
||||
new_req.headers.pop("Authorization", None)
|
||||
new_req.unredirected_hdrs.pop("Authorization", None)
|
||||
return new_req
|
||||
|
||||
|
||||
def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urllib.request.Request:
|
||||
"""Build a :class:`~urllib.request.Request`, attaching auth when config matches.
|
||||
|
||||
Uses the first matching entry from ``auth.json`` whose token resolves.
|
||||
Returns a plain request when no entry matches or the file doesn't exist.
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if extra_headers:
|
||||
# Strip Authorization from extra_headers to prevent bypass
|
||||
headers.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"})
|
||||
# Auth headers applied last — cannot be overridden by extra_headers
|
||||
entries = find_entries_for_url(url, _load_config())
|
||||
for entry in entries:
|
||||
provider = get_provider(entry.provider)
|
||||
if provider is None:
|
||||
continue
|
||||
token = provider.resolve_token(entry)
|
||||
if token:
|
||||
headers.update(provider.auth_headers(token, entry.auth))
|
||||
break
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None):
|
||||
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
|
||||
|
||||
1. Find ``auth.json`` entries whose hosts match the URL.
|
||||
2. For each entry, resolve the token and try the request.
|
||||
3. On 401/403 move to the next matching entry.
|
||||
4. After all entries exhausted (or none matched), try unauthenticated.
|
||||
5. Non-auth errors (404, 500, network) raise immediately.
|
||||
|
||||
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
|
||||
"""
|
||||
entries = find_entries_for_url(url, _load_config())
|
||||
|
||||
def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
|
||||
merged = {}
|
||||
if extra_headers:
|
||||
# Strip Authorization from extra_headers to prevent bypass
|
||||
merged.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"})
|
||||
# Auth headers applied last — cannot be overridden by extra_headers
|
||||
merged.update(auth_headers)
|
||||
return urllib.request.Request(url, headers=merged)
|
||||
|
||||
# Try each matching entry
|
||||
for entry in entries:
|
||||
provider = get_provider(entry.provider)
|
||||
if provider is None:
|
||||
continue
|
||||
token = provider.resolve_token(entry)
|
||||
if not token:
|
||||
continue
|
||||
|
||||
req = _make_req(provider.auth_headers(token, entry.auth))
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
|
||||
try:
|
||||
return opener.open(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (401, 403):
|
||||
exc.close()
|
||||
continue # try next entry
|
||||
raise
|
||||
|
||||
# No entry worked (or none matched) — unauthenticated fallback
|
||||
req = _make_req({})
|
||||
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
|
||||
180
src/specify_cli/catalogs.py
Normal file
180
src/specify_cli/catalogs.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""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.")
|
||||
|
||||
def _make_request(self, url: str):
|
||||
"""Build a urllib Request, adding a GitHub auth header when available.
|
||||
"""Build a urllib Request, adding auth headers when a provider matches.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.build_github_request`.
|
||||
Delegates to :func:`specify_cli.authentication.http.build_request`.
|
||||
"""
|
||||
from specify_cli._github_http import build_github_request
|
||||
return build_github_request(url)
|
||||
from specify_cli.authentication.http import build_request
|
||||
return build_request(url)
|
||||
|
||||
def _open_url(self, url: str, timeout: int = 10):
|
||||
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||
"""Open a URL with provider-based auth, trying each configured provider.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.open_github_url`.
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
"""
|
||||
from specify_cli._github_http import open_github_url
|
||||
return open_github_url(url, timeout)
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
@@ -21,6 +21,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
import yaml
|
||||
from packaging import version as pkg_version
|
||||
|
||||
from ..catalogs import CatalogEntry, CatalogStackBase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
@@ -43,21 +45,15 @@ class IntegrationDescriptorError(Exception):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class IntegrationCatalogEntry:
|
||||
class IntegrationCatalogEntry(CatalogEntry):
|
||||
"""Represents a single catalog source in the catalog stack."""
|
||||
|
||||
url: str
|
||||
name: str
|
||||
priority: int
|
||||
install_allowed: bool
|
||||
description: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class IntegrationCatalog:
|
||||
class IntegrationCatalog(CatalogStackBase):
|
||||
"""Manages integration catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = (
|
||||
@@ -67,136 +63,15 @@ class IntegrationCatalog:
|
||||
"https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json"
|
||||
)
|
||||
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:
|
||||
self.project_root = project_root
|
||||
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]:
|
||||
"""Return the ordered list of active integration catalogs.
|
||||
|
||||
@@ -265,7 +140,6 @@ class IntegrationCatalog:
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch one catalog, with per-URL caching."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
|
||||
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
|
||||
@@ -289,7 +163,9 @@ class IntegrationCatalog:
|
||||
pass # Cache cleanup is best-effort; ignore deletion failures.
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(entry.url, timeout=10) as resp:
|
||||
from specify_cli.authentication.http import open_url
|
||||
|
||||
with open_url(entry.url, timeout=10) as resp:
|
||||
# Validate final URL after redirects
|
||||
final_url = resp.geturl()
|
||||
if final_url != entry.url:
|
||||
@@ -443,8 +319,6 @@ class IntegrationCatalog:
|
||||
|
||||
# -- Catalog-source management ----------------------------------------
|
||||
|
||||
CONFIG_FILENAME = "integration-catalogs.yml"
|
||||
|
||||
def get_catalog_configs(self) -> List[Dict[str, Any]]:
|
||||
"""Return the active catalog stack as a list of dicts.
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
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):
|
||||
key = "kiro-cli"
|
||||
config = {
|
||||
@@ -15,7 +23,7 @@ class KiroCliIntegration(MarkdownIntegration):
|
||||
registrar_config = {
|
||||
"dir": ".kiro/prompts",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"args": _KIRO_ARG_FALLBACK,
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
@@ -1845,20 +1845,20 @@ class PresetCatalog:
|
||||
)
|
||||
|
||||
def _make_request(self, url: str):
|
||||
"""Build a urllib Request, adding a GitHub auth header when available.
|
||||
"""Build a urllib Request, adding auth headers when a provider matches.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.build_github_request`.
|
||||
Delegates to :func:`specify_cli.authentication.http.build_request`.
|
||||
"""
|
||||
from specify_cli._github_http import build_github_request
|
||||
return build_github_request(url)
|
||||
from specify_cli.authentication.http import build_request
|
||||
return build_request(url)
|
||||
|
||||
def _open_url(self, url: str, timeout: int = 10):
|
||||
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||
"""Open a URL with provider-based auth, trying each configured provider.
|
||||
|
||||
Delegates to :func:`specify_cli._github_http.open_github_url`.
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
"""
|
||||
from specify_cli._github_http import open_github_url
|
||||
return open_github_url(url, timeout)
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
@@ -11,6 +11,15 @@ from .integrations.base import IntegrationBase
|
||||
from .integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class SymlinkedSharedPathError(ValueError):
|
||||
"""Raised when a shared infrastructure path or ancestor is a symlink.
|
||||
|
||||
Distinct from other unsafe-path errors so callers can preserve symlinked
|
||||
destinations as customizations while still letting genuine safety errors
|
||||
(e.g. path escape, not-a-directory) propagate and abort the operation.
|
||||
"""
|
||||
|
||||
|
||||
def load_speckit_manifest(
|
||||
project_path: Path,
|
||||
*,
|
||||
@@ -89,7 +98,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
|
||||
current = current / part
|
||||
label = _shared_destination_label(project_path, current)
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
if current.exists():
|
||||
if not current.is_dir():
|
||||
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
|
||||
@@ -102,7 +111,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
|
||||
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
|
||||
current.mkdir()
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
try:
|
||||
current.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
@@ -119,7 +128,7 @@ def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None
|
||||
current = current / part
|
||||
label = _shared_destination_label(project_path, current)
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
if not current.exists():
|
||||
continue
|
||||
if not current.is_dir():
|
||||
@@ -145,7 +154,7 @@ def _ensure_safe_shared_destination(
|
||||
_validate_safe_shared_directory(project_path, dest.parent)
|
||||
label = _shared_destination_label(project_path, dest)
|
||||
if dest.is_symlink():
|
||||
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
|
||||
|
||||
if dest.exists():
|
||||
try:
|
||||
@@ -242,58 +251,147 @@ def install_shared_infra(
|
||||
console: Any,
|
||||
force: bool = False,
|
||||
invoke_separator: str = ".",
|
||||
refresh_managed: bool = False,
|
||||
refresh_hint: str | None = None,
|
||||
) -> bool:
|
||||
"""Install shared scripts and templates into *project_path*."""
|
||||
"""Install shared scripts and templates into *project_path*.
|
||||
|
||||
When ``refresh_managed`` is True, files whose on-disk hash still matches
|
||||
the previously recorded manifest hash are overwritten with the bundled
|
||||
version. Files whose hash diverges are treated as user customizations and
|
||||
preserved with a warning. ``force=True`` overwrites every regular file
|
||||
(symlinks and symlinked-parent destinations are always preserved with a
|
||||
warning — the safe-destination check refuses to follow them so writes
|
||||
cannot escape the project root). ``refresh_hint`` is shown after the
|
||||
customization warning to tell the user which flag would overwrite their
|
||||
customizations.
|
||||
"""
|
||||
from .integrations.manifest import _sha256
|
||||
|
||||
manifest = load_speckit_manifest(project_path, version=version, console=console)
|
||||
prior_hashes = dict(manifest.files)
|
||||
|
||||
def _is_managed(rel: str, dst: Path) -> bool:
|
||||
expected = prior_hashes.get(rel)
|
||||
if not expected or not dst.is_file() or dst.is_symlink():
|
||||
return False
|
||||
try:
|
||||
return _sha256(dst) == expected
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
skipped_files: list[str] = []
|
||||
preserved_user_files: list[str] = []
|
||||
symlinked_files: list[str] = []
|
||||
planned_copies: list[tuple[Path, str, bytes, int]] = []
|
||||
planned_templates: list[tuple[Path, str, str]] = []
|
||||
|
||||
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
|
||||
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
|
||||
if not dst.exists():
|
||||
return True, None
|
||||
if force:
|
||||
return True, None
|
||||
if refresh_managed:
|
||||
if _is_managed(rel, dst):
|
||||
return True, None
|
||||
if rel in prior_hashes:
|
||||
return False, "preserved"
|
||||
return False, "skip"
|
||||
return False, "skip"
|
||||
|
||||
def _safe_dest_or_bucket(dst: Path, rel: str, *, parent_must_exist: bool = True) -> bool:
|
||||
"""Run the safe-destination check and bucket symlinked paths.
|
||||
|
||||
Returns True when the destination is safe to consider (write or skip).
|
||||
Returns False (and records *rel* under ``symlinked_files``) when the
|
||||
destination or any of its ancestors is a symlink — those paths can't
|
||||
be written to safely, but they shouldn't abort the whole switch
|
||||
either. They're surfaced as a separate "symlinked" warning bucket.
|
||||
|
||||
Other unsafe-path errors (e.g. path escape, parent-not-a-directory)
|
||||
are NOT caught here: they re-raise so the operation aborts, since
|
||||
treating them as "symlinked" would mask security-relevant failures.
|
||||
"""
|
||||
try:
|
||||
_ensure_safe_shared_destination(project_path, dst, parent_must_exist=parent_must_exist)
|
||||
except SymlinkedSharedPathError:
|
||||
symlinked_files.append(rel)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _ensure_or_bucket_dir(directory: Path) -> bool:
|
||||
"""Create *directory* unless an ancestor is symlinked.
|
||||
|
||||
Returns True when the directory is safe to use. Returns False (and
|
||||
records the path under ``symlinked_files``) when a symlink ancestor
|
||||
forces us to skip the whole subtree. Other unsafe-path errors
|
||||
(escape, not-a-directory) re-raise so the operation aborts.
|
||||
"""
|
||||
try:
|
||||
_ensure_safe_shared_directory(project_path, directory)
|
||||
except SymlinkedSharedPathError:
|
||||
symlinked_files.append(directory.relative_to(project_path).as_posix())
|
||||
return False
|
||||
return True
|
||||
|
||||
scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if scripts_src.is_dir():
|
||||
dest_scripts = project_path / ".specify" / "scripts"
|
||||
_ensure_safe_shared_directory(project_path, dest_scripts)
|
||||
variant_dir = "bash" if script_type == "sh" else "powershell"
|
||||
variant_src = scripts_src / variant_dir
|
||||
if variant_src.is_dir():
|
||||
dest_variant = dest_scripts / variant_dir
|
||||
_ensure_safe_shared_directory(project_path, dest_variant)
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
if _ensure_or_bucket_dir(dest_scripts):
|
||||
variant_dir = "bash" if script_type == "sh" else "powershell"
|
||||
variant_src = scripts_src / variant_dir
|
||||
if variant_src.is_dir():
|
||||
dest_variant = dest_scripts / variant_dir
|
||||
if _ensure_or_bucket_dir(dest_variant):
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
_ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False)
|
||||
if dst_path.exists() and not force:
|
||||
skipped_files.append(dst_path.relative_to(project_path).as_posix())
|
||||
continue
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
|
||||
continue
|
||||
write, bucket = _decide_overwrite(rel, dst_path)
|
||||
if not write:
|
||||
if bucket == "preserved":
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
continue
|
||||
|
||||
_ensure_safe_shared_directory(project_path, dst_path.parent)
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
|
||||
|
||||
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if templates_src.is_dir():
|
||||
dest_templates = project_path / ".specify" / "templates"
|
||||
_ensure_safe_shared_directory(project_path, dest_templates)
|
||||
for src in templates_src.iterdir():
|
||||
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
|
||||
continue
|
||||
if _ensure_or_bucket_dir(dest_templates):
|
||||
for src in templates_src.iterdir():
|
||||
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
|
||||
continue
|
||||
|
||||
dst = dest_templates / src.name
|
||||
_ensure_safe_shared_destination(project_path, dst)
|
||||
if dst.exists() and not force:
|
||||
skipped_files.append(dst.relative_to(project_path).as_posix())
|
||||
continue
|
||||
dst = dest_templates / src.name
|
||||
rel = dst.relative_to(project_path).as_posix()
|
||||
if not _safe_dest_or_bucket(dst, rel):
|
||||
continue
|
||||
write, bucket = _decide_overwrite(rel, dst)
|
||||
if not write:
|
||||
if bucket == "preserved":
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
continue
|
||||
|
||||
content = src.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
rel = dst.relative_to(project_path).as_posix()
|
||||
planned_templates.append((dst, rel, content))
|
||||
content = src.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
planned_templates.append((dst, rel, content))
|
||||
|
||||
for dst_path, rel, content, mode in planned_copies:
|
||||
_ensure_safe_shared_directory(project_path, dst_path.parent)
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
_write_shared_bytes(project_path, dst_path, content, mode=mode)
|
||||
manifest.record_existing(rel)
|
||||
|
||||
@@ -307,11 +405,37 @@ def install_shared_infra(
|
||||
)
|
||||
for path in skipped_files:
|
||||
console.print(f" {path}")
|
||||
if refresh_managed and refresh_hint:
|
||||
console.print(refresh_hint)
|
||||
else:
|
||||
console.print(
|
||||
"To refresh shared infrastructure, run "
|
||||
"[cyan]specify init --here --force[/cyan] or "
|
||||
"[cyan]specify integration upgrade --force[/cyan]."
|
||||
)
|
||||
|
||||
if symlinked_files:
|
||||
console.print(
|
||||
"To refresh shared infrastructure, run "
|
||||
"[cyan]specify init --here --force[/cyan] or "
|
||||
"[cyan]specify integration upgrade --force[/cyan]."
|
||||
f"[yellow]⚠[/yellow] Skipped {len(symlinked_files)} symlinked shared "
|
||||
"infrastructure path(s) — symlinks are never overwritten because they "
|
||||
"may resolve outside the project root:"
|
||||
)
|
||||
for path in symlinked_files:
|
||||
console.print(f" {path}")
|
||||
console.print(
|
||||
"To restore the bundled version, remove or replace the symlink manually, "
|
||||
"then re-run the command."
|
||||
)
|
||||
|
||||
if preserved_user_files:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] Preserved {len(preserved_user_files)} customized shared "
|
||||
"infrastructure file(s) (hash differs from previous install):"
|
||||
)
|
||||
for path in preserved_user_files:
|
||||
console.print(f" {path}")
|
||||
if refresh_hint:
|
||||
console.print(refresh_hint)
|
||||
|
||||
manifest.save()
|
||||
return True
|
||||
|
||||
@@ -322,7 +322,7 @@ class WorkflowCatalog:
|
||||
|
||||
# Fetch from URL — validate scheme before opening and after redirects
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_catalog_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
@@ -337,7 +337,7 @@ class WorkflowCatalog:
|
||||
_validate_catalog_url(entry.url)
|
||||
|
||||
try:
|
||||
with urlopen(entry.url, timeout=30) as resp: # noqa: S310
|
||||
with _open_url(entry.url, timeout=30) as resp:
|
||||
_validate_catalog_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Implementation Plan: [FEATURE]
|
||||
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
|
||||
**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.
|
||||
@@ -17,14 +18,22 @@
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**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]
|
||||
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
**Testing**: [e.g., pytest, XCTest, cargo test 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]
|
||||
|
||||
**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]
|
||||
**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]
|
||||
|
||||
**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]
|
||||
|
||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||
|
||||
## Constitution Check
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
**Created**: [DATE]
|
||||
**Status**: Draft
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
|
||||
**Created**: [DATE]
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
@@ -11,7 +14,7 @@
|
||||
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,
|
||||
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.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
@@ -85,7 +88,7 @@
|
||||
### Functional Requirements
|
||||
|
||||
- **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-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||
|
||||
@@ -6,6 +6,7 @@ description: "Task list template for feature implementation"
|
||||
# Tasks: [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/
|
||||
|
||||
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||
@@ -25,21 +26,21 @@ description: "Task list template for feature implementation"
|
||||
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||
- Paths shown below assume single project - adjust based on plan.md structure
|
||||
|
||||
<!--
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||
|
||||
|
||||
The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
|
||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||
- Feature requirements from plan.md
|
||||
- Entities from data-model.md
|
||||
- Endpoints from contracts/
|
||||
|
||||
|
||||
Tasks MUST be organized by user story so each story can be:
|
||||
- Implemented independently
|
||||
- Tested independently
|
||||
- Delivered as an MVP increment
|
||||
|
||||
|
||||
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
21
tests/auth_helpers.py
Normal file
21
tests/auth_helpers.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Shared test helpers for authentication config injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
|
||||
def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry:
|
||||
"""Build a GitHub ``AuthConfigEntry`` for testing."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token_env=token_env,
|
||||
)
|
||||
|
||||
|
||||
def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None:
|
||||
"""Inject a GitHub auth.json config entry into the auth HTTP module."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)])
|
||||
@@ -66,3 +66,18 @@ requires_bash = pytest.mark.skipif(
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_auth_config(monkeypatch):
|
||||
"""Ensure no test reads the real ~/.specify/auth.json."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [])
|
||||
# Also clear the per-process cache so tests that unset _config_override
|
||||
# won't see a previously cached real-file result.
|
||||
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
||||
|
||||
@@ -320,8 +320,8 @@ class TestInitIntegrationFlag:
|
||||
assert "A new shared manifest will be created" in captured.out
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
|
||||
"""Shared script refreshes must not follow destination symlinks."""
|
||||
def test_shared_infra_buckets_symlinked_script_destination(self, tmp_path, capsys):
|
||||
"""Symlinked script destinations are bucketed with a warning; the symlink target is preserved."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-script-test"
|
||||
@@ -334,14 +334,15 @@ class TestInitIntegrationFlag:
|
||||
scripts_dir.mkdir(parents=True)
|
||||
os.symlink(outside, scripts_dir / "common.sh")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "symlinked shared infrastructure" in captured.out
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
|
||||
"""Shared template installs must not follow destination symlinks."""
|
||||
def test_shared_infra_buckets_symlinked_template_destination(self, tmp_path, capsys):
|
||||
"""Symlinked template destinations are bucketed with a warning; the symlink target is preserved."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-template-test"
|
||||
@@ -354,9 +355,10 @@ class TestInitIntegrationFlag:
|
||||
templates_dir.mkdir(parents=True)
|
||||
os.symlink(outside, templates_dir / "plan-template.md")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "symlinked shared infrastructure" in captured.out
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
@@ -381,7 +383,7 @@ class TestInitIntegrationFlag:
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
|
||||
"""Shared infra directory creation must not follow a symlinked .specify."""
|
||||
"""Shared infra installs must not follow a symlinked .specify directory."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-dir-test"
|
||||
@@ -390,8 +392,10 @@ class TestInitIntegrationFlag:
|
||||
outside.mkdir()
|
||||
os.symlink(outside, project / ".specify")
|
||||
|
||||
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
# Nothing should have been written under the symlinked .specify target.
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
assert not (outside / "scripts").exists()
|
||||
assert not (outside / "templates").exists()
|
||||
@@ -465,8 +469,8 @@ class TestInitIntegrationFlag:
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
|
||||
"""Full shared infra installs validate destinations before writing any file."""
|
||||
def test_shared_infra_install_buckets_unsafe_destinations_and_continues(self, tmp_path):
|
||||
"""Symlinked destinations are bucketed with a warning; safe destinations in the same install still complete."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "preflight-install-test"
|
||||
@@ -486,19 +490,19 @@ class TestInitIntegrationFlag:
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
os.symlink(outside, scripts_dir / "z.sh")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert existing.read_text(encoding="utf-8") == "# old a\n"
|
||||
# Symlinked z.sh is preserved (bucketed); regular a.sh is overwritten.
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
assert existing.read_text(encoding="utf-8") == "# new a\n"
|
||||
|
||||
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
|
||||
"""Nested script source files create safe destination parents at write time."""
|
||||
|
||||
@@ -166,12 +166,12 @@ class TestCatalogFetch:
|
||||
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||
|
||||
def _patch_urlopen(self, monkeypatch, catalog_data):
|
||||
"""Patch urllib.request.urlopen to return *catalog_data*."""
|
||||
"""Patch authentication.http.urllib.request.urlopen to return *catalog_data*."""
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
@@ -185,11 +185,12 @@ class TestCatalogFetch:
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
def fake_urlopen(url, timeout=10):
|
||||
def fake_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
return FakeResponse(catalog_data, url)
|
||||
|
||||
import urllib.request
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
@@ -486,12 +487,12 @@ class TestIntegrationListCatalog:
|
||||
},
|
||||
}
|
||||
|
||||
import urllib.request
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
def read(self):
|
||||
return self._data
|
||||
def geturl(self):
|
||||
@@ -501,7 +502,8 @@ class TestIntegrationListCatalog:
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen",
|
||||
lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url))
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
"""Tests for KiroCliIntegration."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
# 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):
|
||||
KEY = "kiro-cli"
|
||||
FOLDER = ".kiro/"
|
||||
@@ -12,6 +43,85 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
|
||||
REGISTRAR_DIR = ".kiro/prompts"
|
||||
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:
|
||||
"""--ai kiro alias normalizes to kiro-cli and auto-promotes."""
|
||||
|
||||
@@ -901,6 +901,152 @@ class TestIntegrationSwitch:
|
||||
assert shared_script.exists()
|
||||
assert shared_script.read_text(encoding="utf-8") == shared_content
|
||||
|
||||
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
|
||||
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
|
||||
import hashlib
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
bundled_bytes = shared_script.read_bytes()
|
||||
|
||||
# Simulate a stale vendored script: write truncated content as bytes
|
||||
# (write_text would translate \n→\r\n on Windows and break the hash)
|
||||
# and update the speckit manifest hash so the stale copy is treated
|
||||
# as "managed" (installed by spec-kit, not a user customization).
|
||||
stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n"
|
||||
shared_script.write_bytes(stale_bytes)
|
||||
|
||||
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
|
||||
hashlib.sha256(stale_bytes).hexdigest()
|
||||
)
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Stale managed file should be replaced by the bundled version
|
||||
assert shared_script.read_bytes() == bundled_bytes
|
||||
|
||||
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
|
||||
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
|
||||
# User customization: append bytes but do NOT update manifest hash,
|
||||
# so on-disk hash diverges from the recorded one.
|
||||
original = shared_script.read_bytes()
|
||||
custom_bytes = original + b"\n# user customization\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert shared_script.read_bytes() == custom_bytes
|
||||
assert "Preserved" in result.output
|
||||
|
||||
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
|
||||
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
bundled_bytes = shared_script.read_bytes()
|
||||
|
||||
# User customization (hash diverges from manifest)
|
||||
custom_bytes = bundled_bytes + b"\n# user customization\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
"--refresh-shared-infra",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# Customization is overwritten with the bundled version
|
||||
assert shared_script.read_bytes() == bundled_bytes
|
||||
|
||||
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
|
||||
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
|
||||
|
||||
Copilot follow-up on #2375: leaf-only symlink check let writes escape
|
||||
when an *ancestor* directory was symlinked outside the project root.
|
||||
"""
|
||||
import sys
|
||||
if sys.platform.startswith("win"):
|
||||
import pytest as _pytest
|
||||
_pytest.skip("Symlink creation typically requires admin on Windows")
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
bash_dir = project / ".specify" / "scripts" / "bash"
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
for child in bash_dir.iterdir():
|
||||
child.rename(outside / child.name)
|
||||
bash_dir.rmdir()
|
||||
bash_dir.symlink_to(outside, target_is_directory=True)
|
||||
sentinel = (outside / "common.sh").read_bytes()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# Symlinked tree reported, not written through.
|
||||
assert "symlink" in result.output.lower()
|
||||
# Outside dir contents unchanged.
|
||||
assert (outside / "common.sh").read_bytes() == sentinel
|
||||
|
||||
def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path):
|
||||
"""--force (uninstall semantics) must NOT overwrite shared-infra customizations.
|
||||
|
||||
Regression: ensures the decoupling of --force and --refresh-shared-infra.
|
||||
"""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
bundled_bytes = shared_script.read_bytes()
|
||||
|
||||
custom_bytes = bundled_bytes + b"\n# user customization\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# --force alone preserves the customization
|
||||
assert shared_script.read_bytes() == custom_bytes
|
||||
|
||||
def test_switch_from_nothing(self, tmp_path):
|
||||
"""Switch when no integration is installed should just install the target."""
|
||||
project = tmp_path / "bare"
|
||||
|
||||
860
tests/test_authentication.py
Normal file
860
tests/test_authentication.py
Normal file
@@ -0,0 +1,860 @@
|
||||
"""Tests for the authentication provider registry and config-driven HTTP helpers.
|
||||
|
||||
Covers:
|
||||
- Config loading (auth.json parsing, validation, permission warning)
|
||||
- Registry mechanics (_register, get_provider, duplicate/empty-key guards)
|
||||
- GitHubAuth — bearer headers
|
||||
- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers
|
||||
- Host matching (find_entries_for_url)
|
||||
- open_url — config-driven auth with fallthrough and redirect stripping
|
||||
- build_request — single-shot request construction
|
||||
- _fetch_latest_release_tag() delegation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider
|
||||
from specify_cli.authentication.azure_devops import AzureDevOpsAuth
|
||||
from specify_cli.authentication.base import AuthProvider
|
||||
from specify_cli.authentication.config import (
|
||||
AuthConfigEntry,
|
||||
find_entries_for_url,
|
||||
load_auth_config,
|
||||
)
|
||||
from specify_cli.authentication.github import GitHubAuth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry:
|
||||
"""Build a standard GitHub config entry."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token=token,
|
||||
token_env=token_env if token is None else None,
|
||||
)
|
||||
|
||||
|
||||
def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry:
|
||||
"""Build an ADO basic-pat config entry."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("dev.azure.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env=token_env,
|
||||
)
|
||||
|
||||
|
||||
class _StubProvider(AuthProvider):
|
||||
"""Minimal concrete provider for registry mechanics tests."""
|
||||
|
||||
key = "stub-provider"
|
||||
supported_auth_schemes = ("bearer",)
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadAuthConfig:
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
assert load_auth_config(tmp_path / "nonexistent.json") == []
|
||||
|
||||
def test_valid_github_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].provider == "github"
|
||||
assert entries[0].auth == "bearer"
|
||||
assert entries[0].token_env == "GH_TOKEN"
|
||||
|
||||
def test_valid_ado_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "AZURE_DEVOPS_PAT",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].provider == "azure-devops"
|
||||
assert entries[0].auth == "basic-pat"
|
||||
|
||||
def test_inline_token(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token": "ghp_inline_token",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].token == "ghp_inline_token"
|
||||
|
||||
def test_azure_ad_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "tid",
|
||||
"client_id": "cid",
|
||||
"client_secret_env": "SECRET",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].auth == "azure-ad"
|
||||
assert entries[0].tenant_id == "tid"
|
||||
|
||||
def test_azure_cli_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-cli",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].auth == "azure-cli"
|
||||
|
||||
def test_multiple_entries(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [
|
||||
{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"},
|
||||
{"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"},
|
||||
]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 2
|
||||
|
||||
# -- Negative: validation errors --
|
||||
|
||||
def test_invalid_json_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text("not json")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_not_object_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text("[]")
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_missing_providers_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({"foo": "bar"}))
|
||||
with pytest.raises(ValueError, match="providers"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_empty_hosts_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_missing_provider_key_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="provider"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_unsupported_auth_scheme_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_bearer_without_token_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="token"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_azure_ad_missing_fields_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "tid",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="azure-ad"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_unknown_provider_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="unknown provider"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_incompatible_provider_scheme_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "X",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_dangerous_wildcard_host_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_multi_wildcard_host_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_valid_star_dot_host_accepted(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].hosts == ("*.visualstudio.com",)
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows")
|
||||
def test_world_readable_warns(self, tmp_path):
|
||||
import stat
|
||||
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}]
|
||||
}))
|
||||
cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
|
||||
with pytest.warns(UserWarning, match="readable by group"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindEntriesForUrl:
|
||||
def test_exact_match(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://github.com/org/repo", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
def test_wildcard_match(self):
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("*.visualstudio.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env="ADO_PAT",
|
||||
)
|
||||
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://evil.example.com/file", [entry])
|
||||
assert result == []
|
||||
|
||||
def test_no_match_for_lookalike_host(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://github.com.evil.com/file", [entry])
|
||||
assert result == []
|
||||
|
||||
def test_empty_url_returns_empty(self):
|
||||
assert find_entries_for_url("", [_github_entry()]) == []
|
||||
|
||||
def test_empty_entries_returns_empty(self):
|
||||
assert find_entries_for_url("https://github.com/org/repo", []) == []
|
||||
|
||||
def test_multiple_matches_returned(self):
|
||||
e1 = _github_entry(token_env="GH_TOKEN")
|
||||
e2 = _github_entry(token_env="GITHUB_TOKEN")
|
||||
result = find_entries_for_url("https://github.com/org/repo", [e1, e2])
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry mechanics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthRegistry:
|
||||
def test_github_registered(self):
|
||||
assert "github" in AUTH_REGISTRY
|
||||
|
||||
def test_azure_devops_registered(self):
|
||||
assert "azure-devops" in AUTH_REGISTRY
|
||||
|
||||
def test_get_provider_returns_github(self):
|
||||
assert isinstance(get_provider("github"), GitHubAuth)
|
||||
|
||||
def test_get_provider_returns_azure_devops(self):
|
||||
assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth)
|
||||
|
||||
def test_get_provider_unknown_returns_none(self):
|
||||
assert get_provider("does-not-exist") is None
|
||||
|
||||
def test_register_duplicate_raises_key_error(self):
|
||||
class _UniqueStub(_StubProvider):
|
||||
key = "__test_duplicate__"
|
||||
|
||||
try:
|
||||
_register(_UniqueStub())
|
||||
with pytest.raises(KeyError, match="already registered"):
|
||||
_register(_UniqueStub())
|
||||
finally:
|
||||
AUTH_REGISTRY.pop("__test_duplicate__", None)
|
||||
|
||||
def test_register_empty_key_raises_value_error(self):
|
||||
class _EmptyKey(_StubProvider):
|
||||
key = ""
|
||||
|
||||
with pytest.raises(ValueError, match="empty key"):
|
||||
_register(_EmptyKey())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGitHubAuth:
|
||||
def test_bearer_headers(self):
|
||||
assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"}
|
||||
|
||||
def test_unsupported_scheme_raises(self):
|
||||
with pytest.raises(ValueError, match="basic-pat"):
|
||||
GitHubAuth().auth_headers("tok", "basic-pat")
|
||||
|
||||
def test_resolve_token_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "env-token")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) == "env-token"
|
||||
|
||||
def test_resolve_token_inline(self):
|
||||
assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok"
|
||||
|
||||
def test_resolve_token_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " my-token ")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) == "my-token"
|
||||
|
||||
def test_resolve_token_empty_env_returns_none(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) is None
|
||||
|
||||
def test_resolve_token_missing_env_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
assert GitHubAuth().resolve_token(_github_entry()) is None
|
||||
|
||||
def test_key(self):
|
||||
assert GitHubAuth.key == "github"
|
||||
|
||||
def test_supported_schemes(self):
|
||||
assert GitHubAuth.supported_auth_schemes == ("bearer",)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AzureDevOpsAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAzureDevOpsAuth:
|
||||
def test_basic_pat_headers(self):
|
||||
headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat")
|
||||
encoded = base64.b64encode(b":my-pat").decode("ascii")
|
||||
assert headers == {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
def test_basic_pat_format(self):
|
||||
header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"]
|
||||
raw = base64.b64decode(header[len("Basic "):]).decode("ascii")
|
||||
assert raw == ":test-pat"
|
||||
|
||||
def test_bearer_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_azure_cli_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_azure_ad_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_unsupported_scheme_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
AzureDevOpsAuth().auth_headers("tok", "ntlm")
|
||||
|
||||
def test_resolve_token_basic_pat(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat")
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
|
||||
|
||||
def test_resolve_token_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ")
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
|
||||
|
||||
def test_resolve_token_missing_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False)
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None
|
||||
|
||||
def test_key(self):
|
||||
assert AzureDevOpsAuth.key == "azure-devops"
|
||||
|
||||
def test_supported_schemes(self):
|
||||
schemes = AzureDevOpsAuth.supported_auth_schemes
|
||||
assert "basic-pat" in schemes
|
||||
assert "bearer" in schemes
|
||||
assert "azure-cli" in schemes
|
||||
assert "azure-ad" in schemes
|
||||
|
||||
def test_resolve_token_azure_cli_success(self):
|
||||
"""azure-cli acquires token via az CLI."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = '{"accessToken": "cli-acquired-token"}'
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_cli_failure_returns_none(self):
|
||||
"""azure-cli returns None when az CLI fails."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 1
|
||||
result.stdout = ""
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_cli_not_installed_returns_none(self):
|
||||
"""azure-cli returns None when az is not installed."""
|
||||
from unittest.mock import patch
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_success(self, monkeypatch):
|
||||
"""azure-ad acquires token via OAuth2 client credentials."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None when client secret env var is missing."""
|
||||
monkeypatch.delenv("MY_SECRET", raising=False)
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None on network errors."""
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
with patch("urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("connection refused")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_url / build_request — positive tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticatedHttp:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def test_build_request_attaches_auth_for_matching_host(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://github.com/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_build_request_no_auth_for_non_matching_host(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://evil.example.com/file")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_build_request_no_auth_when_no_config(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
self._set_config(monkeypatch, [])
|
||||
req = build_request("https://github.com/org/repo")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_build_request_extra_headers(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"})
|
||||
assert req.get_header("Accept") == "application/json"
|
||||
assert req.get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_open_url_attaches_auth_for_matching_host(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener.open.side_effect = fake_open
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
open_url("https://github.com/org/repo/catalog.json")
|
||||
assert captured["req"].get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_open_url_no_auth_for_non_matching_host(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://example.com/file.json")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
def test_open_url_no_auth_when_no_config(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
def test_open_url_falls_through_on_401(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "bad-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
call_count = 0
|
||||
def fake_side_effect(req, timeout=None):
|
||||
nonlocal call_count; call_count += 1
|
||||
if call_count == 1:
|
||||
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
|
||||
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_url — negative tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticatedHttpNegative:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def test_500_raises_immediately(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "tok")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None)
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with pytest.raises(urllib.error.HTTPError, match="500"):
|
||||
open_url("https://github.com/org/repo")
|
||||
|
||||
def test_404_raises_immediately(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "tok")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with pytest.raises(urllib.error.HTTPError, match="404"):
|
||||
open_url("https://github.com/org/repo")
|
||||
|
||||
def test_urlerror_propagates(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("refused")):
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
def test_timeout_propagates(self, monkeypatch):
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=socket.timeout("timed out")):
|
||||
with pytest.raises(socket.timeout):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_config caching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadConfigCaching:
|
||||
def test_config_cached_after_first_load(self, monkeypatch):
|
||||
"""_load_config() should call load_auth_config only once per process."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
# Allow the real load path (no override)
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
entry = _github_entry()
|
||||
call_count = 0
|
||||
|
||||
def fake_load(path=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return [entry]
|
||||
|
||||
with patch.object(_mod, "load_auth_config", side_effect=fake_load):
|
||||
_mod._load_config()
|
||||
_mod._load_config()
|
||||
_mod._load_config()
|
||||
|
||||
assert call_count == 1
|
||||
|
||||
def test_cache_bypassed_by_override(self, monkeypatch):
|
||||
"""When _config_override is set, the cache is ignored entirely."""
|
||||
from specify_cli.authentication import http as _mod
|
||||
sentinel = [_github_entry()]
|
||||
monkeypatch.setattr(_mod, "_config_override", sentinel)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
result = _mod._load_config()
|
||||
assert result is sentinel
|
||||
# Cache must not have been populated when override is active
|
||||
assert _mod._config_cache is None
|
||||
|
||||
def test_failed_load_warns_once_and_caches_empty(self, monkeypatch):
|
||||
"""A bad auth.json emits exactly one warning and subsequent calls use cache."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
import warnings as _warnings
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fail_load(path=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("bad config")
|
||||
|
||||
with patch.object(_mod, "load_auth_config", side_effect=fail_load):
|
||||
with _warnings.catch_warnings(record=True) as w:
|
||||
_warnings.simplefilter("always")
|
||||
result1 = _mod._load_config()
|
||||
result2 = _mod._load_config()
|
||||
result3 = _mod._load_config()
|
||||
|
||||
user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
|
||||
assert len(user_warnings) == 1, "Expected exactly one warning"
|
||||
# Loader called only once — subsequent calls used cache
|
||||
assert call_count == 1
|
||||
# All calls returned the cached empty list
|
||||
assert result1 == result2 == result3 == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redirect stripping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRedirectStripping:
|
||||
def test_redirect_within_hosts_preserves_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com", "codeload.github.com"))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://codeload.github.com/org/repo/zip")
|
||||
assert new_req is not None
|
||||
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth == "Bearer tok"
|
||||
|
||||
def test_redirect_outside_hosts_strips_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://objects.githubusercontent.com/asset")
|
||||
assert new_req is not None
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
|
||||
"""Auth survives a multi-hop redirect chain within allowed hosts."""
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com")
|
||||
handler = _StripAuthOnRedirect(hosts)
|
||||
|
||||
# First hop: github.com → codeload.github.com
|
||||
req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://codeload.github.com/org/repo/zip")
|
||||
assert req2 is not None
|
||||
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization")
|
||||
assert auth2 == "Bearer tok"
|
||||
|
||||
# Second hop: codeload.github.com → objects-origin.githubusercontent.com
|
||||
req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://objects-origin.githubusercontent.com/asset")
|
||||
assert req3 is not None
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchLatestReleaseTagDelegation:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def _capture_request(self):
|
||||
import json as _json
|
||||
from unittest.mock import MagicMock
|
||||
captured: dict = {}
|
||||
def side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
|
||||
resp = MagicMock(); resp.read.return_value = body
|
||||
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
|
||||
return cm
|
||||
return captured, side_effect
|
||||
|
||||
def test_gh_token_forwarded_when_configured(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") is None
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
|
||||
@@ -2453,6 +2453,10 @@ class TestExtensionCatalog:
|
||||
(project_dir / ".specify").mkdir()
|
||||
return ExtensionCatalog(project_dir)
|
||||
|
||||
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
|
||||
"""Without a token, requests carry no Authorization header."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
@@ -2473,6 +2477,7 @@ class TestExtensionCatalog:
|
||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||
@@ -2481,6 +2486,7 @@ class TestExtensionCatalog:
|
||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -2489,49 +2495,40 @@ class TestExtensionCatalog:
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||
|
||||
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||
def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch):
|
||||
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||
|
||||
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
|
||||
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||
def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch):
|
||||
"""Auth is NOT attached to hosts not listed in auth.json."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch):
|
||||
"""No auth header when no auth.json config exists."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the URL path."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the query string."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip")
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for api.github.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -2539,49 +2536,17 @@ class TestExtensionCatalog:
|
||||
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_redirect_preserves_auth_for_github_to_codeload(self):
|
||||
"""Auth header is preserved when GitHub redirects to codeload.github.com."""
|
||||
from specify_cli._github_http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect()
|
||||
original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip"
|
||||
redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1"
|
||||
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
|
||||
fp = io.BytesIO(b"")
|
||||
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
|
||||
assert new_req is not None
|
||||
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth == "Bearer ghp_test"
|
||||
|
||||
def test_redirect_strips_auth_for_github_to_external(self):
|
||||
"""Auth header is stripped when GitHub redirects to a non-GitHub host."""
|
||||
from specify_cli._github_http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect()
|
||||
original_url = "https://github.com/org/repo/releases/download/v1/asset.zip"
|
||||
redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345"
|
||||
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
|
||||
fp = io.BytesIO(b"")
|
||||
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
|
||||
assert new_req is not None
|
||||
auth_header = new_req.headers.get("Authorization")
|
||||
auth_unredirected = new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth_header is None
|
||||
assert auth_unredirected is None
|
||||
|
||||
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||
"""_fetch_single_catalog passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
catalog_data = {"schema_version": "1.0", "extensions": {}}
|
||||
@@ -2589,6 +2554,7 @@ class TestExtensionCatalog:
|
||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json"
|
||||
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
@@ -2606,17 +2572,18 @@ class TestExtensionCatalog:
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header via opener for GitHub URLs."""
|
||||
"""download_extension passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import zipfile, io
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
# Build a minimal valid ZIP in memory
|
||||
@@ -2631,7 +2598,6 @@ class TestExtensionCatalog:
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = {}
|
||||
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
@@ -2648,7 +2614,7 @@ class TestExtensionCatalog:
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
@@ -1224,6 +1224,10 @@ class TestExtensionPriorityResolution:
|
||||
class TestPresetCatalog:
|
||||
"""Test template catalog functionality."""
|
||||
|
||||
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
def test_default_catalog_url(self, project_dir):
|
||||
"""Test default catalog URL."""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
@@ -1418,6 +1422,7 @@ class TestPresetCatalog:
|
||||
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", " ")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_fallback"
|
||||
@@ -1426,6 +1431,7 @@ class TestPresetCatalog:
|
||||
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -1434,58 +1440,50 @@ class TestPresetCatalog:
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
|
||||
|
||||
def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
|
||||
def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch):
|
||||
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
|
||||
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://api.github.com/repos/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_primary"
|
||||
|
||||
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
|
||||
"""GITHUB_TOKEN is attached for codeload.github.com URLs."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
|
||||
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch):
|
||||
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
|
||||
def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch):
|
||||
"""Auth is NOT attached to hosts not listed in auth.json."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://internal.example.com/catalog.json")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached to hosts that include github.com as a suffix."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch):
|
||||
"""No auth header when no auth.json config exists."""
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the URL path."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch):
|
||||
"""Auth header is not attached when github.com appears only in the query string."""
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip")
|
||||
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
|
||||
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
|
||||
"""_fetch_single_catalog passes Authorization header when configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
catalog_data = {"schema_version": "1.0", "presets": {}}
|
||||
@@ -1493,6 +1491,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(catalog_data).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json"
|
||||
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
@@ -1510,16 +1509,17 @@ class TestPresetCatalog:
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
|
||||
"""download_pack passes Authorization header via opener for GitHub URLs."""
|
||||
"""download_pack passes Authorization header when configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
import io
|
||||
@@ -1551,7 +1551,7 @@ class TestPresetCatalog:
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||
patch("urllib.request.build_opener", return_value=mock_opener):
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
@@ -1949,7 +1949,16 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1
|
||||
|
||||
|
||||
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):
|
||||
"""Verify the self-test preset directory and manifest exist."""
|
||||
@@ -2237,7 +2246,12 @@ class TestInitOptions:
|
||||
|
||||
|
||||
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"):
|
||||
from specify_cli import save_init_options
|
||||
|
||||
@@ -23,7 +23,6 @@ from specify_cli import (
|
||||
_normalize_tag,
|
||||
app,
|
||||
)
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -31,6 +30,10 @@ runner = CliRunner()
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
_RATE_LIMITED_REASON = (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
|
||||
|
||||
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
@@ -66,11 +69,20 @@ class TestSelfUpgradeStub:
|
||||
]
|
||||
|
||||
def test_stub_makes_no_network_call(self):
|
||||
# If the stub ever starts calling urllib, this patch's side_effect
|
||||
# would fire and the assertion below would fail.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
side_effect=AssertionError("stub must not hit the network"),
|
||||
# The stub must not hit the network via either urllib path:
|
||||
# unauthenticated requests use urlopen() directly; authenticated ones
|
||||
# go through build_opener(...).open(). Both are patched so that any
|
||||
# accidental network call raises immediately.
|
||||
network_error = AssertionError("stub must not hit the network")
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=network_error,
|
||||
),
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
side_effect=network_error,
|
||||
),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
@@ -138,7 +150,7 @@ class TestNormalizeTag:
|
||||
class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -151,7 +163,7 @@ class TestUserStory1:
|
||||
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -163,7 +175,7 @@ class TestUserStory1:
|
||||
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -174,7 +186,7 @@ class TestUserStory1:
|
||||
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -186,7 +198,7 @@ class TestUserStory1:
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -200,7 +212,7 @@ class TestUserStory1:
|
||||
class TestFailureCategorization:
|
||||
def test_urlerror_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("no route to host"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -209,7 +221,7 @@ class TestFailureCategorization:
|
||||
|
||||
def test_timeout_maps_to_offline(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=TimeoutError(),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -218,17 +230,17 @@ class TestFailureCategorization:
|
||||
|
||||
def test_403_maps_to_rate_limited(self):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=_http_error(403, "rate limited"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
assert tag is None
|
||||
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
|
||||
assert reason == _RATE_LIMITED_REASON
|
||||
|
||||
@pytest.mark.parametrize("code", [404, 500, 502])
|
||||
def test_other_http_uses_code_string(self, code):
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=_http_error(code, "oops"),
|
||||
):
|
||||
tag, reason = _fetch_latest_release_tag()
|
||||
@@ -238,7 +250,7 @@ class TestFailureCategorization:
|
||||
def test_generic_exception_propagates(self):
|
||||
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
|
||||
with patch(
|
||||
"specify_cli.urllib.request.urlopen",
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
@@ -247,7 +259,7 @@ class TestFailureCategorization:
|
||||
|
||||
_FAILURE_CASES = [
|
||||
("offline or timeout", urllib.error.URLError("down")),
|
||||
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
|
||||
(_RATE_LIMITED_REASON, _http_error(403)),
|
||||
("HTTP 500", _http_error(500)),
|
||||
]
|
||||
|
||||
@@ -258,22 +270,21 @@ class TestUserStory2:
|
||||
self, expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert "Installed: 0.7.4" in output
|
||||
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
|
||||
if expected_reason == _RATE_LIMITED_REASON:
|
||||
assert "Could not check latest release: rate limited" in output
|
||||
assert "GH_TOKEN" in output
|
||||
assert "GITHUB_TOKEN" in output
|
||||
assert "~/.specify/auth.json" in output
|
||||
else:
|
||||
assert f"Could not check latest release: {expected_reason}" in output
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
assert result.exit_code == 0
|
||||
@@ -283,7 +294,7 @@ class TestUserStory2:
|
||||
self, _expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = (result.output or "") + (result.stderr or "")
|
||||
@@ -302,12 +313,20 @@ def _capture_request_via_urlopen():
|
||||
return captured, _side_effect
|
||||
|
||||
|
||||
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
|
||||
|
||||
class TestUserStory3:
|
||||
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||
@@ -315,8 +334,11 @@ class TestUserStory3:
|
||||
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -325,7 +347,7 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -333,8 +355,9 @@ class TestUserStory3:
|
||||
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -342,8 +365,9 @@ class TestUserStory3:
|
||||
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") is None
|
||||
@@ -351,8 +375,11 @@ class TestUserStory3:
|
||||
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -364,7 +391,7 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
@@ -377,7 +404,7 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.urllib.request.urlopen", side_effect=side_effect
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
combined = strip_ansi((result.output or "") + (result.stderr or ""))
|
||||
|
||||
Reference in New Issue
Block a user