mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
217730a8cd | ||
|
|
33fefde268 | ||
|
|
70f9242be9 | ||
|
|
7c1d4212db | ||
|
|
4f5c4971c0 | ||
|
|
13b8db2d87 | ||
|
|
68980c9a4e | ||
|
|
1b0556c711 | ||
|
|
f2710f32cf | ||
|
|
4384338ec1 | ||
|
|
dd9d84e7bc | ||
|
|
77af08ba22 | ||
|
|
f5d47720b9 | ||
|
|
4e899d3002 | ||
|
|
63a2a17305 | ||
|
|
36ad3cde1b | ||
|
|
5ae7ff53d0 | ||
|
|
902b98654d | ||
|
|
40e48ed22c | ||
|
|
45b88f62be | ||
|
|
7c610a38cd | ||
|
|
a72ba95460 | ||
|
|
fa93572e27 | ||
|
|
0b82a1ddf1 | ||
|
|
d3f872f484 | ||
|
|
8373a60107 | ||
|
|
9c4fa31cec | ||
|
|
de88c23bb6 | ||
|
|
f65d9f9382 | ||
|
|
ad9f047aaa |
@@ -70,6 +70,8 @@ Use the existing entries as the format template. Required fields:
|
||||
"documentation": "<documentation>",
|
||||
"changelog": "<changelog>",
|
||||
"license": "<license>",
|
||||
"category": "<category>",
|
||||
"effect": "<effect>",
|
||||
"requires": {
|
||||
"speckit_version": "<speckit_version>"
|
||||
},
|
||||
@@ -87,6 +89,9 @@ Use the existing entries as the format template. Required fields:
|
||||
}
|
||||
```
|
||||
|
||||
**Category** — free-form string; common values: `docs`, `code`, `process`, `integration`, `visibility`
|
||||
**Effect** — one of: `read-only`, `read-write`
|
||||
|
||||
If the extension has optional tool dependencies, add a `"tools"` array inside `"requires"`:
|
||||
|
||||
```json
|
||||
@@ -113,8 +118,8 @@ Determine the category and effect from the extension's behavior:
|
||||
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
|
||||
```
|
||||
|
||||
**Category** — one of: `docs`, `code`, `process`, `integration`, `visibility`
|
||||
**Effect** — `Read-only` (produces reports only) or `Read+Write` (modifies project files)
|
||||
**Category** — free-form; common values: `docs`, `code`, `process`, `integration`, `visibility`
|
||||
**Effect** — write canonical values `read-only` or `read-write` in `extension.yml` and `catalog.community.json`; use `Read-only`/`Read+Write` only for the docs table display
|
||||
|
||||
### 6. Commit, push, and open PR
|
||||
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -2,6 +2,64 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.10.3] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Superpowers Bridge extension to v1.6.0 (#2998)
|
||||
- Add Improve Extension to community catalog (#2997)
|
||||
- Update Product Forge extension to v1.7.0 (#2996)
|
||||
- Update Linear Integration extension to v0.5.0 (#2995)
|
||||
- Update Superpowers Implementation Bridge extension to v1.0.3 (#2993)
|
||||
- Update Ralph community extension to v1.1.1 (#2861)
|
||||
- Update Linear Integration extension to v0.4.0 (#2942)
|
||||
- Update DocGuard — CDD Enforcement to v0.26.0 (#2941)
|
||||
- Add SpecKit Companion extension to community catalog (#2937)
|
||||
- chore: release 0.10.2, begin 0.10.3.dev0 development (#2936)
|
||||
|
||||
## [0.10.2] - 2026-06-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Add Research Harness extension to community catalog (#2935)
|
||||
- Add Coding Standards Drift Control extension to community catalog (#2934)
|
||||
- Add Spec Trace extension to community catalog (#2527)
|
||||
- fix(extensions): preserve argument-hint in extension Claude SKILL.md (#2916)
|
||||
- fix(presets): harden preset URL installs against unsafe redirects (#2911)
|
||||
- fix: skip recovered files during refresh_managed overwrite check (#2918) (#2919)
|
||||
- Update multi-model-review extension to v0.1.1 (#2900)
|
||||
- feat: add category and effect as first-class fields in extension schema (#2899)
|
||||
- chore(catalog): add Jira Integration (Sync Engine) extension (#2895)
|
||||
- chore: release 0.10.1, begin 0.10.2.dev0 development (#2910)
|
||||
|
||||
## [0.10.1] - 2026-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Update DocGuard — CDD Enforcement extension to v0.25.1 (#2909)
|
||||
- Update a11y-governance preset to v0.3.0 (#2867)
|
||||
- docs: document spec persistence models (#2856)
|
||||
- chore(catalog): bump Linear Integration to v0.3.0 (repo renamed to spec-kit-linear-sync) (#2893)
|
||||
- chore: update DocGuard extension to v0.25.0 (#2707)
|
||||
- chore: remove unused open_github_url/_StripAuthOnRedirect from _github_http.py (#2883)
|
||||
- fix(catalogs): validate extension and preset catalog payload shape (#2621)
|
||||
- feat(integration): add status reporting (#2674)
|
||||
- chore: release 0.10.0, begin 0.10.1.dev0 development (#2904)
|
||||
|
||||
## [0.10.0] - 2026-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
- feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
|
||||
- [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
|
||||
- test(workflows): cover executable override fallback preflight (#2843)
|
||||
- Add GitHub Copilot CLI guidance to readme (#2891)
|
||||
- Update Security Review extension to v1.5.3 (#2898)
|
||||
- Update Architecture Guard extension to v1.8.17 (#2897)
|
||||
- feat(extensions): per-event hook lists with priority ordering (#2798)
|
||||
- feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
|
||||
- chore: release 0.9.5, begin 0.9.6.dev0 development (#2875)
|
||||
|
||||
## [0.9.5] - 2026-06-05
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):
|
||||
|
||||
**Categories:**
|
||||
**Categories** (common values, but any string is allowed):
|
||||
|
||||
- `docs` — reads, validates, or generates spec artifacts
|
||||
- `code` — reviews, validates, or modifies source code
|
||||
@@ -15,10 +15,13 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
- `integration` — syncs with external platforms
|
||||
- `visibility` — reports on project health or progress
|
||||
|
||||
**Effect:**
|
||||
**Effect** (canonical `extension.yml`/catalog values):
|
||||
|
||||
- `Read-only` — produces reports without modifying files
|
||||
- `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||
- `read-only` — produces reports without modifying files (displayed as `Read-only` in the table)
|
||||
- `read-write` — modifies files, creates artifacts, or updates specs (displayed as `Read+Write` in the table)
|
||||
|
||||
> [!TIP]
|
||||
> Extension authors can declare `category` and `effect` in their `extension.yml` under the `extension:` block. These fields are also available in `catalog.community.json` for tooling and the CLI (`specify extension info`).
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
@@ -41,22 +44,25 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
|
||||
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
|
||||
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
|
||||
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear](https://github.com/ashbrener/spec-kit-linear) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
||||
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
|
||||
@@ -79,7 +85,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Product Forge | Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Forge | Full product-lifecycle orchestrator for Spec Kit: research → product-spec → plan → tasks → implement → verify → test → release-readiness, across express/lite/standard/v-model modes with human-in-the-loop gates. | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
@@ -88,6 +94,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Research Harness | State-externalizing research harness: budgeted exploration, evidence curation, and claim verification for spec-driven development | `process` | Read+Write | [spec-kit-harness](https://github.com/formin/spec-kit-harness) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
| Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) |
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
@@ -107,13 +114,15 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
|
||||
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
|
||||
@@ -7,7 +7,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, inclusive-content guidance, and didactic inline-code-comment review | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Keeps shared AI-agent instructions aligned and adds agent-neutral Spec Kit model-routing guidance across project-defined agent guidance surfaces | 9 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
|
||||
@@ -11,6 +11,11 @@ Spec-Driven Development is a structured process that emphasizes:
|
||||
- **Multi-step refinement** rather than one-shot code generation from prompts
|
||||
- **Heavy reliance** on advanced AI model capabilities for specification interpretation
|
||||
|
||||
Spec Kit does not prescribe how teams preserve or mutate `spec.md`, `plan.md`,
|
||||
and `tasks.md` after requirements change. See
|
||||
[Spec Persistence Models](spec-persistence.md) for three common ways to manage
|
||||
those artifacts over time.
|
||||
|
||||
## Development Phases
|
||||
|
||||
| Phase | Focus | Key Activities |
|
||||
|
||||
107
docs/concepts/spec-persistence.md
Normal file
107
docs/concepts/spec-persistence.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Spec Persistence Models
|
||||
|
||||
Spec Kit intentionally leaves teams in control of what happens to `spec.md`,
|
||||
`plan.md`, and `tasks.md` after requirements change. The toolkit gives you a
|
||||
repeatable workflow, but it does not force one artifact maintenance strategy.
|
||||
|
||||
This page names three common models so teams can make that choice explicit.
|
||||
None is the default, and none is required by Spec Kit.
|
||||
|
||||
## Two Separate Questions
|
||||
|
||||
Spec-driven development has a temporal question: how long should the
|
||||
specification matter? One
|
||||
[overview of SDD tooling](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)
|
||||
frames that lifecycle in three levels:
|
||||
|
||||
- **Spec-first**: write a spec before coding, then allow it to be discarded.
|
||||
- **Spec-anchored**: keep the spec after implementation and use it for future
|
||||
changes.
|
||||
- **Spec-as-source**: treat the spec as the only human-edited source and
|
||||
regenerate implementation artifacts from it.
|
||||
|
||||
Spec Kit also exposes a second question: what happens to the artifact set when
|
||||
requirements change? The models below describe that mutation strategy.
|
||||
|
||||
## Flow-Back Spec
|
||||
|
||||
Use flow-back when `spec.md`, `plan.md`, `tasks.md`, and the implementation are
|
||||
all allowed to inform each other.
|
||||
|
||||
In this model, edits can begin in any artifact. A developer might update
|
||||
`tasks.md` during implementation, revise `plan.md` after a technical discovery,
|
||||
or adjust `spec.md` after a product clarification. The team then reconciles the
|
||||
artifact set manually so the final project history still makes sense.
|
||||
|
||||
Flow-back works well when:
|
||||
|
||||
- the team is small enough to notice and reconcile drift quickly
|
||||
- implementation discoveries are expected to reshape the original plan
|
||||
- speed matters more than preserving each intermediate decision as immutable
|
||||
history
|
||||
|
||||
The main risk is silent divergence. If the team changes lower-level artifacts
|
||||
without reflecting the decision back into `spec.md`, future contributors may
|
||||
not know which artifact to trust.
|
||||
|
||||
## Flow-Forward Spec
|
||||
|
||||
Use flow-forward when each feature directory should remain a historical record.
|
||||
|
||||
In this model, completed artifacts are treated as immutable. When requirements
|
||||
change, the team creates a new feature directory instead of mutating the
|
||||
existing `spec.md`, `plan.md`, or `tasks.md`. The older directory remains useful
|
||||
for audit, comparison, or explaining how the project reached its current state.
|
||||
|
||||
Flow-forward works well when:
|
||||
|
||||
- auditability and traceability matter
|
||||
- features are well-scoped and rarely revisited in place
|
||||
- the team wants a clear sequence of requirement changes over time
|
||||
|
||||
The main tradeoff is duplication. Related decisions can be spread across
|
||||
multiple feature directories, so teams need naming, linking, or review habits
|
||||
that make the lineage easy to follow.
|
||||
|
||||
## Living Spec
|
||||
|
||||
Use living spec when `spec.md` is the contract and the other artifacts are
|
||||
derived from it.
|
||||
|
||||
In this model, teams update `spec.md` first and then regenerate or revise
|
||||
`plan.md` and `tasks.md` from that source. The plan and task list are still
|
||||
valuable, but they are treated as disposable derivations rather than permanent
|
||||
sources of truth.
|
||||
|
||||
Living spec works well when:
|
||||
|
||||
- the product contract is stable enough to own the workflow
|
||||
- the team is comfortable regenerating derived artifacts after spec changes
|
||||
- consistency between requirements and implementation matters more than keeping
|
||||
every intermediate plan intact
|
||||
|
||||
The main risk is losing useful implementation rationale if derived artifacts are
|
||||
discarded without preserving important decisions elsewhere.
|
||||
|
||||
## Choosing a Model
|
||||
|
||||
The model is a team convention, not a CLI setting. A project can even use
|
||||
different models in different areas, as long as contributors know which one
|
||||
applies.
|
||||
|
||||
| Model | Mutation rule | Best fit | Watch out for |
|
||||
|---|---|---|---|
|
||||
| Flow-back spec | Edit any artifact, then reconcile | Fast iteration and close collaboration | Silent drift between artifacts |
|
||||
| Flow-forward spec | Create a new feature directory for new requirements | Audit trails and historical clarity | Duplicate or fragmented context |
|
||||
| Living spec | Edit `spec.md`; regenerate derived artifacts | Spec as contract | Lost rationale in regenerated files |
|
||||
|
||||
If your team has not chosen a model yet, start by answering two questions:
|
||||
|
||||
1. Should completed feature directories be historical records or editable work
|
||||
areas?
|
||||
2. Is `spec.md` the single source of truth, or are `plan.md` and `tasks.md`
|
||||
allowed to become co-equal sources?
|
||||
|
||||
Once those answers are clear, document the convention in your project
|
||||
constitution or team onboarding notes so future contributors know how to handle
|
||||
changes.
|
||||
@@ -126,6 +126,27 @@ specify integration upgrade [<key>]
|
||||
|
||||
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
|
||||
|
||||
## Report Integration Status
|
||||
|
||||
```bash
|
||||
specify integration status
|
||||
specify integration status --json
|
||||
```
|
||||
|
||||
Reports the current project's integration status without changing files. The
|
||||
status report includes the default integration, installed integrations,
|
||||
multi-install safety, missing managed files, modified managed files, invalid
|
||||
manifest paths, shared Spec Kit infrastructure health, unchecked manifests, and
|
||||
the target integration for default-sensitive shared templates. The JSON form is
|
||||
intended for CI and coding agents that need stable machine-readable status data;
|
||||
it also reports the raw recorded integrations and the integration manifests that
|
||||
were checked when state repair heuristics differ from the recorded file.
|
||||
The command exits 0 when the report status is `ok` or `warning`; it exits 1
|
||||
only when the report status is `error`. In JSON output, `multi_install_safe`
|
||||
is `null` when no installed integration set can be evaluated, such as when the
|
||||
integration state is missing, unreadable, lacks a valid recorded integration
|
||||
list, or records no installed integrations.
|
||||
|
||||
## Integration-Specific Options
|
||||
|
||||
Some integrations accept additional options via `--integration-options`:
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
items:
|
||||
- name: What is SDD?
|
||||
href: concepts/sdd.md
|
||||
- name: Spec Persistence Models
|
||||
href: concepts/spec-persistence.md
|
||||
|
||||
# Development workflows
|
||||
- name: Development
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,14 @@ extension:
|
||||
# CUSTOMIZE: Brief description (under 200 characters)
|
||||
description: "Brief description of what your extension does"
|
||||
|
||||
# CUSTOMIZE: Extension category — describes what the extension operates on
|
||||
# Common values: docs, code, process, integration, visibility
|
||||
# category: "process"
|
||||
|
||||
# CUSTOMIZE: Extension effect — whether it modifies project files
|
||||
# One of: read-only | read-write
|
||||
# effect: "read-write"
|
||||
|
||||
# CUSTOMIZE: Your name or organization name
|
||||
author: "Your Name"
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-03T00:00:00Z",
|
||||
"updated_at": "2026-06-05T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.",
|
||||
"version": "0.3.0",
|
||||
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, and didactic inline-code-comment review to Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -18,7 +18,7 @@
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
"templates": 10,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -29,7 +29,7 @@
|
||||
"inclusion"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
"updated_at": "2026-06-05T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.9.6.dev0"
|
||||
version = "0.10.3"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -684,16 +684,44 @@ def preset_add(
|
||||
|
||||
elif from_url:
|
||||
# Validate URL scheme before downloading
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
_parsed = _urlparse(from_url)
|
||||
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
|
||||
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
|
||||
|
||||
def _is_allowed_download_url(parsed_url):
|
||||
host = parsed_url.hostname
|
||||
if not host:
|
||||
return False
|
||||
is_loopback = host == "localhost"
|
||||
if not is_loopback:
|
||||
try:
|
||||
is_loopback = ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
|
||||
pass
|
||||
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
|
||||
|
||||
def _validate_download_redirect(old_url, new_url):
|
||||
if not _is_allowed_download_url(_urlparse(new_url)):
|
||||
import urllib.error
|
||||
|
||||
raise urllib.error.URLError(
|
||||
"redirect target must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback"
|
||||
)
|
||||
|
||||
if not _is_allowed_download_url(_parsed):
|
||||
console.print(
|
||||
"[red]Error:[/red] URL must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
@@ -707,8 +735,25 @@ def preset_add(
|
||||
from_url = _resolved_from_url
|
||||
_preset_extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
with _open_url(from_url, timeout=60, extra_headers=_preset_extra_headers) as response:
|
||||
zip_path.write_bytes(response.read())
|
||||
with _open_url(
|
||||
from_url,
|
||||
timeout=60,
|
||||
extra_headers=_preset_extra_headers,
|
||||
redirect_validator=_validate_download_redirect,
|
||||
) as response:
|
||||
final_url = response.geturl() if hasattr(response, "geturl") else from_url
|
||||
if not _is_allowed_download_url(_urlparse(final_url)):
|
||||
console.print(
|
||||
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
|
||||
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
with zip_path.open("wb") as output:
|
||||
try:
|
||||
shutil.copyfileobj(response, output)
|
||||
except TypeError:
|
||||
output.write(response.read())
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||
raise typer.Exit(1)
|
||||
@@ -1186,7 +1231,7 @@ def preset_catalog_add(
|
||||
})
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
|
||||
install_label = "install allowed" if install_allowed else "discovery only"
|
||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||
@@ -1226,7 +1271,7 @@ def preset_catalog_remove(
|
||||
raise typer.Exit(1)
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
|
||||
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||
if not catalogs:
|
||||
@@ -1975,7 +2020,11 @@ def extension_info(
|
||||
author = ext_manifest.data.get("extension", {}).get("author")
|
||||
if author:
|
||||
console.print(f"[dim]Author:[/dim] {author}")
|
||||
console.print()
|
||||
if ext_manifest.category:
|
||||
console.print(f"[dim]Category:[/dim] {ext_manifest.category}")
|
||||
if ext_manifest.effect:
|
||||
console.print(f"[dim]Effect:[/dim] {ext_manifest.effect}")
|
||||
console.print()
|
||||
|
||||
if ext_manifest.commands:
|
||||
console.print("[bold]Commands:[/bold]")
|
||||
@@ -2025,6 +2074,12 @@ def _print_extension_info(ext_info: dict, manager):
|
||||
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
|
||||
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
|
||||
|
||||
# Category and Effect
|
||||
if ext_info.get('category'):
|
||||
console.print(f"[dim]Category:[/dim] {ext_info['category']}")
|
||||
if ext_info.get('effect'):
|
||||
console.print(f"[dim]Effect:[/dim] {ext_info['effect']}")
|
||||
|
||||
# Source catalog
|
||||
if ext_info.get("_catalog_name"):
|
||||
install_allowed = ext_info.get("_install_allowed", True)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Shared GitHub-authenticated HTTP helpers.
|
||||
"""Shared GitHub HTTP request helpers.
|
||||
|
||||
Used by both ExtensionCatalog and PresetCatalog to attach
|
||||
GITHUB_TOKEN / GH_TOKEN credentials to requests targeting
|
||||
GitHub-hosted domains, while preventing token leakage to
|
||||
third-party hosts on redirects.
|
||||
Provides ``build_github_request()`` for attaching GITHUB_TOKEN / GH_TOKEN
|
||||
credentials to requests targeting GitHub-hosted domains, and
|
||||
``resolve_github_release_asset_api_url()`` — used by extensions, presets,
|
||||
and workflow URL resolution — to translate browser release-download URLs
|
||||
into GitHub REST API asset URLs. Authenticated downloads themselves go
|
||||
through the config-driven helpers in :mod:`specify_cli.authentication.http`.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -54,28 +56,6 @@ def build_github_request(url: str) -> urllib.request.Request:
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Redirect handler that drops the Authorization header when leaving GitHub.
|
||||
|
||||
Prevents token leakage to CDNs or other third-party hosts that GitHub
|
||||
may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com).
|
||||
Auth is preserved as long as the redirect target remains within GITHUB_HOSTS.
|
||||
"""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
original_auth = req.get_header("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 GITHUB_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 resolve_github_release_asset_api_url(
|
||||
download_url: str,
|
||||
open_url_fn: Callable,
|
||||
@@ -147,20 +127,3 @@ def resolve_github_release_asset_api_url(
|
||||
return str(asset["url"])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def open_github_url(url: str, timeout: int = 10):
|
||||
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
|
||||
|
||||
When the request carries an Authorization header, a custom redirect
|
||||
handler drops that header if the redirect target is not a GitHub-owned
|
||||
domain, preventing token leakage to CDNs or other third-party hosts
|
||||
that GitHub may redirect to (e.g. S3 for release asset downloads).
|
||||
"""
|
||||
req = build_github_request(url)
|
||||
|
||||
if not req.get_header("Authorization"):
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect)
|
||||
return opener.open(req, timeout=timeout)
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from fnmatch import fnmatch
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import get_provider
|
||||
@@ -56,22 +57,36 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
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."""
|
||||
RedirectValidator = Callable[[str, str], None]
|
||||
|
||||
def __init__(self, hosts: tuple[str, ...]) -> None:
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hosts: tuple[str, ...],
|
||||
redirect_validator: RedirectValidator | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._hosts = hosts
|
||||
self._redirect_validator = redirect_validator
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if self._redirect_validator is not None:
|
||||
self._redirect_validator(req.full_url, 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):
|
||||
old_scheme = urlparse(req.full_url).scheme
|
||||
new_parsed = urlparse(newurl)
|
||||
hostname = (new_parsed.hostname or "").lower()
|
||||
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
|
||||
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
|
||||
if original_auth:
|
||||
new_req.add_unredirected_header("Authorization", original_auth)
|
||||
else:
|
||||
@@ -103,7 +118,12 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None):
|
||||
def open_url(
|
||||
url: str,
|
||||
timeout: int = 10,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
redirect_validator: RedirectValidator | None = None,
|
||||
):
|
||||
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
|
||||
|
||||
1. Find ``auth.json`` entries whose hosts match the URL.
|
||||
@@ -113,6 +133,8 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
5. Non-auth errors (404, 500, network) raise immediately.
|
||||
|
||||
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
|
||||
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
|
||||
before following each redirect and may raise to reject the redirect.
|
||||
"""
|
||||
entries = find_entries_for_url(url, _load_config())
|
||||
|
||||
@@ -135,7 +157,7 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
continue
|
||||
|
||||
req = _make_req(provider.auth_headers(token, entry.auth))
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator))
|
||||
try:
|
||||
return opener.open(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
@@ -146,4 +168,7 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
|
||||
|
||||
# No entry worked (or none matched) — unauthenticated fallback
|
||||
req = _make_req({})
|
||||
if redirect_validator is not None:
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
|
||||
return opener.open(req, timeout=timeout)
|
||||
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
|
||||
|
||||
@@ -41,6 +41,8 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
})
|
||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
||||
|
||||
VALID_EFFECTS = frozenset({"read-only", "read-write"})
|
||||
|
||||
DEFAULT_HOOK_PRIORITY = 10
|
||||
|
||||
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
|
||||
@@ -201,6 +203,21 @@ class ExtensionManifest:
|
||||
except pkg_version.InvalidVersion:
|
||||
raise ValidationError(f"Invalid version: {ext['version']}")
|
||||
|
||||
# Validate optional category field (free-form string)
|
||||
if "category" in ext:
|
||||
if not isinstance(ext["category"], str) or not ext["category"].strip():
|
||||
raise ValidationError(
|
||||
"Invalid extension.category: must be a non-empty string"
|
||||
)
|
||||
|
||||
# Validate optional effect field
|
||||
if "effect" in ext:
|
||||
if not isinstance(ext["effect"], str) or ext["effect"] not in VALID_EFFECTS:
|
||||
raise ValidationError(
|
||||
f"Invalid extension.effect '{ext.get('effect')}': "
|
||||
f"must be one of {sorted(VALID_EFFECTS)}"
|
||||
)
|
||||
|
||||
# Validate requires section
|
||||
requires = self.data["requires"]
|
||||
if "speckit_version" not in requires:
|
||||
@@ -374,6 +391,16 @@ class ExtensionManifest:
|
||||
"""Get extension description."""
|
||||
return self.data["extension"]["description"]
|
||||
|
||||
@property
|
||||
def category(self) -> Optional[str]:
|
||||
"""Get extension category (free-form; common values: docs, code, process, integration, visibility)."""
|
||||
return self.data["extension"].get("category")
|
||||
|
||||
@property
|
||||
def effect(self) -> Optional[str]:
|
||||
"""Get extension effect (read-only, read-write)."""
|
||||
return self.data["extension"].get("effect")
|
||||
|
||||
@property
|
||||
def requires_speckit_version(self) -> str:
|
||||
"""Get required spec-kit version range."""
|
||||
@@ -1026,6 +1053,22 @@ class ExtensionManager:
|
||||
description,
|
||||
f"extension:{manifest.id}",
|
||||
)
|
||||
# Preserve the command's argument-hint in the generated skill,
|
||||
# mirroring the core template path (ClaudeIntegration.setup injects
|
||||
# it for built-in commands). The value is added to the frontmatter
|
||||
# dict before serialization — rather than via the string-based
|
||||
# inject_argument_hint helper — so that a folded multi-line
|
||||
# description cannot be split by the inserted line. Gated on the
|
||||
# integration exposing inject_argument_hint so only argument-hint
|
||||
# aware agents receive the key, leaving build_skill_frontmatter's
|
||||
# shared shape unchanged for every other agent.
|
||||
argument_hint = frontmatter.get("argument-hint")
|
||||
if (
|
||||
argument_hint
|
||||
and integration is not None
|
||||
and hasattr(integration, "inject_argument_hint")
|
||||
):
|
||||
frontmatter_data["argument-hint"] = str(argument_hint)
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
|
||||
# Derive a human-friendly title from the command name
|
||||
@@ -1905,6 +1948,44 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
download_url, self._open_url, timeout=timeout
|
||||
)
|
||||
|
||||
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
|
||||
"""Validate a parsed catalog payload's shape.
|
||||
|
||||
Applied to both network-fetched and cache-loaded payloads so a
|
||||
once-poisoned cache (older spec-kit version, manual edit, upstream
|
||||
served a bad payload before the network-side guards were added)
|
||||
cannot re-crash ``_get_merged_extensions`` on subsequent calls.
|
||||
|
||||
Checking only key presence would let a payload like
|
||||
``{"extensions": []}`` or ``{"extensions": null}`` slip through
|
||||
here and then crash with ``AttributeError: 'list' object has no
|
||||
attribute 'items'`` deep inside ``_get_merged_extensions``. The
|
||||
sibling integration catalog reader already guards both the root
|
||||
object and the nested mapping (see ``integrations/catalog.py``);
|
||||
the extension catalog must stay consistent so a malformed payload
|
||||
surfaces as the user-facing ``Invalid catalog format`` error
|
||||
instead of a raw Python traceback.
|
||||
|
||||
Args:
|
||||
catalog_data: Parsed JSON payload from the catalog source.
|
||||
url: Source URL — used in the error message so the user can
|
||||
tell which catalog in a multi-catalog stack is malformed.
|
||||
|
||||
Raises:
|
||||
ExtensionError: If the payload's shape is invalid.
|
||||
"""
|
||||
if not isinstance(catalog_data, dict):
|
||||
raise ExtensionError(
|
||||
f"Invalid catalog format from {url}: expected a JSON object"
|
||||
)
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError(f"Invalid catalog format from {url}")
|
||||
if not isinstance(catalog_data.get("extensions"), dict):
|
||||
raise ExtensionError(
|
||||
f"Invalid catalog format from {url}: "
|
||||
"'extensions' must be a JSON object"
|
||||
)
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
"""Get the ordered list of active catalogs.
|
||||
|
||||
@@ -2020,21 +2101,51 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
is_valid = False
|
||||
if not force_refresh and cache_file.exists() and cache_meta_file.exists():
|
||||
try:
|
||||
metadata = json.loads(cache_meta_file.read_text())
|
||||
metadata = json.loads(
|
||||
cache_meta_file.read_text(encoding="utf-8")
|
||||
)
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
is_valid = age < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
# If metadata is invalid or missing expected fields, treat cache as invalid
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# Cache validity is best-effort: invalid/missing metadata
|
||||
# fields, an unreadable metadata file (permissions / disk),
|
||||
# a wrongly-encoded metadata file (written by a tool using
|
||||
# the system locale codec), or a metadata payload that
|
||||
# parses to a non-mapping like ``[]`` or ``"oops"`` (so
|
||||
# ``metadata.get(...)`` raises ``AttributeError``) all
|
||||
# degrade to "cache invalid" so the caller falls through
|
||||
# to a network refetch instead of crashing.
|
||||
pass
|
||||
|
||||
# Use cache if valid
|
||||
# Use cache if valid. A previously-cached payload must clear the
|
||||
# same shape checks as a freshly-fetched one — otherwise a once-
|
||||
# poisoned cache (older spec-kit version, manual edit, upstream
|
||||
# served a bad payload before the network-side guards were added)
|
||||
# would re-crash on every invocation despite the cache being
|
||||
# "valid" by age. If validation fails on the cached read, fall
|
||||
# through to the network fetch path so the cache gets refreshed.
|
||||
if is_valid:
|
||||
try:
|
||||
return json.loads(cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
self._validate_catalog_payload(cached_data, entry.url)
|
||||
return cached_data
|
||||
except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError):
|
||||
# Cache is best-effort: a JSON-decode failure, an OS-level
|
||||
# read failure (permissions / disk / handle limit), or a
|
||||
# text-encoding failure on a cache file written by an older
|
||||
# client all fall through to the network fetch path. Only
|
||||
# the network failure is surfaced to the caller.
|
||||
pass
|
||||
|
||||
# Fetch from network
|
||||
@@ -2042,16 +2153,32 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError(f"Invalid catalog format from {entry.url}")
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
|
||||
# Save to cache
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
cache_meta_file.write_text(json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}, indent=2))
|
||||
# Save to cache. Both files are explicitly UTF-8 to match the
|
||||
# ``read_text(encoding="utf-8")`` on the read side and the
|
||||
# ``integrations/catalog.py`` precedent (see the cache write
|
||||
# helpers in ``CatalogCache`` there). Without this, platforms
|
||||
# whose default encoding isn't UTF-8 would write locale-encoded
|
||||
# bytes that the read path can't decode, forcing an unnecessary
|
||||
# network refetch on every invocation. The write itself is
|
||||
# best-effort, matching the read side: an unwritable cache dir
|
||||
# (read-only checkout, permissions) must not fail a fetch whose
|
||||
# payload was already fetched and validated.
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
cache_meta_file.write_text(
|
||||
json.dumps({
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass # Cache is best-effort; proceed with fetched data
|
||||
|
||||
return catalog_data
|
||||
|
||||
@@ -2098,6 +2225,16 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
continue
|
||||
|
||||
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
|
||||
# Per-entry guard: ``_fetch_single_catalog`` already validates
|
||||
# that ``catalog_data["extensions"]`` is a mapping, but it
|
||||
# does not (and should not) validate every entry shape there
|
||||
# — one malformed entry shouldn't poison an otherwise valid
|
||||
# catalog. Skip non-mapping entries here so a payload like
|
||||
# ``{"extensions": {"foo": [], "bar": {...}}}`` still merges
|
||||
# the valid entries without crashing on ``**ext_data``.
|
||||
# Mirrors ``integrations/catalog.py:245``.
|
||||
if not isinstance(ext_data, dict):
|
||||
continue
|
||||
if ext_id not in merged: # Higher-priority catalog wins
|
||||
merged[ext_id] = {
|
||||
**ext_data,
|
||||
@@ -2114,6 +2251,12 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
def is_cache_valid(self) -> bool:
|
||||
"""Check if cached catalog is still valid.
|
||||
|
||||
Returns ``False`` for any read/decoding failure on the metadata
|
||||
file (missing fields, malformed JSON, permissions / disk errors,
|
||||
wrong text encoding) so callers fall through to a network refetch
|
||||
instead of crashing. Treating cache validity as best-effort
|
||||
matches the contract used by the per-URL cache check below.
|
||||
|
||||
Returns:
|
||||
True if cache exists and is within cache duration
|
||||
"""
|
||||
@@ -2121,13 +2264,28 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# ``AttributeError`` covers the case where the metadata file is
|
||||
# valid JSON but parses to a non-mapping (``[]``, ``"oops"``,
|
||||
# ``42``) so ``metadata.get(...)`` would otherwise crash. All
|
||||
# decode/shape failures degrade to "cache invalid" so the
|
||||
# caller falls through to a network refetch.
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2142,36 +2300,62 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
Raises:
|
||||
ExtensionError: If catalog cannot be fetched
|
||||
"""
|
||||
# Check cache first unless force refresh
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
# Check the cache first unless ``force_refresh`` was requested,
|
||||
# then fall through to a network fetch. Match the
|
||||
# ``_fetch_single_catalog`` cache contract: a poisoned or
|
||||
# unreadable cache silently falls through to a network refetch
|
||||
# rather than crashing the caller. ``_validate_catalog_payload``
|
||||
# is reused here so a cache written by an older client
|
||||
# (pre-validation) is rejected and refreshed instead of returning
|
||||
# the stale malformed payload. ``is_cache_valid`` itself swallows
|
||||
# OSError/UnicodeError on the metadata read, so a cache-validity
|
||||
# check can't crash this method before the read-side fallback
|
||||
# runs.
|
||||
if not force_refresh and self.is_cache_valid():
|
||||
try:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
cached_data = json.loads(self.cache_file.read_text(encoding="utf-8"))
|
||||
self._validate_catalog_payload(cached_data, catalog_url)
|
||||
return cached_data
|
||||
except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError):
|
||||
pass # Fall through to network fetch
|
||||
|
||||
# Fetch from network
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
try:
|
||||
import urllib.error
|
||||
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
# Validate catalog structure
|
||||
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
|
||||
raise ExtensionError("Invalid catalog format")
|
||||
# Validate catalog structure. Reuses the same helper as
|
||||
# ``_fetch_single_catalog`` so all three branches (root type,
|
||||
# missing keys, nested-mapping type) stay consistent.
|
||||
self._validate_catalog_payload(catalog_data, catalog_url)
|
||||
|
||||
# Save to cache
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
# Save to cache. Explicit UTF-8 on both writes mirrors the
|
||||
# ``read_text(encoding="utf-8")`` on the read side and the
|
||||
# ``integrations/catalog.py`` precedent — otherwise platforms
|
||||
# whose default encoding isn't UTF-8 would write locale-encoded
|
||||
# bytes the read path can't decode, forcing an unnecessary
|
||||
# refetch on every invocation. Like the read side, the write
|
||||
# is best-effort: an unwritable cache dir must not abort a
|
||||
# fetch whose payload was already fetched and validated.
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Save cache metadata
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(json.dumps(metadata, indent=2))
|
||||
# Save cache metadata
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2), encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass # Cache is best-effort; proceed with fetched data
|
||||
|
||||
return catalog_data
|
||||
|
||||
|
||||
@@ -25,17 +25,14 @@ class IntegrationReadError:
|
||||
schema: int | None = None
|
||||
|
||||
|
||||
def try_read_integration_json(
|
||||
def _read_integration_json_data(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``.specify/integration.json`` without raising.
|
||||
"""Read raw integration state without normalizing or raising.
|
||||
|
||||
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
|
||||
file does not exist, or ``(None, error)`` for any parse / validation
|
||||
failure. This is the single low-level reader; both the CLI's loud
|
||||
``_read_integration_json`` and the workflow engine's silent
|
||||
``_load_project_integration`` consume it so the schema guard and parse
|
||||
logic cannot drift between them.
|
||||
Returns ``(data, None)`` when the JSON object is readable and supported,
|
||||
``(None, None)`` when the file is absent, and ``(None, error)`` for parse,
|
||||
schema, encoding, or filesystem failures.
|
||||
"""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
|
||||
@@ -70,9 +67,41 @@ def try_read_integration_json(
|
||||
and schema > INTEGRATION_STATE_SCHEMA
|
||||
):
|
||||
return None, IntegrationReadError(kind="schema_too_new", schema=schema)
|
||||
return data, None
|
||||
|
||||
|
||||
def try_read_integration_json(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``.specify/integration.json`` without raising.
|
||||
|
||||
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
|
||||
file does not exist, or ``(None, error)`` for any parse / validation
|
||||
failure. This helper delegates file I/O and raw JSON validation to
|
||||
``_read_integration_json_data`` so callers that need raw state can share
|
||||
the same low-level reader instead of duplicating parse logic.
|
||||
"""
|
||||
data, error = _read_integration_json_data(project_root)
|
||||
if data is None:
|
||||
return None, error
|
||||
return normalize_integration_state(data), None
|
||||
|
||||
|
||||
def try_read_integration_json_with_raw(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``integration.json`` and return normalized plus raw state.
|
||||
|
||||
Returns ``(normalized_state, raw_state, None)`` when the file is readable,
|
||||
``(None, None, None)`` when it is absent, and ``(None, None, error)`` for
|
||||
parse, schema, encoding, or filesystem failures.
|
||||
"""
|
||||
data, error = _read_integration_json_data(project_root)
|
||||
if data is None:
|
||||
return None, None, error
|
||||
return normalize_integration_state(data), data, None
|
||||
|
||||
|
||||
def clean_integration_key(key: Any) -> str | None:
|
||||
"""Return a stripped integration key, or None for empty/non-string values."""
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
|
||||
663
src/specify_cli/integration_status.py
Normal file
663
src/specify_cli/integration_status.py
Normal file
@@ -0,0 +1,663 @@
|
||||
"""Read-only status reporting for project integration state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .integration_state import (
|
||||
INTEGRATION_JSON,
|
||||
INTEGRATION_STATE_SCHEMA,
|
||||
IntegrationReadError,
|
||||
default_integration_key,
|
||||
installed_integration_keys,
|
||||
try_read_integration_json_with_raw,
|
||||
)
|
||||
from .integrations import INTEGRATION_REGISTRY
|
||||
from .integrations.manifest import IntegrationManifest
|
||||
|
||||
_MANIFEST_READ_ERRORS = (ValueError, OSError)
|
||||
_MANIFEST_KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
_WINDOWS_RESERVED_MANIFEST_BASENAMES = {
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
*(f"COM{i}" for i in range(1, 10)),
|
||||
*(f"LPT{i}" for i in range(1, 10)),
|
||||
}
|
||||
_SHARED_MANIFEST_KEY = "speckit"
|
||||
|
||||
|
||||
def _finding(
|
||||
severity: str,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
integration: str | None = None,
|
||||
path: str | None = None,
|
||||
suggestion: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
item = {
|
||||
"severity": severity,
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
if integration:
|
||||
item["integration"] = integration
|
||||
if path:
|
||||
item["path"] = path
|
||||
if suggestion:
|
||||
item["suggestion"] = suggestion
|
||||
return item
|
||||
|
||||
|
||||
def _status(findings: list[dict[str, str]]) -> str:
|
||||
if any(item["severity"] == "error" for item in findings):
|
||||
return "error"
|
||||
if findings:
|
||||
return "warning"
|
||||
return "ok"
|
||||
|
||||
|
||||
def _with_error_detail(message: str, error: IntegrationReadError) -> str:
|
||||
if error.detail:
|
||||
return f"{message} Detail: {error.detail}"
|
||||
return message
|
||||
|
||||
|
||||
def _integration_state_error_message(error: IntegrationReadError) -> str:
|
||||
if error.kind == "decode":
|
||||
return _with_error_detail(
|
||||
f"{INTEGRATION_JSON} contains invalid JSON or is not valid UTF-8.",
|
||||
error,
|
||||
)
|
||||
if error.kind == "os":
|
||||
return _with_error_detail(f"Could not read {INTEGRATION_JSON}.", error)
|
||||
if error.kind == "not_object":
|
||||
return f"{INTEGRATION_JSON} must contain a JSON object, got {error.detail}."
|
||||
if error.kind == "schema_too_new":
|
||||
return (
|
||||
f"{INTEGRATION_JSON} uses integration state schema {error.schema}, "
|
||||
f"which is newer than this CLI supports; supported schema: {INTEGRATION_STATE_SCHEMA}."
|
||||
)
|
||||
return f"Could not inspect {INTEGRATION_JSON}."
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _strip_extended_length_prefix(path: Path) -> Path:
|
||||
"""Drop the Windows ``\\\\?\\`` extended-length prefix for path comparison.
|
||||
|
||||
``os.readlink`` and ``Path.resolve`` can return extended-length paths on
|
||||
Windows (e.g. ``\\\\?\\C:\\proj``). Comparing such a path against a plain
|
||||
``C:\\proj`` root via :meth:`Path.relative_to` would spuriously fail, so we
|
||||
normalise both sides through this helper before containment checks.
|
||||
"""
|
||||
raw = str(path)
|
||||
if raw.startswith("\\\\?\\UNC\\"):
|
||||
return Path("\\\\" + raw[len("\\\\?\\UNC\\"):])
|
||||
if raw.startswith("\\\\?\\"):
|
||||
return Path(raw[len("\\\\?\\"):])
|
||||
return path
|
||||
|
||||
|
||||
def _is_within_project(project_root_resolved: Path, candidate: Path) -> bool:
|
||||
"""Return ``True`` when *candidate* stays within *project_root_resolved*.
|
||||
|
||||
Both paths are stripped of any Windows extended-length prefix first so that
|
||||
a target produced by ``os.readlink`` (which may be ``\\\\?\\``-prefixed) is
|
||||
still recognised as living inside an unprefixed project root.
|
||||
"""
|
||||
try:
|
||||
_strip_extended_length_prefix(candidate).relative_to(
|
||||
_strip_extended_length_prefix(project_root_resolved)
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _safe_manifest_file(
|
||||
project_root: Path,
|
||||
project_root_resolved: Path,
|
||||
rel: str,
|
||||
*,
|
||||
project_root_is_resolved: bool = True,
|
||||
) -> Path | None:
|
||||
rel_path = Path(rel)
|
||||
if rel_path.is_absolute() or ".." in rel_path.parts:
|
||||
return None
|
||||
candidate = project_root / rel_path
|
||||
if not project_root_is_resolved:
|
||||
walk = project_root
|
||||
for part in rel_path.parts[:-1]:
|
||||
walk = walk / part
|
||||
try:
|
||||
if walk.is_symlink():
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
candidate_parent = (
|
||||
candidate.parent.resolve(strict=False)
|
||||
if project_root_is_resolved
|
||||
else candidate.parent.absolute()
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
if not _is_within_project(project_root_resolved, candidate_parent):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def _tracked_symlink_manifest_status(
|
||||
path: Path,
|
||||
project_root_resolved: Path,
|
||||
*,
|
||||
project_root_is_resolved: bool = True,
|
||||
) -> str:
|
||||
"""Classify a tracked symlink without following it outside the project.
|
||||
|
||||
Manifests store content hashes for regular files, so an existing in-project
|
||||
symlink is still reported as modified. Escaping targets are invalid, and
|
||||
dangling in-project targets are missing.
|
||||
"""
|
||||
try:
|
||||
target = path.readlink()
|
||||
except OSError:
|
||||
return "modified"
|
||||
|
||||
target_path = target if target.is_absolute() else path.parent / target
|
||||
try:
|
||||
contained_parent = (
|
||||
target_path.parent.resolve(strict=False)
|
||||
if project_root_is_resolved
|
||||
else target_path.parent.absolute()
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
return "invalid"
|
||||
if not _is_within_project(project_root_resolved, contained_parent):
|
||||
return "invalid"
|
||||
|
||||
try:
|
||||
target_path.lstat()
|
||||
except FileNotFoundError:
|
||||
return "missing"
|
||||
except OSError:
|
||||
return "modified"
|
||||
return "modified"
|
||||
|
||||
|
||||
def _resolve_project_root_for_status(
|
||||
project_root: Path,
|
||||
findings: list[dict[str, str]],
|
||||
) -> tuple[Path, bool]:
|
||||
try:
|
||||
return project_root.resolve(), True
|
||||
except (OSError, RuntimeError) as exc:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"project-root-unresolved",
|
||||
f"Could not fully resolve project root: {exc}",
|
||||
suggestion="Check project path permissions and symlinks before relying on manifest path checks.",
|
||||
)
|
||||
)
|
||||
return project_root.absolute(), False
|
||||
|
||||
|
||||
def _is_safe_manifest_key(key: str) -> bool:
|
||||
if key in {"", ".", ".."}:
|
||||
return False
|
||||
if key.endswith("."):
|
||||
return False
|
||||
if _MANIFEST_KEY_RE.fullmatch(key) is None:
|
||||
return False
|
||||
if key.split(".", 1)[0].upper() in _WINDOWS_RESERVED_MANIFEST_BASENAMES:
|
||||
return False
|
||||
if "/" in key or "\\" in key:
|
||||
return False
|
||||
key_path = Path(key)
|
||||
return not key_path.is_absolute() and key_path.name == key
|
||||
|
||||
|
||||
def _manifest_file_status(
|
||||
manifest: IntegrationManifest,
|
||||
project_root_resolved: Path,
|
||||
*,
|
||||
project_root_is_resolved: bool = True,
|
||||
) -> tuple[list[str], list[str], list[str], list[str]]:
|
||||
missing: list[str] = []
|
||||
modified: list[str] = []
|
||||
invalid: list[str] = []
|
||||
valid: list[str] = []
|
||||
|
||||
for rel, expected_hash in manifest.files.items():
|
||||
path = _safe_manifest_file(
|
||||
manifest.project_root,
|
||||
project_root_resolved,
|
||||
rel,
|
||||
project_root_is_resolved=project_root_is_resolved,
|
||||
)
|
||||
if path is None:
|
||||
invalid.append(rel)
|
||||
continue
|
||||
try:
|
||||
path_stat = path.lstat()
|
||||
except FileNotFoundError:
|
||||
valid.append(rel)
|
||||
missing.append(rel)
|
||||
continue
|
||||
except OSError:
|
||||
valid.append(rel)
|
||||
modified.append(rel)
|
||||
continue
|
||||
is_symlink = stat.S_ISLNK(path_stat.st_mode)
|
||||
if not is_symlink:
|
||||
try:
|
||||
is_symlink = path.is_symlink()
|
||||
except OSError:
|
||||
is_symlink = False
|
||||
if is_symlink:
|
||||
symlink_status = _tracked_symlink_manifest_status(
|
||||
path,
|
||||
project_root_resolved,
|
||||
project_root_is_resolved=project_root_is_resolved,
|
||||
)
|
||||
if symlink_status == "invalid":
|
||||
invalid.append(rel)
|
||||
continue
|
||||
valid.append(rel)
|
||||
if symlink_status == "missing":
|
||||
missing.append(rel)
|
||||
continue
|
||||
modified.append(rel)
|
||||
continue
|
||||
valid.append(rel)
|
||||
if not stat.S_ISREG(path_stat.st_mode):
|
||||
modified.append(rel)
|
||||
continue
|
||||
try:
|
||||
if _sha256_file(path) != expected_hash:
|
||||
modified.append(rel)
|
||||
except OSError:
|
||||
modified.append(rel)
|
||||
|
||||
return missing, modified, invalid, valid
|
||||
|
||||
|
||||
def _default_not_installed_from_raw_state(raw_state: dict[str, Any]) -> str | None:
|
||||
if not isinstance(raw_state.get("installed_integrations"), list):
|
||||
return None
|
||||
|
||||
raw_default = default_integration_key(raw_state)
|
||||
raw_installed = installed_integration_keys(raw_state)
|
||||
if raw_default and raw_default not in raw_installed:
|
||||
return raw_default
|
||||
return None
|
||||
|
||||
|
||||
def _manifest_summary(
|
||||
manifest_path: Path,
|
||||
project_root: Path,
|
||||
*,
|
||||
readable: bool,
|
||||
tracked_files: int = 0,
|
||||
missing_files: list[str] | None = None,
|
||||
modified_files: list[str] | None = None,
|
||||
invalid_files: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"manifest": manifest_path.relative_to(project_root).as_posix(),
|
||||
"readable": readable,
|
||||
"tracked_files": tracked_files,
|
||||
"missing_files": missing_files or [],
|
||||
"modified_files": modified_files or [],
|
||||
"invalid_files": invalid_files or [],
|
||||
}
|
||||
|
||||
|
||||
def _manifest_owner(key: str) -> str:
|
||||
if key == _SHARED_MANIFEST_KEY:
|
||||
return "shared Spec Kit infrastructure"
|
||||
return f"integration '{key}'"
|
||||
|
||||
|
||||
def _manifest_suggestion(key: str, default_key: str | None) -> str:
|
||||
if key == _SHARED_MANIFEST_KEY:
|
||||
if default_key and default_key in INTEGRATION_REGISTRY:
|
||||
return f"Run `specify integration upgrade {default_key}` to regenerate shared managed files."
|
||||
return (
|
||||
"Run `specify init --here --force --integration <key>` to regenerate "
|
||||
"shared managed files."
|
||||
)
|
||||
if key not in INTEGRATION_REGISTRY:
|
||||
return (
|
||||
"Upgrade Spec Kit, reinstall with a supported CLI version, "
|
||||
f"or remove the stale integration entry from {INTEGRATION_JSON}."
|
||||
)
|
||||
return f"Run `specify integration upgrade {key}` or reinstall the integration."
|
||||
|
||||
|
||||
def build_integration_status_report(project_root: Path) -> dict[str, Any]:
|
||||
"""Return a machine-readable integration status report for *project_root*."""
|
||||
findings: list[dict[str, str]] = []
|
||||
project_root_resolved, project_root_is_resolved = _resolve_project_root_for_status(
|
||||
project_root,
|
||||
findings,
|
||||
)
|
||||
state, raw_state, error = try_read_integration_json_with_raw(project_root)
|
||||
if error is not None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"integration-state-unreadable",
|
||||
_integration_state_error_message(error),
|
||||
path=INTEGRATION_JSON,
|
||||
suggestion=f"Fix or delete {INTEGRATION_JSON}, then retry.",
|
||||
)
|
||||
)
|
||||
return _build_report(None, [], findings, {}, None)
|
||||
|
||||
if state is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"integration-state-missing",
|
||||
f"{INTEGRATION_JSON} is missing.",
|
||||
path=INTEGRATION_JSON,
|
||||
suggestion="Run `specify integration install <key>` to install an integration.",
|
||||
)
|
||||
)
|
||||
return _build_report(None, [], findings, {}, None)
|
||||
|
||||
assert raw_state is not None
|
||||
raw_default_key = default_integration_key(raw_state)
|
||||
raw_installed_value = raw_state.get("installed_integrations")
|
||||
raw_installed_is_list = isinstance(raw_installed_value, list)
|
||||
raw_installed_keys = (
|
||||
installed_integration_keys(raw_state)
|
||||
if raw_installed_is_list
|
||||
else []
|
||||
)
|
||||
default_key = raw_default_key or default_integration_key(state)
|
||||
installed_keys = installed_integration_keys(state)
|
||||
raw_default_not_installed = _default_not_installed_from_raw_state(raw_state)
|
||||
if raw_installed_is_list and raw_default_not_installed and raw_installed_keys:
|
||||
check_installed_keys = raw_installed_keys
|
||||
else:
|
||||
check_installed_keys = installed_keys
|
||||
recorded_installed_keys = raw_installed_keys
|
||||
if "installed_integrations" in raw_state and not raw_installed_is_list:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"installed-integrations-invalid",
|
||||
(
|
||||
"installed_integrations must be a list, "
|
||||
f"got {type(raw_installed_value).__name__}."
|
||||
),
|
||||
path=INTEGRATION_JSON,
|
||||
suggestion=f"Fix {INTEGRATION_JSON}, then retry.",
|
||||
)
|
||||
)
|
||||
if not installed_keys:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"no-installed-integrations",
|
||||
"No installed integrations are recorded.",
|
||||
suggestion="Run `specify integration install <key>` to install one.",
|
||||
)
|
||||
)
|
||||
|
||||
if raw_installed_keys and raw_default_key is None:
|
||||
default_key = None
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"default-integration-missing",
|
||||
"No default integration is recorded.",
|
||||
suggestion="Run `specify integration use <key>` after choosing an installed integration.",
|
||||
)
|
||||
)
|
||||
|
||||
if raw_default_not_installed:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"default-integration-not-installed",
|
||||
(
|
||||
f"Default integration '{raw_default_not_installed}' is not listed "
|
||||
"in installed_integrations."
|
||||
),
|
||||
integration=raw_default_not_installed,
|
||||
suggestion="Run `specify integration use <key>` for an installed integration, or reinstall the default integration.",
|
||||
)
|
||||
)
|
||||
|
||||
known_installed = [key for key in check_installed_keys if key in INTEGRATION_REGISTRY]
|
||||
unknown_installed: list[str] = []
|
||||
for key in check_installed_keys:
|
||||
if key not in INTEGRATION_REGISTRY:
|
||||
unknown_installed.append(key)
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"unknown-integration",
|
||||
f"Integration '{key}' is installed but is not known to this CLI.",
|
||||
integration=key,
|
||||
suggestion=(
|
||||
"Upgrade Spec Kit, reinstall with a supported CLI version, "
|
||||
f"or remove the stale integration entry from {INTEGRATION_JSON}."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
unsafe = [
|
||||
key for key in known_installed
|
||||
if not getattr(INTEGRATION_REGISTRY[key], "multi_install_safe", False)
|
||||
]
|
||||
if len(check_installed_keys) > 1:
|
||||
unsafe.extend(unknown_installed)
|
||||
|
||||
if len(check_installed_keys) > 1 and unsafe:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"unsafe-multi-install",
|
||||
(
|
||||
"Installed integrations are not all declared multi-install safe: "
|
||||
+ ", ".join(sorted(unsafe))
|
||||
),
|
||||
suggestion=(
|
||||
"Use `specify integration use <key>` to change defaults, "
|
||||
"or `specify integration switch <key>` only when replacing integrations."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
manifest_files_by_path: dict[str, list[str]] = {}
|
||||
manifest_summaries: dict[str, dict[str, Any]] = {}
|
||||
attempted_manifest_keys: list[str] = []
|
||||
manifest_keys = list(check_installed_keys)
|
||||
if _SHARED_MANIFEST_KEY not in manifest_keys:
|
||||
manifest_keys.append(_SHARED_MANIFEST_KEY)
|
||||
|
||||
for key in manifest_keys:
|
||||
owner = _manifest_owner(key)
|
||||
if not _is_safe_manifest_key(key):
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"integration-key-invalid",
|
||||
f"Integration key {key!r} cannot be used as a manifest filename.",
|
||||
integration=key,
|
||||
path=INTEGRATION_JSON,
|
||||
suggestion=f"Fix {INTEGRATION_JSON}, then reinstall the integration.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
attempted_manifest_keys.append(key)
|
||||
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||
try:
|
||||
manifest = IntegrationManifest.load(
|
||||
key,
|
||||
project_root_resolved,
|
||||
resolve_project_root=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"manifest-missing",
|
||||
f"Manifest for {owner} is missing.",
|
||||
integration=key,
|
||||
path=manifest_path.relative_to(project_root).as_posix(),
|
||||
suggestion=_manifest_suggestion(key, default_key),
|
||||
)
|
||||
)
|
||||
manifest_summaries[key] = _manifest_summary(
|
||||
manifest_path,
|
||||
project_root,
|
||||
readable=False,
|
||||
)
|
||||
continue
|
||||
except _MANIFEST_READ_ERRORS as exc:
|
||||
manifest_summaries[key] = _manifest_summary(
|
||||
manifest_path,
|
||||
project_root,
|
||||
readable=False,
|
||||
)
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"manifest-unreadable",
|
||||
f"Manifest for {owner} is unreadable: {exc}",
|
||||
integration=key,
|
||||
path=manifest_path.relative_to(project_root).as_posix(),
|
||||
suggestion=_manifest_suggestion(key, default_key),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
missing, modified, invalid, valid_files = _manifest_file_status(
|
||||
manifest,
|
||||
project_root_resolved,
|
||||
project_root_is_resolved=project_root_is_resolved,
|
||||
)
|
||||
manifest_summaries[key] = _manifest_summary(
|
||||
manifest_path,
|
||||
project_root,
|
||||
readable=True,
|
||||
tracked_files=len(manifest.files),
|
||||
missing_files=missing,
|
||||
modified_files=modified,
|
||||
invalid_files=invalid,
|
||||
)
|
||||
|
||||
for rel in valid_files:
|
||||
manifest_files_by_path.setdefault(rel, []).append(key)
|
||||
if invalid:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"manifest-paths-invalid",
|
||||
f"{len(invalid)} unsafe manifest path(s) are recorded for {owner}.",
|
||||
integration=key,
|
||||
path=manifest_path.relative_to(project_root).as_posix(),
|
||||
suggestion=_manifest_suggestion(key, default_key),
|
||||
)
|
||||
)
|
||||
if missing:
|
||||
findings.append(
|
||||
_finding(
|
||||
"error",
|
||||
"managed-files-missing",
|
||||
f"{len(missing)} managed file(s) are missing for {owner}.",
|
||||
integration=key,
|
||||
suggestion=_manifest_suggestion(key, default_key),
|
||||
)
|
||||
)
|
||||
if modified:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"managed-files-modified",
|
||||
f"{len(modified)} managed file(s) were modified for {owner}.",
|
||||
integration=key,
|
||||
suggestion="Review the changes before running `specify integration upgrade --force`.",
|
||||
)
|
||||
)
|
||||
|
||||
for rel, keys in sorted(manifest_files_by_path.items()):
|
||||
if len(keys) > 1:
|
||||
findings.append(
|
||||
_finding(
|
||||
"warning",
|
||||
"managed-file-collision",
|
||||
f"Managed file '{rel}' is tracked by multiple integrations: {', '.join(sorted(keys))}.",
|
||||
path=rel,
|
||||
suggestion="Review the manifests before uninstalling or upgrading these integrations.",
|
||||
)
|
||||
)
|
||||
|
||||
if not raw_installed_is_list or not raw_installed_keys:
|
||||
multi_install_safe = None
|
||||
else:
|
||||
multi_install_safe = not (len(check_installed_keys) > 1 and unsafe)
|
||||
return _build_report(
|
||||
default_key,
|
||||
installed_keys,
|
||||
findings,
|
||||
manifest_summaries,
|
||||
multi_install_safe,
|
||||
manifest_checked_keys=attempted_manifest_keys,
|
||||
recorded_installed_keys=recorded_installed_keys,
|
||||
)
|
||||
|
||||
|
||||
def _build_report(
|
||||
default_key: str | None,
|
||||
installed_keys: list[str],
|
||||
findings: list[dict[str, str]],
|
||||
manifests: dict[str, dict[str, Any]],
|
||||
multi_install_safe: bool | None,
|
||||
*,
|
||||
manifest_checked_keys: list[str] | None = None,
|
||||
recorded_installed_keys: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
missing_count = sum(len(item.get("missing_files", [])) for item in manifests.values())
|
||||
modified_count = sum(len(item.get("modified_files", [])) for item in manifests.values())
|
||||
invalid_count = sum(len(item.get("invalid_files", [])) for item in manifests.values())
|
||||
unchecked_count = sum(1 for item in manifests.values() if not item.get("readable", True))
|
||||
return {
|
||||
"status": _status(findings),
|
||||
"default_integration": default_key,
|
||||
"installed_integrations": installed_keys,
|
||||
"recorded_installed_integrations": (
|
||||
installed_keys if recorded_installed_keys is None else recorded_installed_keys
|
||||
),
|
||||
"manifest_checked_integrations": (
|
||||
installed_keys if manifest_checked_keys is None else manifest_checked_keys
|
||||
),
|
||||
"multi_install_safe": multi_install_safe,
|
||||
"shared_templates_target_alignment": default_key,
|
||||
"missing_managed_files": missing_count,
|
||||
"modified_managed_files": modified_count,
|
||||
"invalid_manifest_paths": invalid_count,
|
||||
"unchecked_manifests": unchecked_count,
|
||||
"manifests": manifests,
|
||||
"findings": findings,
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
|
||||
"""specify integration list/status/use/search/info + catalog list/add/remove command handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
from rich.markup import escape as _rich_escape
|
||||
from rich.table import Table
|
||||
|
||||
from .._console import console
|
||||
@@ -120,6 +122,86 @@ def integration_list(
|
||||
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
|
||||
|
||||
|
||||
def _print_integration_status_report(report: dict[str, Any]) -> None:
|
||||
status = report["status"]
|
||||
status_label = {
|
||||
"ok": "[green]OK[/green]",
|
||||
"warning": "[yellow]WARNING[/yellow]",
|
||||
"error": "[red]ERROR[/red]",
|
||||
}.get(str(status), str(status).upper())
|
||||
installed = report.get("installed_integrations") or []
|
||||
installed_display = ", ".join(_rich_escape(str(item)) for item in installed)
|
||||
|
||||
console.print(f"Integration status: {status_label}")
|
||||
console.print(
|
||||
f"Default integration: {_rich_escape(str(report.get('default_integration') or 'none'))}"
|
||||
)
|
||||
console.print(f"Installed integrations: {installed_display if installed else 'none'}")
|
||||
multi_install_safe = report.get("multi_install_safe")
|
||||
if multi_install_safe is None:
|
||||
multi_install_safe_display = "unknown"
|
||||
else:
|
||||
multi_install_safe_display = "yes" if multi_install_safe else "no"
|
||||
console.print(f"Multi-install safe: {multi_install_safe_display}")
|
||||
console.print(
|
||||
f"Shared templates target alignment: "
|
||||
f"{_rich_escape(str(report.get('shared_templates_target_alignment') or 'none'))}"
|
||||
)
|
||||
console.print(f"Modified managed files: {report.get('modified_managed_files', 0)}")
|
||||
console.print(f"Missing managed files: {report.get('missing_managed_files', 0)}")
|
||||
console.print(f"Invalid manifest paths: {report.get('invalid_manifest_paths', 0)}")
|
||||
console.print(f"Unchecked manifests: {report.get('unchecked_manifests', 0)}")
|
||||
|
||||
findings = report.get("findings") or []
|
||||
if not findings:
|
||||
return
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Findings:[/bold]")
|
||||
for item in findings:
|
||||
severity = item.get("severity", "")
|
||||
severity_label = {
|
||||
"error": "[red]error[/red]",
|
||||
"warning": "[yellow]warning[/yellow]",
|
||||
}.get(severity, severity)
|
||||
prefix = f"- {severity_label} {_rich_escape(str(item.get('code', '')))}"
|
||||
if item.get("integration"):
|
||||
prefix += f" ({_rich_escape(str(item['integration']))})"
|
||||
console.print(
|
||||
f"{prefix}: {_rich_escape(str(item.get('message', '')))}",
|
||||
soft_wrap=True,
|
||||
)
|
||||
if item.get("suggestion"):
|
||||
console.print(
|
||||
f" Suggestion: {_rich_escape(str(item['suggestion']))}",
|
||||
soft_wrap=True,
|
||||
)
|
||||
|
||||
|
||||
@integration_app.command("status")
|
||||
def integration_status(
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
help="Emit machine-readable integration status.",
|
||||
),
|
||||
):
|
||||
"""Report the current project's integration status without changing files."""
|
||||
from .. import _require_specify_project
|
||||
from ..integration_status import build_integration_status_report
|
||||
|
||||
project_root = _require_specify_project()
|
||||
report = build_integration_status_report(project_root)
|
||||
|
||||
if json_output:
|
||||
typer.echo(json.dumps(report, indent=2))
|
||||
else:
|
||||
_print_integration_status_report(report)
|
||||
|
||||
if report["status"] == "error":
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@integration_app.command("use")
|
||||
def integration_use(
|
||||
key: str = typer.Argument(help="Installed integration key to make the default"),
|
||||
|
||||
@@ -108,11 +108,23 @@ class IntegrationManifest:
|
||||
key: Integration identifier (e.g. ``"copilot"``).
|
||||
project_root: Absolute path to the project directory.
|
||||
version: CLI version string recorded in the manifest.
|
||||
resolve_project_root: Resolve ``project_root`` before using it.
|
||||
"""
|
||||
|
||||
def __init__(self, key: str, project_root: Path, version: str = "") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
project_root: Path,
|
||||
version: str = "",
|
||||
*,
|
||||
resolve_project_root: bool = True,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.project_root = project_root.resolve()
|
||||
self.project_root = (
|
||||
project_root.resolve()
|
||||
if resolve_project_root
|
||||
else project_root.absolute()
|
||||
)
|
||||
self.version = version
|
||||
self._files: dict[str, str] = {} # rel_path → sha256 hex
|
||||
self._recovered_files: set[str] = set()
|
||||
@@ -387,12 +399,18 @@ class IntegrationManifest:
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def load(cls, key: str, project_root: Path) -> IntegrationManifest:
|
||||
def load(
|
||||
cls,
|
||||
key: str,
|
||||
project_root: Path,
|
||||
*,
|
||||
resolve_project_root: bool = True,
|
||||
) -> IntegrationManifest:
|
||||
"""Load an existing manifest from disk.
|
||||
|
||||
Raises ``FileNotFoundError`` if the manifest does not exist.
|
||||
"""
|
||||
inst = cls(key, project_root)
|
||||
inst = cls(key, project_root, resolve_project_root=resolve_project_root)
|
||||
path = inst.manifest_path
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -1892,6 +1892,48 @@ class PresetCatalog:
|
||||
download_url, self._open_url, timeout=timeout
|
||||
)
|
||||
|
||||
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
|
||||
"""Validate a parsed preset-catalog payload's shape.
|
||||
|
||||
Applied to both network-fetched and cache-loaded payloads so a
|
||||
once-poisoned cache (older spec-kit version, manual edit, upstream
|
||||
served a bad payload before the network-side guards were added)
|
||||
cannot re-crash ``_get_merged_packs`` on subsequent calls.
|
||||
|
||||
Checking only key presence would let a payload like
|
||||
``{"presets": []}`` or ``{"presets": null}`` slip through here and
|
||||
then crash with ``AttributeError: 'list' object has no attribute
|
||||
'items'`` deep inside ``_get_merged_packs``. The sibling
|
||||
integration catalog reader already guards both the root object and
|
||||
the nested mapping (see ``integrations/catalog.py``); the preset
|
||||
catalog must stay consistent so a malformed payload surfaces as
|
||||
the user-facing ``Invalid preset catalog format`` error instead of
|
||||
a raw Python traceback.
|
||||
|
||||
Args:
|
||||
catalog_data: Parsed JSON payload from the catalog source.
|
||||
url: Source URL — used in the error message so the user can
|
||||
tell which catalog in a multi-catalog stack is malformed.
|
||||
|
||||
Raises:
|
||||
PresetError: If the payload's shape is invalid.
|
||||
"""
|
||||
if not isinstance(catalog_data, dict):
|
||||
raise PresetError(
|
||||
f"Invalid preset catalog format from {url}: "
|
||||
"expected a JSON object"
|
||||
)
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "presets" not in catalog_data
|
||||
):
|
||||
raise PresetError(f"Invalid preset catalog format from {url}")
|
||||
if not isinstance(catalog_data.get("presets"), dict):
|
||||
raise PresetError(
|
||||
f"Invalid preset catalog format from {url}: "
|
||||
"'presets' must be a JSON object"
|
||||
)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
@@ -2053,7 +2095,7 @@ class PresetCatalog:
|
||||
if not cache_file.exists() or not metadata_file.exists():
|
||||
return False
|
||||
try:
|
||||
metadata = json.loads(metadata_file.read_text())
|
||||
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2061,7 +2103,23 @@ class PresetCatalog:
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# Cache validity is best-effort: invalid/missing fields, an
|
||||
# unreadable metadata file (permissions / disk), a wrongly
|
||||
# encoded one (written by a tool using the system locale
|
||||
# codec), or a metadata payload that parses to a non-mapping
|
||||
# like ``[]`` or ``"oops"`` (so ``metadata.get(...)`` raises
|
||||
# ``AttributeError``) all degrade to "cache invalid" so the
|
||||
# caller falls through to a network refetch instead of
|
||||
# crashing.
|
||||
return False
|
||||
|
||||
def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2079,29 +2137,55 @@ class PresetCatalog:
|
||||
"""
|
||||
cache_file, metadata_file = self._get_cache_paths(entry.url)
|
||||
|
||||
# Use cache if valid. A previously-cached payload must clear the
|
||||
# same shape checks as a freshly-fetched one — otherwise a once-
|
||||
# poisoned cache would re-crash on every invocation despite the
|
||||
# cache being "valid" by age. If validation fails on the cached
|
||||
# read, fall through to the network fetch path so the cache gets
|
||||
# refreshed.
|
||||
if not force_refresh and self._is_url_cache_valid(entry.url):
|
||||
try:
|
||||
return json.loads(cache_file.read_text())
|
||||
except json.JSONDecodeError:
|
||||
cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
self._validate_catalog_payload(cached_data, entry.url)
|
||||
return cached_data
|
||||
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
|
||||
# Cache is best-effort: a JSON-decode failure, an OS-level
|
||||
# read failure (permissions / disk / handle limit), or a
|
||||
# text-encoding failure on a cache file written by an
|
||||
# older client all fall through to the network fetch path.
|
||||
# Only the network failure is surfaced to the caller.
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "presets" not in catalog_data
|
||||
):
|
||||
raise PresetError("Invalid preset catalog format")
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}
|
||||
metadata_file.write_text(json.dumps(metadata, indent=2))
|
||||
# Both files are written explicitly as UTF-8 to match the
|
||||
# ``read_text(encoding="utf-8")`` on the read side and the
|
||||
# ``integrations/catalog.py`` precedent. Without this,
|
||||
# platforms whose default encoding isn't UTF-8 would write
|
||||
# locale-encoded bytes the read path can't decode, forcing an
|
||||
# unnecessary refetch on every invocation. The write itself
|
||||
# is best-effort like the read side: an unwritable cache dir
|
||||
# (read-only checkout, permissions) must not be re-raised as
|
||||
# a ``PresetError`` for a payload that was already fetched
|
||||
# and validated.
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": entry.url,
|
||||
}
|
||||
metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2), encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass # Cache is best-effort; proceed with fetched data
|
||||
|
||||
return catalog_data
|
||||
|
||||
@@ -2127,6 +2211,17 @@ class PresetCatalog:
|
||||
try:
|
||||
data = self._fetch_single_catalog(entry, force_refresh)
|
||||
for pack_id, pack_data in data.get("presets", {}).items():
|
||||
# Per-entry guard: ``_fetch_single_catalog`` already
|
||||
# validates that ``data["presets"]`` is a mapping, but it
|
||||
# does not (and should not) validate every entry shape
|
||||
# there — one malformed entry shouldn't poison an
|
||||
# otherwise valid catalog. Skip non-mapping entries here
|
||||
# so a payload like ``{"presets": {"foo": [], "bar":
|
||||
# {...}}}`` still merges the valid entries without
|
||||
# crashing on ``**pack_data``. Mirrors
|
||||
# ``integrations/catalog.py:245``.
|
||||
if not isinstance(pack_data, dict):
|
||||
continue
|
||||
pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed}
|
||||
merged[pack_id] = pack_data_with_catalog
|
||||
except PresetError:
|
||||
@@ -2137,6 +2232,12 @@ class PresetCatalog:
|
||||
def is_cache_valid(self) -> bool:
|
||||
"""Check if cached catalog is still valid.
|
||||
|
||||
Returns ``False`` for any read/decoding failure on the metadata
|
||||
file (missing fields, malformed JSON, permissions / disk errors,
|
||||
wrong text encoding) so callers fall through to a network refetch
|
||||
instead of crashing. Treating cache validity as best-effort
|
||||
matches the contract used by ``_is_url_cache_valid`` above.
|
||||
|
||||
Returns:
|
||||
True if cache exists and is within cache duration
|
||||
"""
|
||||
@@ -2144,7 +2245,9 @@ class PresetCatalog:
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
|
||||
if cached_at.tzinfo is None:
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
@@ -2152,7 +2255,20 @@ class PresetCatalog:
|
||||
datetime.now(timezone.utc) - cached_at
|
||||
).total_seconds()
|
||||
return age_seconds < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
UnicodeError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# ``AttributeError`` covers the case where the metadata file
|
||||
# parses to a non-mapping (``[]``, ``"oops"``, ``42``) so
|
||||
# ``metadata.get(...)`` would otherwise crash. All decode /
|
||||
# shape failures degrade to "cache invalid" so the caller
|
||||
# falls through to a network refetch.
|
||||
return False
|
||||
|
||||
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
@@ -2169,35 +2285,61 @@ class PresetCatalog:
|
||||
"""
|
||||
catalog_url = self.get_catalog_url()
|
||||
|
||||
# Match the ``_fetch_single_catalog`` cache contract: a poisoned
|
||||
# or unreadable cache silently falls through to a network refetch
|
||||
# rather than crashing the caller. ``_validate_catalog_payload``
|
||||
# is reused here so a cache written by an older client
|
||||
# (pre-validation) is rejected and refreshed instead of returning
|
||||
# the stale malformed payload.
|
||||
if not force_refresh and self.is_cache_valid():
|
||||
try:
|
||||
metadata = json.loads(self.cache_metadata_file.read_text())
|
||||
metadata = json.loads(
|
||||
self.cache_metadata_file.read_text(encoding="utf-8")
|
||||
)
|
||||
if metadata.get("catalog_url") == catalog_url:
|
||||
return json.loads(self.cache_file.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Cache is corrupt or unreadable; fall through to network fetch
|
||||
cached_data = json.loads(
|
||||
self.cache_file.read_text(encoding="utf-8")
|
||||
)
|
||||
self._validate_catalog_payload(cached_data, catalog_url)
|
||||
return cached_data
|
||||
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
|
||||
# Cache is corrupt, unreadable, or fails the shape check;
|
||||
# fall through to network fetch.
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "presets" not in catalog_data
|
||||
):
|
||||
raise PresetError("Invalid preset catalog format")
|
||||
# Validate catalog structure. Reuses the same helper as
|
||||
# ``_fetch_single_catalog`` so all three branches (root type,
|
||||
# missing keys, nested-mapping type) stay consistent.
|
||||
self._validate_catalog_payload(catalog_data, catalog_url)
|
||||
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(json.dumps(catalog_data, indent=2))
|
||||
# Save to cache. Explicit UTF-8 on both writes mirrors the
|
||||
# ``read_text(encoding="utf-8")`` on the read side and the
|
||||
# ``integrations/catalog.py`` precedent — otherwise platforms
|
||||
# whose default encoding isn't UTF-8 would write
|
||||
# locale-encoded bytes the read path can't decode, forcing an
|
||||
# unnecessary refetch on every invocation. Like the read
|
||||
# side, the write is best-effort: an unwritable cache dir
|
||||
# must not be re-raised as a ``PresetError`` for a payload
|
||||
# that was already fetched and validated.
|
||||
try:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_file.write_text(
|
||||
json.dumps(catalog_data, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2)
|
||||
)
|
||||
metadata = {
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog_url,
|
||||
}
|
||||
self.cache_metadata_file.write_text(
|
||||
json.dumps(metadata, indent=2), encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass # Cache is best-effort; proceed with fetched data
|
||||
|
||||
return catalog_data
|
||||
|
||||
|
||||
@@ -313,6 +313,8 @@ def install_shared_infra(
|
||||
expected = prior_hashes.get(rel)
|
||||
if not expected or not dst.is_file() or dst.is_symlink():
|
||||
return False
|
||||
if manifest.is_recovered(rel):
|
||||
return False
|
||||
try:
|
||||
return _sha256(dst) == expected
|
||||
except OSError:
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
@@ -47,6 +50,32 @@ def _write_invalid_manifest(project, key):
|
||||
return manifest
|
||||
|
||||
|
||||
def _copy_project_template(tmp_path, template):
|
||||
project = tmp_path / "proj"
|
||||
shutil.copytree(template, project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def status_copilot_template(tmp_path_factory):
|
||||
return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def status_claude_template(tmp_path_factory):
|
||||
return _init_project(tmp_path_factory.mktemp("status-claude"), "claude")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def copilot_project(tmp_path, status_copilot_template):
|
||||
return _copy_project_template(tmp_path, status_copilot_template)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_project(tmp_path, status_claude_template):
|
||||
return _copy_project_template(tmp_path, status_claude_template)
|
||||
|
||||
|
||||
def _integration_list_row_cells(output: str, key: str) -> list[str]:
|
||||
plain = strip_ansi(output)
|
||||
row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}"))
|
||||
@@ -126,6 +155,823 @@ class TestIntegrationList:
|
||||
assert "only supports schema 1" in normalized
|
||||
|
||||
|
||||
# ── status ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntegrationStatus:
|
||||
def test_status_requires_speckit_project(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "status"])
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_status_reports_healthy_project(self, copilot_project):
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Integration status: OK" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Shared templates target alignment: copilot" in result.output
|
||||
assert "Modified managed files: 0" in result.output
|
||||
assert "Missing managed files: 0" in result.output
|
||||
|
||||
def test_status_json_reports_healthy_project(self, copilot_project):
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["default_integration"] == "copilot"
|
||||
assert payload["installed_integrations"] == ["copilot"]
|
||||
assert payload["recorded_installed_integrations"] == ["copilot"]
|
||||
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
|
||||
assert payload["multi_install_safe"] is True
|
||||
assert payload["shared_templates_target_alignment"] == "copilot"
|
||||
assert "shared_templates_aligned_to" not in payload
|
||||
assert payload["findings"] == []
|
||||
|
||||
def test_status_reports_invalid_integration_json(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "integration-state-unreadable" in result.output
|
||||
assert "invalid JSON" in result.output
|
||||
assert "Detail:" in result.output
|
||||
assert "Multi-install safe: unknown" in result.output
|
||||
assert "Traceback" not in result.output
|
||||
|
||||
def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable(
|
||||
self,
|
||||
copilot_project,
|
||||
):
|
||||
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == []
|
||||
assert payload["findings"][0]["code"] == "integration-state-unreadable"
|
||||
assert "Detail:" in payload["findings"][0]["message"]
|
||||
|
||||
def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["integration_state_schema"] = 99
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["findings"][0]["code"] == "integration-state-unreadable"
|
||||
assert "schema 99" in payload["findings"][0]["message"]
|
||||
assert "supported schema: 1" in payload["findings"][0]["message"]
|
||||
|
||||
def test_status_reports_missing_integration_json(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integration.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "integration-state-missing" in result.output
|
||||
assert ".specify/integration.json is missing" in result.output
|
||||
assert "Multi-install safe: unknown" in result.output
|
||||
|
||||
def test_status_json_reports_unknown_multi_install_safety_when_state_missing(
|
||||
self,
|
||||
copilot_project,
|
||||
):
|
||||
(copilot_project / ".specify" / "integration.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == []
|
||||
assert payload["findings"][0]["code"] == "integration-state-missing"
|
||||
|
||||
def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"version": "test",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["findings"][0]["code"] == "no-installed-integrations"
|
||||
assert "speckit" in payload["manifests"]
|
||||
assert payload["manifests"]["speckit"]["readable"] is True
|
||||
|
||||
def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"version": "test",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["unchecked_manifests"] == 1
|
||||
assert any(
|
||||
item["code"] == "no-installed-integrations"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
assert any(
|
||||
item["code"] == "manifest-missing"
|
||||
and item["integration"] == "speckit"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_json_reports_missing_default_integration_as_error(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state.pop("default_integration", None)
|
||||
state.pop("integration", None)
|
||||
state["installed_integrations"] = ["claude"]
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "error"
|
||||
assert payload["default_integration"] is None
|
||||
assert any(
|
||||
item["code"] == "default-integration-missing"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state.pop("default_integration", None)
|
||||
state.pop("integration", None)
|
||||
state["installed_integrations"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == []
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert [item["code"] for item in payload["findings"]] == [
|
||||
"installed-integrations-invalid",
|
||||
"no-installed-integrations",
|
||||
]
|
||||
|
||||
def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project):
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["default_integration"] = "copilot"
|
||||
state["integration"] = "copilot"
|
||||
state["installed_integrations"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["installed_integrations"] == ["copilot"]
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert [item["code"] for item in payload["findings"]] == [
|
||||
"installed-integrations-invalid",
|
||||
]
|
||||
|
||||
def test_status_reports_default_integration_not_installed(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["default_integration"] = "codex"
|
||||
state["integration"] = "codex"
|
||||
state["installed_integrations"] = ["claude"]
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["default_integration"] == "codex"
|
||||
assert payload["installed_integrations"] == ["codex", "claude"]
|
||||
assert payload["recorded_installed_integrations"] == ["claude"]
|
||||
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
|
||||
assert any(
|
||||
item["code"] == "default-integration-not-installed"
|
||||
and "Default integration 'codex' is not listed" in item["message"]
|
||||
for item in payload["findings"]
|
||||
)
|
||||
assert "codex" not in payload["manifests"]
|
||||
assert not any(
|
||||
item["code"] == "manifest-missing" and item.get("integration") == "codex"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = []
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["installed_integrations"] == ["claude"]
|
||||
assert payload["recorded_installed_integrations"] == []
|
||||
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
|
||||
assert payload["multi_install_safe"] is None
|
||||
assert payload["manifests"]["claude"]["readable"] is True
|
||||
assert any(
|
||||
item["code"] == "default-integration-not-installed"
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_reports_missing_manifest(self, copilot_project):
|
||||
(copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "manifest-missing" in result.output
|
||||
assert "Manifest for integration 'copilot' is missing" in result.output
|
||||
|
||||
def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project):
|
||||
_write_invalid_manifest(copilot_project, "copilot")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["unchecked_manifests"] == 1
|
||||
assert payload["manifests"]["copilot"]["readable"] is False
|
||||
assert payload["manifests"]["copilot"]["missing_files"] == []
|
||||
assert payload["manifests"]["copilot"]["modified_files"] == []
|
||||
|
||||
def test_status_reports_modified_managed_files_without_failing(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
(copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Integration status: WARNING" in result.output
|
||||
assert "managed-files-modified" in result.output
|
||||
assert "Modified managed files: 1" in result.output
|
||||
|
||||
def test_status_reports_missing_managed_files(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
(copilot_project / first_rel).unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "managed-files-missing" in result.output
|
||||
assert "Missing managed files: 1" in result.output
|
||||
|
||||
def test_status_reports_missing_shared_managed_files(self, copilot_project):
|
||||
shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
assert shared_file.exists()
|
||||
shared_file.unlink()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "managed-files-missing" in result.output
|
||||
assert "shared Spec Kit infrastructure" in result.output
|
||||
assert "Missing managed files: 1" in result.output
|
||||
|
||||
def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
|
||||
def fail_exists(self):
|
||||
raise AssertionError(f"Path.exists() should not be used for {self}")
|
||||
|
||||
monkeypatch.setattr(Path, "exists", fail_exists)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project.resolve(),
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch):
|
||||
def fail_exists(self):
|
||||
raise AssertionError(f"Path.exists() should not be used for {self}")
|
||||
|
||||
monkeypatch.setattr(Path, "exists", fail_exists)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["manifests"]["copilot"]["readable"] is True
|
||||
|
||||
def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch):
|
||||
original_resolve = Path.resolve
|
||||
failed = {"done": False}
|
||||
|
||||
def fail_first_project_root_resolve(self, *args, **kwargs):
|
||||
if self == copilot_project and not failed["done"]:
|
||||
failed["done"] = True
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
|
||||
|
||||
def test_status_loads_manifests_when_project_root_resolution_keeps_failing(
|
||||
self,
|
||||
copilot_project,
|
||||
monkeypatch,
|
||||
):
|
||||
original_resolve = Path.resolve
|
||||
|
||||
def fail_project_root_resolve(self, *args, **kwargs):
|
||||
if self == copilot_project:
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_project_root_resolve)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["status"] == "warning"
|
||||
assert payload["manifests"]["copilot"]["readable"] is True
|
||||
assert payload["manifests"]["speckit"]["readable"] is True
|
||||
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
|
||||
|
||||
def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
real_project = tmp_path / "real-project"
|
||||
real_project.mkdir()
|
||||
tracked = real_project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
symlinked_project = tmp_path / "symlinked-project"
|
||||
try:
|
||||
symlinked_project.symlink_to(real_project, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
manifest = IntegrationManifest("test", real_project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
manifest.project_root = symlinked_project.absolute()
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
symlinked_project.absolute(),
|
||||
project_root_is_resolved=False,
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
project_root_resolved = project.resolve()
|
||||
original_resolve = Path.resolve
|
||||
|
||||
def fail_project_parent_resolve(self, *args, **kwargs):
|
||||
if self == project:
|
||||
raise RuntimeError("symlink loop")
|
||||
return original_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project_root_resolved,
|
||||
)
|
||||
|
||||
assert missing == []
|
||||
assert modified == []
|
||||
assert invalid == ["tracked.md"]
|
||||
assert valid == []
|
||||
|
||||
def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch):
|
||||
from specify_cli import integration_status as status_module
|
||||
|
||||
def fail_load(key, project_root, **kwargs):
|
||||
raise RuntimeError(f"unexpected manifest loader bug for {key}")
|
||||
|
||||
monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load)
|
||||
|
||||
with pytest.raises(RuntimeError, match="unexpected manifest loader bug"):
|
||||
status_module.build_integration_status_report(copilot_project)
|
||||
|
||||
def test_status_treats_dangling_symlink_as_missing(self, copilot_project):
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
target = copilot_project / first_rel
|
||||
target.unlink()
|
||||
try:
|
||||
target.symlink_to(copilot_project / "missing-target")
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert first_rel in payload["manifests"]["copilot"]["missing_files"]
|
||||
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch):
|
||||
from specify_cli.integration_status import _manifest_file_status
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
tracked = project / "tracked.md"
|
||||
tracked.write_text("content\n", encoding="utf-8")
|
||||
regular_stat = tracked.lstat()
|
||||
|
||||
manifest = IntegrationManifest("test", project, version="test")
|
||||
manifest.record_existing("tracked.md")
|
||||
|
||||
tracked.unlink()
|
||||
try:
|
||||
tracked.symlink_to(project / "missing-target")
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
original_lstat = Path.lstat
|
||||
original_is_symlink = Path.is_symlink
|
||||
|
||||
def windows_style_lstat(self):
|
||||
if self == tracked:
|
||||
return regular_stat
|
||||
return original_lstat(self)
|
||||
|
||||
def windows_style_is_symlink(self):
|
||||
if self == tracked:
|
||||
return True
|
||||
return original_is_symlink(self)
|
||||
|
||||
monkeypatch.setattr(Path, "lstat", windows_style_lstat)
|
||||
monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink)
|
||||
|
||||
missing, modified, invalid, valid = _manifest_file_status(
|
||||
manifest,
|
||||
project.resolve(),
|
||||
)
|
||||
|
||||
assert missing == ["tracked.md"]
|
||||
assert modified == []
|
||||
assert invalid == []
|
||||
assert valid == ["tracked.md"]
|
||||
|
||||
def test_strip_extended_length_prefix_normalizes_windows_paths(self):
|
||||
from specify_cli.integration_status import _strip_extended_length_prefix
|
||||
|
||||
# Build the prefixed strings explicitly so the test is meaningful on
|
||||
# every platform (POSIX won't parse backslash separators, but the
|
||||
# helper operates on the string form). Compare Path objects rather than
|
||||
# their str() form: on Windows pathlib renders a UNC root with a
|
||||
# trailing separator (``\\server\share\``), so an exact string match is
|
||||
# brittle, whereas Path equality captures the intended semantics on
|
||||
# both POSIX and Windows.
|
||||
bs = "\\"
|
||||
assert _strip_extended_length_prefix(
|
||||
Path(f"{bs}{bs}?{bs}C:{bs}proj")
|
||||
) == Path(f"C:{bs}proj")
|
||||
assert _strip_extended_length_prefix(
|
||||
Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share")
|
||||
) == Path(f"{bs}{bs}server{bs}share")
|
||||
# Paths without the prefix are returned unchanged.
|
||||
assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path")
|
||||
|
||||
def test_is_within_project_tolerates_extended_length_prefix(self):
|
||||
from specify_cli.integration_status import _is_within_project
|
||||
|
||||
# A readlink result on POSIX never carries the prefix, so an in-project
|
||||
# child is contained and an outside path is not. The Windows
|
||||
# prefix-stripping branch is exercised by the dangling-symlink tests on
|
||||
# Windows CI; here we lock in the cross-platform containment contract.
|
||||
root = Path("/tmp/project").resolve()
|
||||
assert _is_within_project(root, root / "child")
|
||||
assert not _is_within_project(root, Path("/tmp/other").resolve())
|
||||
|
||||
def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("outside project\n", encoding="utf-8")
|
||||
link = copilot_project / "outside-link"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_data["files"]["outside-link/secret.txt"] = "wrong"
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["invalid_manifest_paths"] == 1
|
||||
assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"]
|
||||
assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
outside_file = outside / "secret.txt"
|
||||
outside_file.write_text("outside project\n", encoding="utf-8")
|
||||
|
||||
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
|
||||
first_rel = next(iter(tracked_files))
|
||||
tracked_path = copilot_project / first_rel
|
||||
tracked_path.unlink()
|
||||
try:
|
||||
tracked_path.symlink_to(outside_file)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
original_stat = Path.stat
|
||||
|
||||
def fail_tracked_symlink_stat(self, *args, **kwargs):
|
||||
follows_symlinks = kwargs.get("follow_symlinks", True)
|
||||
if self == tracked_path and follows_symlinks:
|
||||
raise AssertionError("Path.stat() should not follow tracked symlinks")
|
||||
return original_stat(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat)
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["invalid_manifest_paths"] == 1
|
||||
assert first_rel in payload["manifests"]["copilot"]["invalid_files"]
|
||||
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
|
||||
|
||||
def test_status_reports_unsafe_multi_install_combination(self, copilot_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = copilot_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["copilot", "claude"]
|
||||
state["default_integration"] = "copilot"
|
||||
state["integration"] = "copilot"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
IntegrationManifest("claude", copilot_project, version="test").save()
|
||||
|
||||
result = _run_in_project(copilot_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "unsafe-multi-install" in result.output
|
||||
assert "Multi-install safe: no" in result.output
|
||||
assert "specify integration switch <key>" in result.output
|
||||
|
||||
def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["claude", "mystery"]
|
||||
state["default_integration"] = "claude"
|
||||
state["integration"] = "claude"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
IntegrationManifest("mystery", claude_project, version="test").save()
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "unknown-integration" in result.output
|
||||
assert "unsafe-multi-install" in result.output
|
||||
assert "remove the stale integration entry" in result.output
|
||||
assert "Multi-install safe: no" in result.output
|
||||
|
||||
def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["mystery"]
|
||||
state["default_integration"] = "mystery"
|
||||
state["integration"] = "mystery"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
manifest_finding = next(
|
||||
item for item in payload["findings"]
|
||||
if item["code"] == "manifest-missing" and item["integration"] == "mystery"
|
||||
)
|
||||
assert "remove the stale integration entry" in manifest_finding["suggestion"]
|
||||
assert "integration upgrade mystery" not in manifest_finding["suggestion"]
|
||||
|
||||
def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "../../../escape"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
outside_manifest = tmp_path / "escape.manifest.json"
|
||||
outside_manifest.write_text(
|
||||
json.dumps({"integration": unsafe_key, "files": {}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert unsafe_key not in payload["manifests"]
|
||||
assert payload["manifest_checked_integrations"] == ["speckit"]
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_rejects_filename_invalid_integration_keys(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "bad:key"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_rejects_windows_reserved_integration_keys(self, claude_project):
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
unsafe_key = "CON"
|
||||
state_path.write_text(
|
||||
json.dumps({
|
||||
"integration": unsafe_key,
|
||||
"default_integration": unsafe_key,
|
||||
"installed_integrations": [unsafe_key],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert any(
|
||||
item["code"] == "integration-key-invalid"
|
||||
and item["integration"] == unsafe_key
|
||||
for item in payload["findings"]
|
||||
)
|
||||
|
||||
def test_status_reports_managed_file_collisions(self, claude_project):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
state_path = claude_project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["installed_integrations"] = ["claude", "codex"]
|
||||
state["default_integration"] = "claude"
|
||||
state["integration"] = "claude"
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json"
|
||||
tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"]
|
||||
shared_rel = next(iter(tracked_files))
|
||||
codex_manifest = IntegrationManifest("codex", claude_project, version="test")
|
||||
codex_manifest.record_existing(shared_rel)
|
||||
codex_manifest.save()
|
||||
|
||||
result = _run_in_project(claude_project, ["integration", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "managed-file-collision" in result.output
|
||||
assert "Integration status: WARNING" in result.output
|
||||
|
||||
def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({
|
||||
"integration": "[red]x[/red]",
|
||||
"installed_integrations": ["[red]x[/red]"],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
result = runner.invoke(app, ["integration", "status", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["default_integration"] == "[red]x[/red]"
|
||||
assert payload["installed_integrations"] == ["[red]x[/red]"]
|
||||
|
||||
def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({
|
||||
"integration": "[red]x[/red]",
|
||||
"installed_integrations": ["[red]x[/red]"],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
result = runner.invoke(app, ["integration", "status"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Default integration: [red]x[/red]" in result.output
|
||||
assert "Installed integrations: [red]x[/red]" in result.output
|
||||
|
||||
|
||||
# ── install ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1072,6 +1918,45 @@ class TestIntegrationSwitch:
|
||||
assert "/speckit.plan" in updated
|
||||
assert "/speckit-plan" not in updated
|
||||
|
||||
def test_switch_preserves_recovered_files(self, tmp_path):
|
||||
"""Regression for #2918: files marked recovered in the manifest are not overwritten.
|
||||
|
||||
When a file already exists on disk before init and is recorded with
|
||||
``recovered=True``, ``integration use``/``switch`` must not treat it as
|
||||
managed even when the on-disk hash matches the manifest hash.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
assert shared_script.is_file()
|
||||
|
||||
# Simulate a team-customized file that was recorded as recovered:
|
||||
# write custom content, then update the manifest to record its hash
|
||||
# with the recovered flag set.
|
||||
custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
rel = ".specify/scripts/bash/setup-tasks.sh"
|
||||
manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
|
||||
manifest_data.setdefault("recovered_files", []).append(rel)
|
||||
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
|
||||
# Recovered file must NOT be overwritten — team content preserved.
|
||||
assert shared_script.read_bytes() == custom_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.
|
||||
|
||||
|
||||
@@ -793,6 +793,35 @@ class TestRedirectStripping:
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
def test_https_to_http_same_host_redirect_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", {},
|
||||
"http://github.com/org/repo")
|
||||
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_redirect_validator_can_reject_before_following_redirect(self):
|
||||
import urllib.error
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
def reject_http(old_url, new_url):
|
||||
if new_url.startswith("http://"):
|
||||
raise urllib.error.URLError("scheme downgrade")
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",), reject_http)
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
|
||||
with pytest.raises(urllib.error.URLError, match="scheme downgrade"):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"http://github.com/org/repo")
|
||||
|
||||
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
|
||||
|
||||
@@ -303,6 +303,135 @@ class TestExtensionSkillRegistration:
|
||||
assert "description" in parsed
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
|
||||
def test_argument_hint_preserved_for_extension_command(
|
||||
self, skills_project, temp_dir
|
||||
):
|
||||
"""argument-hint from an extension command must survive into SKILL.md.
|
||||
|
||||
Regression for #2903: the field was dropped for extension-provided
|
||||
commands while being kept for core template commands. The source
|
||||
description is intentionally long so it folds across multiple lines
|
||||
when serialized, guarding against an in-place string injection that
|
||||
would split the folded scalar and produce invalid YAML.
|
||||
"""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
long_description = (
|
||||
"Build and maintain a lean, static context/ knowledge folder so "
|
||||
"coding agents load only what is relevant and save tokens"
|
||||
)
|
||||
arg_hint = "<init | update | list | check> [area] [slug] [-- notes]"
|
||||
|
||||
ext_dir = temp_dir / "hint-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "hint-ext",
|
||||
"name": "Hint Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Extension exercising argument-hint preservation",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.hint-ext.build-context",
|
||||
"file": "commands/build-context.md",
|
||||
"description": long_description,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
(commands_dir / "build-context.md").write_text(
|
||||
"---\n"
|
||||
f'description: "{long_description}"\n'
|
||||
f'argument-hint: "{arg_hint}"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Build Context\n"
|
||||
"\n"
|
||||
"Do the thing.\n"
|
||||
"$ARGUMENTS\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
skill_file = skills_dir / "speckit-hint-ext-build-context" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
|
||||
# Frontmatter must parse cleanly even though the description folds.
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["argument-hint"] == arg_hint
|
||||
assert parsed["description"] == long_description
|
||||
|
||||
def test_argument_hint_not_added_for_non_claude_agent(self, project_dir, temp_dir):
|
||||
"""argument-hint must stay Claude-only — other skills agents are untouched.
|
||||
|
||||
The hint is carried only for integrations that support it (currently
|
||||
Claude, the sole integration defining inject_argument_hint). A non-Claude
|
||||
skills agent such as kimi must keep the shared build_skill_frontmatter
|
||||
shape (name/description/compatibility/metadata) with no argument-hint.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="kimi", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
||||
|
||||
arg_hint = "<init | update | list | check> [area]"
|
||||
ext_dir = temp_dir / "hint-ext-kimi"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "hint-ext-kimi",
|
||||
"name": "Hint Extension Kimi",
|
||||
"version": "1.0.0",
|
||||
"description": "Extension exercising argument-hint gating",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.hint-ext-kimi.build-context",
|
||||
"file": "commands/build-context.md",
|
||||
"description": "Build context",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
(commands_dir / "build-context.md").write_text(
|
||||
"---\n"
|
||||
'description: "Build context"\n'
|
||||
f'argument-hint: "{arg_hint}"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Build Context\n"
|
||||
"\n"
|
||||
"Do the thing.\n"
|
||||
"$ARGUMENTS\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
skill_file = skills_dir / "speckit-hint-ext-kimi-build-context" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
|
||||
assert "argument-hint" not in parsed
|
||||
|
||||
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
||||
"""No skills should be created when ai_skills is false."""
|
||||
manager = ExtensionManager(no_skills_project)
|
||||
|
||||
@@ -24,6 +24,7 @@ from specify_cli.extensions import (
|
||||
CatalogEntry,
|
||||
CORE_COMMAND_NAMES,
|
||||
DEFAULT_HOOK_PRIORITY,
|
||||
VALID_EFFECTS,
|
||||
ExtensionManifest,
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
@@ -300,6 +301,69 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid version"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_valid_category(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with various category values (free-form string)."""
|
||||
import yaml
|
||||
|
||||
for category in ("docs", "code", "process", "integration", "visibility", "custom-category"):
|
||||
valid_manifest_data["extension"]["category"] = category
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
assert manifest.category == category
|
||||
|
||||
def test_valid_effect(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with valid effect values."""
|
||||
import yaml
|
||||
|
||||
for effect in sorted(VALID_EFFECTS):
|
||||
valid_manifest_data["extension"]["effect"] = effect
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
assert manifest.effect == effect
|
||||
|
||||
def test_invalid_category(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with empty category raises ValidationError."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["extension"]["category"] = ""
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="Invalid extension.category"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_invalid_effect(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with invalid effect raises ValidationError."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["extension"]["effect"] = "write-only"
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="Invalid extension.effect"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_category_and_effect_optional(self, temp_dir, valid_manifest_data):
|
||||
"""Test that omitting category and effect still passes validation."""
|
||||
import yaml
|
||||
|
||||
# Ensure no category/effect in data
|
||||
valid_manifest_data["extension"].pop("category", None)
|
||||
valid_manifest_data["extension"].pop("effect", None)
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
assert manifest.category is None
|
||||
assert manifest.effect is None
|
||||
|
||||
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
|
||||
import yaml
|
||||
@@ -3087,6 +3151,424 @@ class TestExtensionCatalog:
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
# Root is not a JSON object.
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
# Root is fine but ``extensions`` is the wrong type.
|
||||
{"schema_version": "1.0", "extensions": []},
|
||||
{"schema_version": "1.0", "extensions": "oops"},
|
||||
{"schema_version": "1.0", "extensions": None},
|
||||
{"schema_version": "1.0", "extensions": 42},
|
||||
],
|
||||
)
|
||||
def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload):
|
||||
"""Malformed catalog payloads raise ExtensionError, not AttributeError.
|
||||
|
||||
Without this guard, a payload like ``{"extensions": []}`` would pass the
|
||||
key-presence check and then crash with ``AttributeError: 'list' object
|
||||
has no attribute 'items'`` deep inside ``_get_merged_extensions``. The
|
||||
sibling integration catalog reader already validates both the root
|
||||
object and the nested mapping (see ``integrations/catalog.py``); the
|
||||
extension catalog must stay consistent.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = CatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(ExtensionError, match="Invalid catalog format"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cached_payload",
|
||||
[
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
{"schema_version": "1.0", "extensions": []},
|
||||
{"schema_version": "1.0", "extensions": "oops"},
|
||||
{"schema_version": "1.0", "extensions": None},
|
||||
],
|
||||
)
|
||||
def test_fetch_single_catalog_rejects_malformed_cached_payload(
|
||||
self, temp_dir, cached_payload
|
||||
):
|
||||
"""A poisoned cache silently falls back to the network instead of
|
||||
crashing — cached payloads pass through the same shape validation
|
||||
as freshly-fetched ones.
|
||||
|
||||
Without this, a cache poisoned by an older spec-kit version (or a
|
||||
manual edit, or an upstream that briefly served a bad payload
|
||||
before the network guards landed) would re-crash every invocation
|
||||
of ``_get_merged_extensions`` despite the cache being "valid" by
|
||||
age. The recovery contract is: if the cached payload fails
|
||||
validation, drop it and refetch — never propagate
|
||||
``AttributeError`` to the caller.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` is the
|
||||
# branch that goes through ``is_cache_valid()`` (the non-default
|
||||
# branch uses per-URL hashed cache files but the same code path
|
||||
# below).
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text(json.dumps(cached_payload))
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Network refetch returns a valid payload so the recovery path
|
||||
# can complete.
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = CatalogEntry(
|
||||
url=ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog._fetch_single_catalog(entry, force_refresh=False)
|
||||
|
||||
# The poisoned cache was discarded and the network payload returned.
|
||||
assert result == valid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
# Root is not a JSON object.
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
# Root is fine but ``extensions`` is the wrong type.
|
||||
{"schema_version": "1.0", "extensions": []},
|
||||
{"schema_version": "1.0", "extensions": "oops"},
|
||||
{"schema_version": "1.0", "extensions": None},
|
||||
],
|
||||
)
|
||||
def test_fetch_catalog_rejects_malformed_payload(self, temp_dir, payload):
|
||||
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
|
||||
|
||||
Before this change ``fetch_catalog`` only checked key presence — so
|
||||
a payload like ``42`` would crash with
|
||||
``TypeError: argument of type 'int' is not iterable`` during the
|
||||
``"schema_version" in catalog_data`` check, and an entry mapping
|
||||
of the wrong type would crash downstream. Reusing
|
||||
``_validate_catalog_payload`` keeps the network-side behaviour of
|
||||
the legacy single-catalog method consistent with the multi-catalog
|
||||
``_fetch_single_catalog`` path.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(ExtensionError, match="Invalid catalog format"):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
def test_fetch_catalog_recovers_from_unreadable_cache(self, temp_dir):
|
||||
"""An unreadable / wrong-encoded cache file silently refetches.
|
||||
|
||||
The cache contract is best-effort: a JSON-decode failure, an OS
|
||||
read failure (permissions / disk / handle limit), or an invalid
|
||||
text encoding on a cache file written by an older client must
|
||||
all fall through to the network fetch rather than crash the
|
||||
caller. Covers Copilot's review point that the previous
|
||||
``except (json.JSONDecodeError,)`` was too narrow.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
# Write invalid UTF-8 bytes to the cache file so ``read_text``
|
||||
# raises ``UnicodeDecodeError`` (a subclass of ``UnicodeError``).
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
|
||||
# Recovered via network rather than crashing on the unreadable cache.
|
||||
assert result == valid
|
||||
|
||||
def test_fetch_catalog_recovers_from_unreadable_metadata(self, temp_dir):
|
||||
"""A wrongly-encoded metadata file degrades to a cache miss.
|
||||
|
||||
``is_cache_valid`` is consulted *before* the cache payload is
|
||||
read; if the metadata file itself can't be decoded (e.g. it was
|
||||
written on a Windows host whose default codec isn't UTF-8) the
|
||||
validity check must return ``False`` rather than propagate
|
||||
``UnicodeDecodeError``. Without that guard, a corrupted metadata
|
||||
file would crash every invocation instead of falling through to
|
||||
a network refetch.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}", encoding="utf-8")
|
||||
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
|
||||
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
|
||||
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
|
||||
|
||||
# is_cache_valid must absorb the decode failure, not crash.
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
|
||||
assert result == valid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"non_mapping_metadata",
|
||||
[
|
||||
"[]", # JSON array
|
||||
'"oops"', # JSON string
|
||||
"42", # JSON number
|
||||
"true", # JSON bool
|
||||
"null", # JSON null
|
||||
],
|
||||
)
|
||||
def test_is_cache_valid_handles_non_mapping_metadata(
|
||||
self, temp_dir, non_mapping_metadata
|
||||
):
|
||||
"""Metadata that parses to a non-mapping degrades to cache-invalid.
|
||||
|
||||
The cache-validity check calls ``metadata.get("cached_at", "")``
|
||||
immediately after ``json.loads``. If the metadata file is valid
|
||||
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
|
||||
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
|
||||
previously slipped past the except tuple and crashed the
|
||||
caller. The contract documented on ``is_cache_valid`` says any
|
||||
decode/shape failure should return ``False`` so ``fetch_catalog``
|
||||
falls through to a network refetch. This test pins that
|
||||
contract across every JSON non-mapping root type so a regression
|
||||
in the except clause can't silently re-introduce the crash.
|
||||
"""
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}", encoding="utf-8")
|
||||
catalog.cache_metadata_file.write_text(
|
||||
non_mapping_metadata, encoding="utf-8"
|
||||
)
|
||||
|
||||
# Must not raise — the contract is "any decode/shape failure → False".
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_fetch_catalog_writes_cache_as_utf8(self, temp_dir, monkeypatch):
|
||||
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
|
||||
|
||||
The earlier version of this test claimed to assert UTF-8 at the
|
||||
byte level but actually only round-tripped a non-ASCII string
|
||||
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
|
||||
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
|
||||
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
|
||||
ever reached ``write_text`` — the bytes on disk were identical
|
||||
regardless of the encoding kwarg, so a locale-encoded write
|
||||
would have round-tripped just fine. The drift Copilot's review
|
||||
flagged wasn't actually being caught.
|
||||
|
||||
Fix: directly observe the ``encoding`` argument passed to every
|
||||
``write_text`` call made against the cache directory. This is
|
||||
the production code's encoding choice, which is exactly what
|
||||
the regression guard cares about; non-ASCII payload tricks are
|
||||
unnecessary because the assertion is about the kwarg, not the
|
||||
bytes.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path as _PathCls
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Record every ``write_text`` call's encoding kwarg so the
|
||||
# assertion observes the production writer's argument directly.
|
||||
recorded: list[dict] = []
|
||||
real_write_text = _PathCls.write_text
|
||||
|
||||
def recording_write_text(self, data, *args, **kwargs):
|
||||
recorded.append(
|
||||
{"path": str(self), "encoding": kwargs.get("encoding")}
|
||||
)
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
# Filter to writes inside the catalog's cache directory so
|
||||
# unrelated writes from other machinery don't pollute the
|
||||
# assertion.
|
||||
cache_writes = [
|
||||
r for r in recorded if str(catalog.cache_dir) in r["path"]
|
||||
]
|
||||
assert cache_writes, "fetch_catalog made no writes to the cache dir"
|
||||
for record in cache_writes:
|
||||
assert record["encoding"] == "utf-8", (
|
||||
f"write_text on {record['path']} used encoding "
|
||||
f"{record['encoding']!r}; expected 'utf-8'"
|
||||
)
|
||||
|
||||
def test_fetch_catalog_survives_unwritable_cache(self, temp_dir, monkeypatch):
|
||||
"""An unwritable cache dir doesn't fail a successful fetch.
|
||||
|
||||
Cache writes are best-effort, mirroring the read side and the
|
||||
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
|
||||
raises ``OSError`` (read-only checkout, permissions), the
|
||||
already-fetched-and-validated payload must still be returned
|
||||
rather than surfacing the cache failure to the caller.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path as _PathCls
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Simulate an unwritable cache dir: every write_text under the
|
||||
# cache directory raises PermissionError (an OSError subclass).
|
||||
real_write_text = _PathCls.write_text
|
||||
|
||||
def failing_write_text(self, data, *args, **kwargs):
|
||||
if str(catalog.cache_dir) in str(self):
|
||||
raise PermissionError("cache dir is read-only")
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
# Legacy single-catalog path.
|
||||
assert catalog.fetch_catalog(force_refresh=True) == valid
|
||||
|
||||
# Multi-catalog path.
|
||||
entry = CatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
assert catalog._fetch_single_catalog(entry, force_refresh=True) == valid
|
||||
|
||||
def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir):
|
||||
"""Per-entry guard: one malformed entry shouldn't poison the merge.
|
||||
|
||||
``_fetch_single_catalog`` validates that ``extensions`` is a mapping,
|
||||
but it doesn't (and shouldn't) validate every entry inside it — a
|
||||
single bad entry in an otherwise-valid catalog should be skipped, not
|
||||
crash the whole resolve path. Mirrors the per-entry skip in
|
||||
``integrations/catalog.py``: a malformed entry returns no error,
|
||||
valid entries continue to merge normally.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
# Mix of valid entry, list-shaped entry, and string-shaped entry.
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"good": {"name": "Good", "version": "1.0.0"},
|
||||
"bad-list": [],
|
||||
"bad-str": "oops",
|
||||
},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = CatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response), \
|
||||
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
|
||||
merged = catalog._get_merged_extensions(force_refresh=True)
|
||||
|
||||
# Only the well-formed entry survives; the two malformed entries are
|
||||
# silently dropped rather than raising or crashing.
|
||||
assert [ext["id"] for ext in merged] == ["good"]
|
||||
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@@ -187,4 +187,4 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
capturing_open,
|
||||
)
|
||||
assert len(captured_urls) == 1
|
||||
assert "releases/tags/v1%23beta" in captured_urls[0]
|
||||
assert "releases/tags/v1%23beta" in captured_urls[0]
|
||||
|
||||
@@ -11,6 +11,7 @@ Tests cover:
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
@@ -18,6 +19,7 @@ import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -1514,6 +1516,421 @@ class TestPresetCatalog:
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
# Root is not a JSON object.
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
# Root is fine but ``presets`` is the wrong type.
|
||||
{"schema_version": "1.0", "presets": []},
|
||||
{"schema_version": "1.0", "presets": "oops"},
|
||||
{"schema_version": "1.0", "presets": None},
|
||||
{"schema_version": "1.0", "presets": 42},
|
||||
],
|
||||
)
|
||||
def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, payload):
|
||||
"""Malformed catalog payloads raise PresetError, not AttributeError.
|
||||
|
||||
Without this guard, a payload like ``{"presets": []}`` would pass the
|
||||
key-presence check and then crash with ``AttributeError: 'list' object
|
||||
has no attribute 'items'`` deep inside ``_get_merged_packs``. The
|
||||
sibling integration catalog reader already validates both the root
|
||||
object and the nested mapping (see ``integrations/catalog.py``); the
|
||||
preset catalog must stay consistent.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(PresetError, match="Invalid preset catalog format"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cached_payload",
|
||||
[
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
{"schema_version": "1.0", "presets": []},
|
||||
{"schema_version": "1.0", "presets": "oops"},
|
||||
{"schema_version": "1.0", "presets": None},
|
||||
],
|
||||
)
|
||||
def test_fetch_single_catalog_rejects_malformed_cached_payload(
|
||||
self, project_dir, cached_payload
|
||||
):
|
||||
"""A poisoned cache silently falls back to the network instead of
|
||||
crashing — cached payloads pass through the same shape validation
|
||||
as freshly-fetched ones.
|
||||
|
||||
Without this, a cache poisoned by an older spec-kit version (or a
|
||||
manual edit, or an upstream that briefly served a bad payload
|
||||
before the network guards landed) would re-crash every invocation
|
||||
of ``_get_merged_packs`` despite the cache being "valid" by age.
|
||||
The recovery contract is: if the cached payload fails validation,
|
||||
drop it and refetch — never propagate ``AttributeError`` to the
|
||||
caller.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and
|
||||
# non-default URLs both flow through the same cache-load branch.
|
||||
cache_file, metadata_file = catalog._get_cache_paths(
|
||||
catalog.DEFAULT_CATALOG_URL
|
||||
)
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(json.dumps(cached_payload))
|
||||
metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog.DEFAULT_CATALOG_URL,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Network refetch returns a valid payload so the recovery path
|
||||
# can complete.
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url=catalog.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog._fetch_single_catalog(entry, force_refresh=False)
|
||||
|
||||
# The poisoned cache was discarded and the network payload returned.
|
||||
assert result == valid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
# Root is not a JSON object.
|
||||
[],
|
||||
"oops",
|
||||
42,
|
||||
None,
|
||||
# Root is fine but ``presets`` is the wrong type.
|
||||
{"schema_version": "1.0", "presets": []},
|
||||
{"schema_version": "1.0", "presets": "oops"},
|
||||
{"schema_version": "1.0", "presets": None},
|
||||
],
|
||||
)
|
||||
def test_fetch_catalog_rejects_malformed_payload(self, project_dir, payload):
|
||||
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
|
||||
|
||||
Before this change ``fetch_catalog`` only checked key presence —
|
||||
so a payload like ``42`` would crash with
|
||||
``TypeError: argument of type 'int' is not iterable`` during the
|
||||
``"schema_version" in catalog_data`` check, and an entry mapping
|
||||
of the wrong type would crash downstream. Reusing
|
||||
``_validate_catalog_payload`` keeps the network-side behaviour of
|
||||
the legacy single-catalog method consistent with the multi-catalog
|
||||
``_fetch_single_catalog`` path.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(PresetError, match="Invalid preset catalog format"):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
def test_fetch_catalog_recovers_from_unreadable_cache(self, project_dir):
|
||||
"""An unreadable / wrong-encoded cache file silently refetches.
|
||||
|
||||
The cache contract is best-effort: a JSON-decode failure, an OS
|
||||
read failure (permissions / disk / handle limit), or an invalid
|
||||
text encoding on a cache file written by an older client must
|
||||
all fall through to the network fetch rather than crash the
|
||||
caller. Covers Copilot's review point that the previous
|
||||
``except (json.JSONDecodeError, OSError)`` was missing
|
||||
``UnicodeError``.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Invalid UTF-8 bytes so ``read_text`` raises ``UnicodeDecodeError``
|
||||
# (a subclass of ``UnicodeError``).
|
||||
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
|
||||
catalog.cache_metadata_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
"catalog_url": catalog.get_catalog_url(),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
|
||||
# Recovered via network rather than crashing on the unreadable cache.
|
||||
assert result == valid
|
||||
|
||||
def test_fetch_catalog_recovers_from_unreadable_metadata(self, project_dir):
|
||||
"""A wrongly-encoded metadata file degrades to a cache miss.
|
||||
|
||||
``is_cache_valid`` is consulted *before* the cache payload is
|
||||
read; if the metadata file itself can't be decoded (e.g. it was
|
||||
written on a host whose default codec isn't UTF-8) the validity
|
||||
check must return ``False`` rather than propagate
|
||||
``UnicodeDecodeError``. Without that guard, a corrupted metadata
|
||||
file would crash every invocation instead of falling through to
|
||||
a network refetch.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}", encoding="utf-8")
|
||||
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
|
||||
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
|
||||
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
|
||||
|
||||
# is_cache_valid must absorb the decode failure, not crash.
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
|
||||
assert result == valid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"non_mapping_metadata",
|
||||
[
|
||||
"[]", # JSON array
|
||||
'"oops"', # JSON string
|
||||
"42", # JSON number
|
||||
"true", # JSON bool
|
||||
"null", # JSON null
|
||||
],
|
||||
)
|
||||
def test_is_cache_valid_handles_non_mapping_metadata(
|
||||
self, project_dir, non_mapping_metadata
|
||||
):
|
||||
"""Metadata that parses to a non-mapping degrades to cache-invalid.
|
||||
|
||||
The cache-validity check calls ``metadata.get("cached_at", "")``
|
||||
immediately after ``json.loads``. If the metadata file is valid
|
||||
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
|
||||
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
|
||||
previously slipped past the except tuple and crashed the
|
||||
caller. The contract documented on ``is_cache_valid`` says any
|
||||
decode/shape failure should return ``False`` so ``fetch_catalog``
|
||||
falls through to a network refetch. This test pins that
|
||||
contract across every JSON non-mapping root type.
|
||||
"""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
catalog.cache_file.write_text("{}", encoding="utf-8")
|
||||
catalog.cache_metadata_file.write_text(
|
||||
non_mapping_metadata, encoding="utf-8"
|
||||
)
|
||||
|
||||
# Must not raise — the contract is "any decode/shape failure → False".
|
||||
assert catalog.is_cache_valid() is False
|
||||
|
||||
def test_fetch_catalog_writes_cache_as_utf8(self, project_dir, monkeypatch):
|
||||
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
|
||||
|
||||
The earlier version of this test claimed to assert UTF-8 at the
|
||||
byte level but actually only round-tripped a non-ASCII string
|
||||
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
|
||||
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
|
||||
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
|
||||
ever reached ``write_text`` — the bytes on disk were identical
|
||||
regardless of the encoding kwarg. The drift Copilot's review
|
||||
flagged wasn't actually being caught.
|
||||
|
||||
Fix: directly observe the ``encoding`` argument passed to every
|
||||
``write_text`` call made against the cache directory. This is
|
||||
the production code's encoding choice, which is exactly what
|
||||
the regression guard cares about.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path as _PathCls
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Record every ``write_text`` call's encoding kwarg so the
|
||||
# assertion observes the production writer's argument directly.
|
||||
recorded: list[dict] = []
|
||||
real_write_text = _PathCls.write_text
|
||||
|
||||
def recording_write_text(self, data, *args, **kwargs):
|
||||
recorded.append(
|
||||
{"path": str(self), "encoding": kwargs.get("encoding")}
|
||||
)
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
cache_writes = [
|
||||
r for r in recorded if str(catalog.cache_dir) in r["path"]
|
||||
]
|
||||
assert cache_writes, "fetch_catalog made no writes to the cache dir"
|
||||
for record in cache_writes:
|
||||
assert record["encoding"] == "utf-8", (
|
||||
f"write_text on {record['path']} used encoding "
|
||||
f"{record['encoding']!r}; expected 'utf-8'"
|
||||
)
|
||||
|
||||
def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch):
|
||||
"""An unwritable cache dir doesn't fail a successful fetch.
|
||||
|
||||
Cache writes are best-effort, mirroring the read side and the
|
||||
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
|
||||
raises ``OSError`` (read-only checkout, permissions), the
|
||||
already-fetched-and-validated payload must still be returned —
|
||||
not swallowed into the broad except and re-raised as a
|
||||
``PresetError``.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path as _PathCls
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
valid = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Simulate an unwritable cache dir: every write_text under the
|
||||
# cache directory raises PermissionError (an OSError subclass).
|
||||
real_write_text = _PathCls.write_text
|
||||
|
||||
def failing_write_text(self, data, *args, **kwargs):
|
||||
if str(catalog.cache_dir) in str(self):
|
||||
raise PermissionError("cache dir is read-only")
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
# Legacy single-catalog path.
|
||||
assert catalog.fetch_catalog(force_refresh=True) == valid
|
||||
|
||||
# Multi-catalog path.
|
||||
entry = PresetCatalogEntry(
|
||||
url=catalog.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
assert (
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True) == valid
|
||||
)
|
||||
|
||||
def test_get_merged_packs_skips_non_mapping_entries(self, project_dir):
|
||||
"""Per-entry guard: one malformed entry shouldn't poison the merge.
|
||||
|
||||
``_fetch_single_catalog`` validates that ``presets`` is a mapping,
|
||||
but it doesn't (and shouldn't) validate every entry inside it — a
|
||||
single bad entry in an otherwise-valid catalog should be skipped,
|
||||
not crash the whole resolve path. Mirrors the per-entry skip in
|
||||
``integrations/catalog.py``: a malformed entry returns no error,
|
||||
valid entries continue to merge normally.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {
|
||||
"good": {"name": "Good", "version": "1.0.0"},
|
||||
"bad-list": [],
|
||||
"bad-str": "oops",
|
||||
},
|
||||
}
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response), \
|
||||
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
|
||||
merged = catalog._get_merged_packs(force_refresh=True)
|
||||
|
||||
# Only the well-formed entry survives; the two malformed entries are
|
||||
# silently dropped rather than raising or crashing.
|
||||
assert list(merged.keys()) == ["good"]
|
||||
|
||||
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
|
||||
"""download_pack passes Authorization header when configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -3842,6 +4259,141 @@ class TestBundledPresetLocator:
|
||||
assert "Lean Workflow" in result.output
|
||||
assert "installed" in result.output.lower()
|
||||
|
||||
def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch):
|
||||
"""URL installs reject redirects from HTTPS to non-loopback HTTP."""
|
||||
import typer
|
||||
from specify_cli import preset_add
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def geturl(self):
|
||||
return "http://example.com/preset.zip"
|
||||
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
assert redirect_validator is not None
|
||||
redirect_validator(url, "http://example.com/preset.zip")
|
||||
return FakeResponse(b"zip")
|
||||
|
||||
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
||||
|
||||
installed = False
|
||||
|
||||
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
|
||||
nonlocal installed
|
||||
installed = True
|
||||
|
||||
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
|
||||
|
||||
with pytest.raises(typer.Exit) as exc_info:
|
||||
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
|
||||
|
||||
assert exc_info.value.exit_code == 1
|
||||
assert installed is False
|
||||
|
||||
def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir):
|
||||
"""URL installs reject HTTPS URLs without a hostname before downloading."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url") as open_url:
|
||||
result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
output = strip_ansi(result.output)
|
||||
assert "URL must use HTTPS with a hostname" in output
|
||||
assert "got https://" not in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
|
||||
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
|
||||
import typer
|
||||
from specify_cli import preset_add
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def geturl(self):
|
||||
return "https:///preset.zip"
|
||||
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"),
|
||||
)
|
||||
monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(typer.Exit) as exc_info:
|
||||
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
|
||||
|
||||
assert exc_info.value.exit_code == 1
|
||||
output = strip_ansi(capsys.readouterr().out)
|
||||
assert "redirected to a disallowed URL" in output
|
||||
assert "must use HTTPS with a hostname" in output
|
||||
|
||||
def test_preset_add_from_url_streams_download_to_zip(self, project_dir, monkeypatch):
|
||||
"""URL installs stream response bytes to disk before installing the ZIP."""
|
||||
from specify_cli import preset_add
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __init__(self, data):
|
||||
super().__init__(data)
|
||||
self.read_sizes = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def geturl(self):
|
||||
return "https://example.com/preset.zip"
|
||||
|
||||
def read(self, size=-1):
|
||||
assert size not in (-1, None)
|
||||
self.read_sizes.append(size)
|
||||
return super().read(size)
|
||||
|
||||
response = FakeResponse(b"zip-bytes")
|
||||
installed = {}
|
||||
|
||||
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
|
||||
installed["zip_bytes"] = Path(zip_path).read_bytes()
|
||||
installed["speckit_version"] = speckit_version
|
||||
installed["priority"] = priority
|
||||
return SimpleNamespace(name="Test Preset", version="1.0.0")
|
||||
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
lambda url, timeout=None, extra_headers=None, redirect_validator=None: response,
|
||||
)
|
||||
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
|
||||
|
||||
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7)
|
||||
|
||||
assert response.read_sizes
|
||||
assert installed == {
|
||||
"zip_bytes": b"zip-bytes",
|
||||
"speckit_version": "0.6.0",
|
||||
"priority": 7,
|
||||
}
|
||||
|
||||
def test_bundled_preset_in_catalog(self):
|
||||
"""Verify the lean preset is listed in catalog.json with bundled marker."""
|
||||
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
|
||||
@@ -3931,7 +4483,7 @@ class TestPresetAddFromUrlResolution:
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
@@ -3989,7 +4541,7 @@ class TestPresetAddFromUrlResolution:
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
return FakeResponse(zip_bytes)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user