mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b91c1eda4 | ||
|
|
99a3b7ccab | ||
|
|
e742b8010a | ||
|
|
73093954e2 | ||
|
|
d83b8d1188 | ||
|
|
d7b6626218 | ||
|
|
2537be8144 | ||
|
|
6ab0c1dac1 | ||
|
|
d956ab722b | ||
|
|
e48f134c3b | ||
|
|
654793b659 | ||
|
|
a8d3038ece | ||
|
|
5f59a5b238 | ||
|
|
3c9aa1f81b | ||
|
|
52c1acf8ba | ||
|
|
fc1a3fd76c | ||
|
|
993083405e | ||
|
|
801ff888ff | ||
|
|
c05a626cbc | ||
|
|
0acb5c6461 | ||
|
|
a965413a24 | ||
|
|
8cb0889f4a |
46
CHANGELOG.md
46
CHANGELOG.md
@@ -2,6 +2,52 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.15] - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Autonomous Run Governance preset to v0.1.4 (#3511)
|
||||
- fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
|
||||
- fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)
|
||||
- fix: add trailing newline to init-options.json output (#3509)
|
||||
- feat(workflows): align workflow CLI with extension command surface (#3419)
|
||||
- fix(extensions): stop env-var config leaking across prefix-colliding extension IDs (#3497)
|
||||
- fix(integrations): escape control characters in goose recipe YAML renderer (#3384)
|
||||
- [extension] Update DocGuard — CDD Enforcement extension to v0.32.0 (#3489)
|
||||
- [extension] Add Multi-Repo Branch Sync extension to community catalog (#3411)
|
||||
- chore: release 0.12.14, begin 0.12.15.dev0 development (#3506)
|
||||
|
||||
## [0.12.14] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- [extension] Add Spec Kit Memory extension to community catalog (#3455)
|
||||
- Add Test-First Governance preset to community catalog (#3504)
|
||||
- Add Autonomous Run Governance preset to community catalog (#3501)
|
||||
- fix(workflows): validate command step input/options are mappings (#3262)
|
||||
- fix(presets): resolve() honors manifest-declared file: for installed presets (#3351)
|
||||
- fix(init): don't block on confirmation for 'init --here' without a TTY (#3236)
|
||||
- [extension] Add Quality Gates (Enforcement Layer) extension to community catalog (#3431)
|
||||
- fix(integrations): exit cleanly on unbalanced quote in --integration-options (#3457) (#3466)
|
||||
- fix(integrations): declare kiro-cli multi-install safe (#3471) (#3485)
|
||||
- fix(workflows): fail fan-in step on non-list wait_for instead of crashing (#3482)
|
||||
- chore: release 0.12.13, begin 0.12.14.dev0 development (#3498)
|
||||
|
||||
## [0.12.13] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): fail switch step on non-mapping cases instead of crashing (#3481)
|
||||
- Cleanup agent-file-template.md (#2579)
|
||||
- fix: mark Kiro integration as multi-install safe (#3472)
|
||||
- fix: rewrite extension-relative subdir paths in generated command bodies (#3444)
|
||||
- fix(templates): point constitution sync checklist at installed command files (#3418)
|
||||
- feat(workflows): make shell step timeout configurable (#3327) (#3328)
|
||||
- docs: clarify that release tags keep the leading v prefix (#3463)
|
||||
- fix(workflows): don't crash on membership test against a non-iterable (#3448)
|
||||
- fix(workflows): if-step validate accepts falsy non-list else (#3264)
|
||||
- chore: release 0.12.12, begin 0.12.13.dev0 development (#3490)
|
||||
|
||||
## [0.12.12] - 2026-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -51,7 +51,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
|
||||
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| DocGuard — CDD Enforcement | Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
|
||||
| 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) |
|
||||
@@ -84,6 +84,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MemoryLint | Evidence-driven instruction drift checker: audits agent memory files for boundary, reality, conflict, and redundancy drift. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
|
||||
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
@@ -98,6 +99,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity). | `process` | Read+Write | [spec-gates](https://github.com/schwichtgit/spec-gates) |
|
||||
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
|
||||
| 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) |
|
||||
@@ -119,6 +121,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
|
||||
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
|
||||
| Spec Kit Memory | Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows | `docs` | Read+Write | [spec-kit-memory](https://github.com/zaytsevand/spec-kit-memory) |
|
||||
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
|
||||
|
||||
@@ -11,6 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 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 software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 12 templates, 2 commands, 2 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
@@ -28,6 +29,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
| Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) |
|
||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
|
||||
|
||||
|
||||
@@ -86,8 +86,30 @@ Lists workflows installed in the current project.
|
||||
specify workflow add <source>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| `--dev` | Install from a local workflow YAML file or directory |
|
||||
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
|
||||
|
||||
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
|
||||
|
||||
## Update Workflows
|
||||
|
||||
```bash
|
||||
specify workflow update [workflow_id]
|
||||
```
|
||||
|
||||
Updates one installed catalog workflow — or all of them when no ID is given — to the latest catalog version. Prompts for confirmation and keeps the installed copy if a download or validation fails.
|
||||
|
||||
## Enable or Disable a Workflow
|
||||
|
||||
```bash
|
||||
specify workflow enable <workflow_id>
|
||||
specify workflow disable <workflow_id>
|
||||
```
|
||||
|
||||
Disabled workflows stay installed and listed (marked `[disabled]`) but refuse to run until re-enabled.
|
||||
|
||||
## Remove a Workflow
|
||||
|
||||
```bash
|
||||
@@ -102,9 +124,10 @@ Removes an installed workflow from the project.
|
||||
specify workflow search [query]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------- | --------------- |
|
||||
| `--tag` | Filter by tag |
|
||||
| Option | Description |
|
||||
| ---------- | ----------------- |
|
||||
| `--tag` | Filter by tag |
|
||||
| `--author` | Filter by author |
|
||||
|
||||
Searches all active catalogs for workflows matching the query.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1106,10 +1106,10 @@
|
||||
"docguard": {
|
||||
"name": "DocGuard — CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
|
||||
"description": "Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js.",
|
||||
"author": "raccioly",
|
||||
"version": "0.30.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
|
||||
"version": "0.32.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.32.0/spec-kit-docguard-v0.32.0.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
@@ -1124,6 +1124,14 @@
|
||||
"name": "node",
|
||||
"version": ">=18.0.0",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "npx",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "specify",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1145,7 +1153,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
@@ -1427,6 +1435,57 @@
|
||||
"created_at": "2026-05-06T00:00:00Z",
|
||||
"updated_at": "2026-05-06T00:00:00Z"
|
||||
},
|
||||
"gates": {
|
||||
"name": "Quality Gates (Enforcement Layer)",
|
||||
"id": "gates",
|
||||
"description": "Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity).",
|
||||
"author": "schwichtgit",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.1.0/gates-0.1.0.zip",
|
||||
"repository": "https://github.com/schwichtgit/spec-gates",
|
||||
"homepage": "https://github.com/schwichtgit/spec-gates",
|
||||
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "jq",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "shellcheck",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"enforcement",
|
||||
"hooks",
|
||||
"ci",
|
||||
"governance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-09T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration 1",
|
||||
"id": "github-issues",
|
||||
@@ -2217,6 +2276,42 @@
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"memory": {
|
||||
"name": "Spec Kit Memory",
|
||||
"id": "memory",
|
||||
"description": "Recalls prior specs and decisions from configurable memory tools (e.g. memsearch) before SDLC stages, so planning and specification start from what the project already knows.",
|
||||
"author": "Andrey Zaytsev",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/zaytsevand/spec-kit-memory/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/zaytsevand/spec-kit-memory",
|
||||
"homepage": "https://github.com/zaytsevand/spec-kit-memory",
|
||||
"documentation": "https://github.com/zaytsevand/spec-kit-memory/blob/main/README.md",
|
||||
"changelog": "",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{ "name": "memsearch", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
"recall",
|
||||
"research",
|
||||
"memsearch"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
},
|
||||
"memory-loader": {
|
||||
"name": "Memory Loader",
|
||||
"id": "memory-loader",
|
||||
@@ -2371,6 +2466,48 @@
|
||||
"created_at": "2026-05-04T02:51:52Z",
|
||||
"updated_at": "2026-06-18T00:00:00Z"
|
||||
},
|
||||
"multi-repo-sync": {
|
||||
"name": "Multi-Repo Branch Sync",
|
||||
"id": "multi-repo-sync",
|
||||
"description": "Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks",
|
||||
"author": "Fyloss",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/fyloss/spec-kit-multi-repo-sync/releases/download/v1.0.0/spec-kit-multi-repo-sync.zip",
|
||||
"sha256": "12a5c7392145b4424b20715aaa3d8b6a8218c143dea596873e344146c1a76ba0",
|
||||
"repository": "https://github.com/fyloss/spec-kit-multi-repo-sync",
|
||||
"homepage": "https://github.com/fyloss/spec-kit-multi-repo-sync",
|
||||
"documentation": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/README.md",
|
||||
"changelog": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"version": ">=2.31",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"git",
|
||||
"branching",
|
||||
"multi-repo",
|
||||
"submodules",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"multi-sites": {
|
||||
"name": "Multi-Sites Spec Kit",
|
||||
"id": "multi-sites",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-30T00:00:00Z",
|
||||
"updated_at": "2026-07-14T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -131,6 +131,35 @@
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"version": "0.1.4",
|
||||
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.4.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.1.4/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 12,
|
||||
"commands": 2,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"autonomous",
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"retrospective"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-14T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
"id": "canon-core",
|
||||
@@ -618,6 +647,34 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"test-first-governance": {
|
||||
"name": "Test-First Governance",
|
||||
"id": "test-first-governance",
|
||||
"version": "1.3.0",
|
||||
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
|
||||
"author": "Zoltán Katona, PhD",
|
||||
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
|
||||
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"documentation": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.11"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"commands": 8
|
||||
},
|
||||
"tags": [
|
||||
"tdd",
|
||||
"bdd",
|
||||
"atdd",
|
||||
"quality-gates",
|
||||
"traceability"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"toc-navigation": {
|
||||
"name": "Table of Contents Navigation",
|
||||
"id": "toc-navigation",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.13.dev0"
|
||||
version = "0.12.15"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -24,6 +24,7 @@ GITHUB_HOSTS = frozenset({
|
||||
"api.github.com",
|
||||
"codeload.github.com",
|
||||
})
|
||||
_MAX_RELEASE_METADATA_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def build_github_request(url: str) -> urllib.request.Request:
|
||||
@@ -68,6 +69,8 @@ def resolve_github_release_asset_api_url(
|
||||
open_url_fn: Callable,
|
||||
timeout: int = 60,
|
||||
github_hosts: tuple[str, ...] = (),
|
||||
redirect_validator: Callable[[str, str], None] | None = None,
|
||||
max_metadata_bytes: int = _MAX_RELEASE_METADATA_BYTES,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
|
||||
|
||||
@@ -91,6 +94,8 @@ def resolve_github_release_asset_api_url(
|
||||
authenticated release-metadata lookup.
|
||||
timeout: Per-request timeout in seconds.
|
||||
github_hosts: Host patterns to treat as GitHub Enterprise Server.
|
||||
redirect_validator: Optional policy applied to metadata redirects.
|
||||
max_metadata_bytes: Maximum release-metadata response size.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
@@ -149,13 +154,33 @@ def resolve_github_release_asset_api_url(
|
||||
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
|
||||
|
||||
try:
|
||||
with open_url_fn(release_url, timeout=timeout) as response:
|
||||
release_data = json.loads(response.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError):
|
||||
open_kwargs = {"timeout": timeout}
|
||||
if redirect_validator is not None:
|
||||
open_kwargs["redirect_validator"] = redirect_validator
|
||||
with open_url_fn(release_url, **open_kwargs) as response:
|
||||
raw_release_data = response.read(max_metadata_bytes + 1)
|
||||
if len(raw_release_data) > max_metadata_bytes:
|
||||
raise ValueError("GitHub release metadata exceeds size limit")
|
||||
release_data = json.loads(raw_release_data)
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
json.JSONDecodeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
return None
|
||||
|
||||
for asset in release_data.get("assets", []):
|
||||
if asset.get("name") == asset_name and asset.get("url"):
|
||||
if not isinstance(release_data, dict):
|
||||
return None
|
||||
assets = release_data.get("assets", [])
|
||||
if not isinstance(assets, list):
|
||||
return None
|
||||
for asset in assets:
|
||||
if (
|
||||
isinstance(asset, dict)
|
||||
and asset.get("name") == asset_name
|
||||
and asset.get("url")
|
||||
):
|
||||
return str(asset["url"])
|
||||
|
||||
return None
|
||||
|
||||
@@ -14,7 +14,7 @@ def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||
dest = project_path / INIT_OPTIONS_FILE
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
@@ -87,17 +89,63 @@ def loads_json(text: str, *, origin: str = "<string>") -> Any:
|
||||
|
||||
|
||||
def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
"""Write *data* as pretty JSON to *path* (optionally confined to *within*)."""
|
||||
"""Atomically write pretty JSON to *path* (optionally confined to *within*)."""
|
||||
path = Path(path)
|
||||
if within is not None:
|
||||
path = ensure_within(within, path)
|
||||
fd = -1
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
dir=path.parent,
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
temp_path = Path(temp_name)
|
||||
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2, sort_keys=False)
|
||||
handle.write("\n")
|
||||
|
||||
try:
|
||||
if path.exists():
|
||||
existing = path.stat(follow_symlinks=False)
|
||||
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchmod"):
|
||||
os.fchmod(fd, stat.S_IMODE(existing.st_mode))
|
||||
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchown"):
|
||||
try:
|
||||
os.fchown(fd, existing.st_uid, existing.st_gid)
|
||||
except PermissionError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
staged = os.stat(temp_path, follow_symlinks=False)
|
||||
opened = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(staged.st_mode)
|
||||
or staged.st_dev != opened.st_dev
|
||||
or staged.st_ino != opened.st_ino
|
||||
):
|
||||
raise OSError("staged JSON file changed before commit")
|
||||
|
||||
os.close(fd)
|
||||
fd = -1
|
||||
os.replace(temp_path, path)
|
||||
temp_path = None
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not write {path}: {exc}") from exc
|
||||
finally:
|
||||
if fd >= 0:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
if temp_path is not None:
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
|
||||
@@ -187,19 +187,41 @@ def remove_bundle(
|
||||
|
||||
still_needed = components_still_needed(records, exclude_bundle_id=bundle_id)
|
||||
result = InstallResult(bundle_id=bundle_id)
|
||||
remove_attempted = False
|
||||
|
||||
for component in target.contributed_components:
|
||||
key = (component.kind, component.id)
|
||||
if key in still_needed:
|
||||
result.skipped.append(component)
|
||||
continue
|
||||
if installer.is_installed(project_root, component):
|
||||
installer.remove(project_root, component)
|
||||
result.uninstalled.append(component)
|
||||
try:
|
||||
for component in target.contributed_components:
|
||||
key = (component.kind, component.id)
|
||||
if key in still_needed:
|
||||
result.skipped.append(component)
|
||||
continue
|
||||
if installer.is_installed(project_root, component):
|
||||
remove_attempted = True
|
||||
installer.remove(project_root, component)
|
||||
result.uninstalled.append(component)
|
||||
save_records(project_root, remove_record(records, bundle_id))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if result.uninstalled:
|
||||
detail = (
|
||||
f"{len(result.uninstalled)} component(s) were already removed "
|
||||
"before this failure; the bundle record was left unchanged, "
|
||||
"so the project may be partially uninstalled."
|
||||
)
|
||||
elif remove_attempted:
|
||||
detail = (
|
||||
"No components were removed, but the failing component may "
|
||||
"have made partial changes before raising, so the project "
|
||||
"may be partially uninstalled."
|
||||
)
|
||||
else:
|
||||
result.skipped.append(component)
|
||||
detail = (
|
||||
"No components were removed and no removal was attempted; "
|
||||
"the bundle record was left unchanged."
|
||||
)
|
||||
raise BundlerError(
|
||||
f"Failed to remove bundle '{bundle_id}': {exc}. {detail}"
|
||||
) from exc
|
||||
|
||||
save_records(project_root, remove_record(records, bundle_id))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -220,16 +220,45 @@ def register(app: typer.Typer) -> None:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)"
|
||||
)
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
if force:
|
||||
# Proceeding: the merge/overwrite warning is accurate here.
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
|
||||
)
|
||||
else:
|
||||
response = typer.confirm("Do you want to continue?")
|
||||
if not response:
|
||||
# Fold the merge risk into the confirmation prompt rather than
|
||||
# printing it unconditionally first: on the EOF/no-input path
|
||||
# below the command exits without changing anything, so a
|
||||
# standalone "will be merged" line would mislead. Interactive
|
||||
# users still see the risk as part of the question.
|
||||
#
|
||||
# Call typer.confirm normally so piped y/n is honored — e.g.
|
||||
# `echo y | specify init --here` keeps reaching the
|
||||
# non-destructive preserve-merge path.
|
||||
try:
|
||||
proceed = typer.confirm(
|
||||
"Template files will be merged with existing content "
|
||||
"and may overwrite existing files. Do you want to continue?"
|
||||
)
|
||||
except (typer.Abort, EOFError):
|
||||
# typer.confirm raises Abort for BOTH an interactive Ctrl+C
|
||||
# and an EOF on closed/empty stdin. Distinguish them: a real
|
||||
# TTY cancellation is a normal exit (0, "cancelled"), while a
|
||||
# missing-input EOF (non-interactive) becomes an actionable
|
||||
# error pointing at --force.
|
||||
if _stdin_is_interactive():
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0) from None
|
||||
console.print(
|
||||
"[red]Error:[/red] Current directory is not empty and no "
|
||||
"confirmation input is available. Re-run with "
|
||||
"[bold]--force[/bold] to merge into it."
|
||||
)
|
||||
raise typer.Exit(1) from None
|
||||
if not proceed:
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
|
||||
@@ -2737,6 +2737,36 @@ class ConfigManager:
|
||||
config_file = self.extension_dir / "local-config.yml"
|
||||
return self._load_yaml_config(config_file)
|
||||
|
||||
def _sibling_extension_ids(self) -> list[str]:
|
||||
"""Return IDs of other extensions installed alongside this one.
|
||||
|
||||
Sourced from ``ExtensionRegistry`` (``.specify/extensions/.registry``)
|
||||
rather than a directory scan: ``ExtensionManager.remove(...,
|
||||
keep_config=True)`` deliberately preserves the extension directory
|
||||
while dropping the registry entry, so a directory scan would treat
|
||||
that config-only leftover as an installed sibling and keep silently
|
||||
absorbing its ``SPECKIT_<sibling>_*`` env vars into no one. The
|
||||
registry is the source of truth for "installed".
|
||||
|
||||
Returns an empty list if the registry is missing or corrupted
|
||||
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
|
||||
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
|
||||
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
|
||||
opens the file in text mode and only handles ``JSONDecodeError`` /
|
||||
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
|
||||
otherwise surface a ``UnicodeDecodeError`` here and break *every*
|
||||
config read instead of degrading gracefully.
|
||||
|
||||
Used by ``_get_env_config`` to detect env vars whose remainder claims
|
||||
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
|
||||
owned by ``git-hooks`` when it is co-installed with ``git``).
|
||||
"""
|
||||
extensions_dir = self.project_root / ".specify" / "extensions"
|
||||
try:
|
||||
return list(ExtensionRegistry(extensions_dir).keys())
|
||||
except (OSError, UnicodeError):
|
||||
return []
|
||||
|
||||
def _get_env_config(self) -> Dict[str, Any]:
|
||||
"""Get configuration from environment variables.
|
||||
|
||||
@@ -2756,15 +2786,49 @@ class ConfigManager:
|
||||
ext_id_upper = self.extension_id.replace("-", "_").upper()
|
||||
prefix = f"SPECKIT_{ext_id_upper}_"
|
||||
|
||||
# Cross-extension prefix collision: because ``_`` doubles as both the
|
||||
# separator between the extension ID and the config path *and* the
|
||||
# substitute for ``-`` inside an extension ID, an env var like
|
||||
# ``SPECKIT_GIT_HOOKS_URL`` begins with *both* the ``SPECKIT_GIT_``
|
||||
# prefix of the ``git`` extension and the ``SPECKIT_GIT_HOOKS_`` prefix
|
||||
# of a co-installed ``git-hooks`` extension. It logically belongs to
|
||||
# the extension whose normalized ID is the longer, more specific match
|
||||
# — otherwise config intended for one extension silently surfaces
|
||||
# inside another and can drive hooks that only inspect
|
||||
# ``config.<field> is set``. Build the list of sibling-owned
|
||||
# remainder-prefixes here so a later env var can be skipped if it
|
||||
# matches one.
|
||||
sibling_prefixes: list[str] = []
|
||||
for sibling_id in self._sibling_extension_ids():
|
||||
if sibling_id == self.extension_id:
|
||||
continue
|
||||
sib_upper = sibling_id.replace("-", "_").upper()
|
||||
# A sibling collides only when its normalized ID *extends* our own
|
||||
# (i.e. starts with ``<US>_``). ``git`` vs ``not-git`` is not a
|
||||
# collision; ``git`` vs ``git-hooks`` is.
|
||||
if sib_upper.startswith(ext_id_upper + "_"):
|
||||
# The portion of the env-var *remainder* the sibling claims,
|
||||
# including the trailing ``_`` so a shorter ID that shares a
|
||||
# non-boundary prefix cannot false-positive (e.g. sibling
|
||||
# ``hook`` would not eat env vars under key ``hooks``).
|
||||
sibling_prefixes.append(sib_upper[len(ext_id_upper) + 1 :] + "_")
|
||||
|
||||
for key, value in os.environ.items():
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
|
||||
remainder = key[len(prefix) :]
|
||||
# Skip when a longer sibling ID claims this var — see the block
|
||||
# above. Keeps ``SPECKIT_GIT_HOOKS_URL`` out of the ``git``
|
||||
# extension's config when ``git-hooks`` is co-installed.
|
||||
if any(remainder.startswith(sp) for sp in sibling_prefixes):
|
||||
continue
|
||||
|
||||
# Remove prefix and split into parts. Drop empty components from a
|
||||
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
|
||||
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
|
||||
# entry under an empty key.
|
||||
config_path = [p for p in key[len(prefix) :].lower().split("_") if p]
|
||||
config_path = [p for p in remainder.lower().split("_") if p]
|
||||
if not config_path:
|
||||
continue
|
||||
|
||||
|
||||
@@ -190,7 +190,15 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
|
||||
"""
|
||||
import shlex
|
||||
parsed: dict[str, Any] = {}
|
||||
tokens = shlex.split(raw_options)
|
||||
try:
|
||||
tokens = shlex.split(raw_options)
|
||||
except ValueError as exc:
|
||||
# An unbalanced quote (e.g. --integration-options='--commands-dir "foo')
|
||||
# makes shlex raise "No closing quotation". Translate it into the same
|
||||
# clean exit-1 UX as every other bad-input path below rather than
|
||||
# letting a raw traceback escape.
|
||||
console.print(f"[red]Error:[/red] Could not parse integration options: {exc}.")
|
||||
raise typer.Exit(1)
|
||||
declared_options = list(integration.options())
|
||||
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
|
||||
allowed = ", ".join(sorted(opt.name for opt in declared_options))
|
||||
|
||||
@@ -1122,6 +1122,17 @@ class TomlIntegration(IntegrationBase):
|
||||
# YamlIntegration — YAML-format agents (Goose)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Characters a YAML literal block scalar cannot carry: C0 controls other
|
||||
# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the
|
||||
# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF.
|
||||
# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1
|
||||
# treats it as a line break, which corrupts the block scalar's structure
|
||||
# just the same, so all three are included.
|
||||
_YAML_BLOCK_SCALAR_UNSAFE = re.compile(
|
||||
r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]"
|
||||
)
|
||||
|
||||
|
||||
class YamlIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use YAML recipe format.
|
||||
|
||||
@@ -1227,9 +1238,9 @@ class YamlIntegration(IntegrationBase):
|
||||
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
|
||||
"""Render a YAML recipe file from title, description, and body.
|
||||
|
||||
Produces a Goose-compatible recipe with a literal block scalar
|
||||
for the prompt content. Uses ``yaml.safe_dump()`` for the
|
||||
header fields to ensure proper escaping.
|
||||
Produces a Goose-compatible recipe with a literal block scalar for
|
||||
normal prompt content, or an escaped quoted scalar when control
|
||||
characters require it. Uses ``yaml.safe_dump()`` for the header fields.
|
||||
"""
|
||||
header = cls._build_yaml_header(title, description)
|
||||
|
||||
@@ -1240,6 +1251,23 @@ class YamlIntegration(IntegrationBase):
|
||||
default_flow_style=False,
|
||||
).strip()
|
||||
|
||||
# YAML forbids C0 control characters (except tab and newline) and
|
||||
# DEL in every scalar form, and a bare CR acts as a line break
|
||||
# inside a block scalar. A literal block scalar emits such bytes
|
||||
# verbatim, producing a recipe the YAML parser rejects, so fall
|
||||
# back to an escaped double-quoted scalar for those bodies.
|
||||
if _YAML_BLOCK_SCALAR_UNSAFE.search(body):
|
||||
prompt_yaml = yaml.safe_dump(
|
||||
{"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize
|
||||
).strip()
|
||||
lines = [
|
||||
header_yaml,
|
||||
prompt_yaml,
|
||||
"",
|
||||
f"# Source: {source_id}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
# Indent the body for YAML block scalar. Use an explicit indentation
|
||||
# indicator ("|2") rather than a bare "|": YAML infers a plain block
|
||||
# scalar's indentation from its first non-empty line, so a body whose
|
||||
|
||||
@@ -27,3 +27,10 @@ class KiroCliIntegration(MarkdownIntegration):
|
||||
"args": _KIRO_ARG_FALLBACK,
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
# Kiro CLI keeps everything under a static, isolated agent root
|
||||
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
|
||||
# integration writes to, so it is safe to install alongside others
|
||||
# (issue #3471). The registry's multi-install-safe contract tests
|
||||
# enforce that isolation for every integration setting this flag.
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -2588,6 +2588,39 @@ class PresetResolver:
|
||||
self._manifest_cache[key] = None
|
||||
return self._manifest_cache[key]
|
||||
|
||||
def _manifest_declared_template(
|
||||
self, pack_dir: Path, template_name: str, template_type: str
|
||||
) -> tuple[dict | None, Path | None]:
|
||||
"""Resolve a preset's manifest-declared template entry and usable file.
|
||||
|
||||
Returns ``(entry, candidate)``:
|
||||
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
|
||||
the manifest is absent or does not list this ``(name, type)``.
|
||||
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
|
||||
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
|
||||
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
|
||||
|
||||
The manifest is authoritative: when it declares a template (``entry`` is
|
||||
not ``None``) but the file is unusable (``candidate`` is ``None``),
|
||||
callers must NOT fall back to the convention lookup — that would mask a
|
||||
typo or pick up an undeclared file. Shared by ``resolve()`` and
|
||||
``collect_all_layers()`` so their manifest-first resolution cannot
|
||||
silently diverge again (the divergence this fix addressed).
|
||||
"""
|
||||
manifest = self._get_manifest(pack_dir)
|
||||
if not manifest:
|
||||
return None, None
|
||||
for tmpl in manifest.templates:
|
||||
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
|
||||
file_path = tmpl.get("file")
|
||||
if file_path:
|
||||
manifest_candidate = pack_dir / file_path
|
||||
return tmpl, (
|
||||
manifest_candidate if manifest_candidate.is_file() else None
|
||||
)
|
||||
return tmpl, None
|
||||
return None, None
|
||||
|
||||
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
||||
"""Build unified list of registered and unregistered extensions sorted by priority.
|
||||
|
||||
@@ -2690,6 +2723,27 @@ class PresetResolver:
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
for pack_id, _metadata in registry.list_by_priority():
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# The preset manifest is authoritative: if it declares this
|
||||
# template with an explicit ``file:``, resolve to that path —
|
||||
# and do NOT fall back to convention when it's missing, to
|
||||
# avoid masking typos or picking up an undeclared file. Only
|
||||
# when the manifest is absent or doesn't list this template do
|
||||
# we use the convention-based subdir lookup. Mirrors
|
||||
# collect_all_layers()/resolve_content() so resolve() and
|
||||
# resolve_with_source() agree with them instead of returning
|
||||
# the core template (or a stray convention file).
|
||||
entry, manifest_candidate = self._manifest_declared_template(
|
||||
pack_dir, template_name, template_type
|
||||
)
|
||||
if manifest_candidate is not None:
|
||||
return manifest_candidate
|
||||
if entry is not None:
|
||||
# Manifest declares this template but the file is missing,
|
||||
# non-file (e.g. a directory), or an empty/falsey ``file``
|
||||
# value. The manifest is authoritative, so skip this pack's
|
||||
# convention fallback rather than mask a typo — mirrors
|
||||
# collect_all_layers().
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = pack_dir / subdir / f"{template_name}{ext}"
|
||||
@@ -2957,31 +3011,22 @@ class PresetResolver:
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# Read strategy and manifest file path from preset manifest
|
||||
strategy = "replace"
|
||||
manifest_file_path = None
|
||||
manifest_has_strategy = False
|
||||
manifest_found_entry = False
|
||||
manifest = self._get_manifest(pack_dir)
|
||||
if manifest:
|
||||
for tmpl in manifest.templates:
|
||||
if (tmpl.get("name") == template_name
|
||||
and tmpl.get("type") == template_type):
|
||||
strategy = tmpl.get("strategy", "replace")
|
||||
manifest_has_strategy = "strategy" in tmpl
|
||||
manifest_file_path = tmpl.get("file")
|
||||
manifest_found_entry = True
|
||||
break
|
||||
# Use manifest file path if specified, otherwise convention-based
|
||||
# lookup — but only when the manifest doesn't exist or doesn't
|
||||
# list this template, so preset.yml stays authoritative.
|
||||
entry, manifest_candidate = self._manifest_declared_template(
|
||||
pack_dir, template_name, template_type
|
||||
)
|
||||
if entry is not None:
|
||||
strategy = entry.get("strategy", "replace")
|
||||
manifest_has_strategy = "strategy" in entry
|
||||
# Use the manifest's declared file when it's a usable regular file;
|
||||
# only fall back to convention-based lookup when the manifest
|
||||
# doesn't list this template at all, so preset.yml stays
|
||||
# authoritative (a declared-but-unusable file skips convention —
|
||||
# parity with resolve()).
|
||||
candidate = None
|
||||
if manifest_file_path:
|
||||
manifest_candidate = pack_dir / manifest_file_path
|
||||
if manifest_candidate.exists():
|
||||
candidate = manifest_candidate
|
||||
# Explicit file path that doesn't exist: skip convention
|
||||
# fallback to avoid masking typos or picking up unintended files.
|
||||
elif not manifest_found_entry:
|
||||
# Manifest doesn't list this template — check convention paths
|
||||
if manifest_candidate is not None:
|
||||
candidate = manifest_candidate
|
||||
elif entry is None:
|
||||
candidate = _find_in_subdirs(pack_dir)
|
||||
if candidate:
|
||||
# Legacy fallback: if manifest doesn't explicitly declare a
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -71,48 +73,180 @@ class WorkflowRegistry:
|
||||
self.registry_path = self.workflows_dir / self.REGISTRY_FILE
|
||||
self.data = self._load()
|
||||
|
||||
def _has_symlinked_parent(self) -> bool:
|
||||
"""Return True if any directory under .specify/workflows is a symlink."""
|
||||
current = self.project_root
|
||||
for part in (".specify", "workflows"):
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
"""Load registry from disk or create default."""
|
||||
default_registry: dict[str, Any] = {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"workflows": {},
|
||||
}
|
||||
# Defense-in-depth: refuse to read through symlinked parents or a
|
||||
# symlinked registry file. Unlike StepRegistry (read-only best-effort
|
||||
# elsewhere), a fabricated empty registry here is not safe: read-only
|
||||
# callers (notably the bundler's remove path) query is_installed()
|
||||
# before ever writing, and would otherwise conclude an installed
|
||||
# workflow is absent, skip removing it, then delete the bundle
|
||||
# record -- leaving the workflow untracked but still on disk. Fail
|
||||
# closed here just like the unreadable-file case below.
|
||||
if self._has_symlinked_parent() or self.registry_path.is_symlink():
|
||||
raise OSError(
|
||||
f"Refusing to read workflow registry at {self.registry_path}: "
|
||||
"a parent directory or the registry file itself is a symlink"
|
||||
)
|
||||
if self.registry_path.exists():
|
||||
try:
|
||||
with open(self.registry_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Validate shape: must be a dict with a dict "workflows" field,
|
||||
# otherwise every method that indexes data["workflows"] crashes.
|
||||
# Mirrors StepRegistry._load.
|
||||
if not isinstance(data, dict):
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
if not isinstance(data.get("workflows"), dict):
|
||||
data["workflows"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
|
||||
# Corrupted registry file — reset to default
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
|
||||
except OSError as exc:
|
||||
# The real data may still be intact on disk. Fail closed at
|
||||
# construction rather than fabricating an empty registry that
|
||||
# a read-only caller could mistake for "nothing installed."
|
||||
raise OSError(
|
||||
f"Failed to read workflow registry at {self.registry_path}: {exc}"
|
||||
) from exc
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
ValueError,
|
||||
UnicodeError,
|
||||
) as exc:
|
||||
raise OSError(
|
||||
f"Workflow registry at {self.registry_path} is corrupted: "
|
||||
f"{exc}"
|
||||
) from exc
|
||||
# Validate shape: must be a dict with a dict "workflows" field.
|
||||
if not isinstance(data, dict):
|
||||
raise OSError(
|
||||
f"Workflow registry at {self.registry_path} is corrupted: "
|
||||
"top-level value must be an object"
|
||||
)
|
||||
if not isinstance(data.get("workflows"), dict):
|
||||
raise OSError(
|
||||
f"Workflow registry at {self.registry_path} is corrupted: "
|
||||
"'workflows' must be an object"
|
||||
)
|
||||
return data
|
||||
return default_registry
|
||||
|
||||
def save(self) -> None:
|
||||
"""Persist registry to disk."""
|
||||
"""Persist registry to disk atomically."""
|
||||
# Refuse to write through symlinked parents (mirrors StepRegistry.save
|
||||
# and the CLI-level _reject_unsafe_dir guard).
|
||||
if self._has_symlinked_parent() or self.registry_path.is_symlink():
|
||||
raise OSError(
|
||||
"Refusing to write workflow registry through a symlinked path."
|
||||
)
|
||||
self.workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
# Unique, exclusive temp then replace: a failed dump cannot truncate
|
||||
# the registry, a pre-created symlink cannot redirect the write, and
|
||||
# concurrent CLI processes cannot collide on the same temp path.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(self.registry_path.parent),
|
||||
prefix=f".{self.registry_path.name}.",
|
||||
suffix=".tmp",
|
||||
)
|
||||
try:
|
||||
# Write through a duplicate so the exclusive mkstemp descriptor
|
||||
# stays open for fd-based metadata updates and inode verification.
|
||||
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
# mkstemp creates the temp file at 0600. A pre-existing registry
|
||||
# may be shared more permissively (e.g. 0640/0644); preserve its
|
||||
# mode across the replace so a save doesn't silently lock other
|
||||
# project users out. A brand-new registry has no prior mode to
|
||||
# preserve, so mkstemp's secure 0600 default stands. Mirrors
|
||||
# _utils.py's atomic_write_json (best-effort; data safety over
|
||||
# metadata preservation).
|
||||
try:
|
||||
if self.registry_path.exists():
|
||||
existing_stat = self.registry_path.stat(
|
||||
follow_symlinks=False
|
||||
)
|
||||
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
|
||||
os, "fchmod"
|
||||
):
|
||||
os.fchmod(fd, stat.S_IMODE(existing_stat.st_mode))
|
||||
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
|
||||
os, "fchown"
|
||||
):
|
||||
try:
|
||||
os.fchown(
|
||||
fd, existing_stat.st_uid, existing_stat.st_gid
|
||||
)
|
||||
except PermissionError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
staged_stat = os.stat(tmp, follow_symlinks=False)
|
||||
open_stat = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(staged_stat.st_mode)
|
||||
or staged_stat.st_dev != open_stat.st_dev
|
||||
or staged_stat.st_ino != open_stat.st_ino
|
||||
):
|
||||
raise OSError(
|
||||
"Refusing to replace workflow registry: "
|
||||
"staged file changed before commit"
|
||||
)
|
||||
os.close(fd)
|
||||
fd = -1
|
||||
os.replace(tmp, self.registry_path)
|
||||
except BaseException:
|
||||
if fd >= 0:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
|
||||
"""Add or update an installed workflow entry."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
existing = self.data["workflows"].get(workflow_id, {})
|
||||
raw_existing = self.data["workflows"].get(workflow_id)
|
||||
had_entry = workflow_id in self.data["workflows"]
|
||||
# Corrupted-but-parseable registries may hold non-dict entries.
|
||||
existing = raw_existing if isinstance(raw_existing, dict) else {}
|
||||
metadata["installed_at"] = existing.get(
|
||||
"installed_at", datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
self.data["workflows"][workflow_id] = metadata
|
||||
self.save()
|
||||
try:
|
||||
self.save()
|
||||
except (OSError, TypeError, ValueError):
|
||||
# Roll back the in-memory mutation so a later successful save
|
||||
# cannot persist metadata for a write that failed.
|
||||
if had_entry:
|
||||
self.data["workflows"][workflow_id] = raw_existing
|
||||
else:
|
||||
del self.data["workflows"][workflow_id]
|
||||
raise
|
||||
|
||||
def remove(self, workflow_id: str) -> bool:
|
||||
"""Remove an installed workflow entry. Returns True if found."""
|
||||
if workflow_id in self.data["workflows"]:
|
||||
removed_entry = self.data["workflows"][workflow_id]
|
||||
del self.data["workflows"][workflow_id]
|
||||
self.save()
|
||||
try:
|
||||
self.save()
|
||||
except (OSError, TypeError, ValueError):
|
||||
# Roll back the in-memory deletion so a save failure can't
|
||||
# desync this instance from the untouched file on disk,
|
||||
# mirroring add()'s rollback-on-save-failure.
|
||||
self.data["workflows"][workflow_id] = removed_entry
|
||||
raise
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -165,8 +299,20 @@ class WorkflowCatalog:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. an unterminated IPv6 bracket
|
||||
# "https://[::1") makes urlparse / hostname access raise ValueError.
|
||||
# This validator's contract is to raise WorkflowValidationError for a
|
||||
# bad URL, so surface that rather than leaking a raw ValueError past the
|
||||
# command handler (which only catches WorkflowValidationError). Mirrors
|
||||
# specify_cli.catalogs (#3435).
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise WorkflowValidationError(
|
||||
f"Catalog URL is malformed: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
@@ -174,7 +320,7 @@ class WorkflowCatalog:
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
if not hostname:
|
||||
raise WorkflowValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
@@ -340,15 +486,26 @@ class WorkflowCatalog:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_catalog_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. "https://[::1") makes urlparse /
|
||||
# hostname access raise ValueError; treat it as a refused fetch
|
||||
# rather than leaking a raw ValueError (this also validates the
|
||||
# post-redirect resp.geturl(), so a hostile redirect target cannot
|
||||
# crash the fetch either).
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from malformed URL: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
|
||||
)
|
||||
if not parsed.hostname:
|
||||
if not hostname:
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from URL with no hostname: {url}"
|
||||
)
|
||||
@@ -435,6 +592,7 @@ class WorkflowCatalog:
|
||||
self,
|
||||
query: str | None = None,
|
||||
tag: str | None = None,
|
||||
author: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search workflows across all configured catalogs."""
|
||||
merged = self._get_merged_workflows()
|
||||
@@ -459,6 +617,10 @@ class WorkflowCatalog:
|
||||
normalized_tags = [t.lower() for t in tags if isinstance(t, str)]
|
||||
if tag.lower() not in normalized_tags:
|
||||
continue
|
||||
if author:
|
||||
wf_author = wf_data.get("author", "")
|
||||
if not isinstance(wf_author, str) or wf_author.lower() != author.lower():
|
||||
continue
|
||||
results.append(wf_data)
|
||||
return results
|
||||
|
||||
@@ -782,8 +944,20 @@ class StepCatalog:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. an unterminated IPv6 bracket
|
||||
# "https://[::1") makes urlparse / hostname access raise ValueError.
|
||||
# This validator's contract is to raise StepValidationError for a bad
|
||||
# URL, so surface that rather than leaking a raw ValueError past the
|
||||
# command handler (which only catches StepValidationError). Mirrors
|
||||
# specify_cli.catalogs (#3435).
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise StepValidationError(
|
||||
f"Catalog URL is malformed: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
@@ -791,7 +965,7 @@ class StepCatalog:
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
if not hostname:
|
||||
raise StepValidationError(
|
||||
"Catalog URL must be a valid URL with a host."
|
||||
)
|
||||
@@ -957,15 +1131,26 @@ class StepCatalog:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _validate_url(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. "https://[::1") makes urlparse /
|
||||
# hostname access raise ValueError; treat it as a refused fetch
|
||||
# rather than leaking a raw ValueError (this also validates the
|
||||
# post-redirect resp.geturl(), so a hostile redirect target cannot
|
||||
# crash the fetch either).
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from malformed URL: {url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
|
||||
)
|
||||
if not parsed.hostname:
|
||||
if not hostname:
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from URL with no hostname: {url}"
|
||||
)
|
||||
|
||||
@@ -150,7 +150,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"'workflow.id' must be a string, got "
|
||||
f"{type(definition.id).__name__} ({definition.id!r})."
|
||||
)
|
||||
elif not _ID_PATTERN.match(definition.id):
|
||||
elif not _ID_PATTERN.fullmatch(definition.id):
|
||||
errors.append(
|
||||
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
|
||||
f"with hyphens."
|
||||
@@ -172,7 +172,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"{type(definition.version).__name__} ({definition.version!r}) — "
|
||||
f'quote it in YAML (version: "1.0.0").'
|
||||
)
|
||||
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
|
||||
elif not re.fullmatch(r"\d+\.\d+\.\d+", definition.version):
|
||||
errors.append(
|
||||
f"Workflow version {definition.version!r} is not valid "
|
||||
f"semantic versioning (expected X.Y.Z)."
|
||||
@@ -416,18 +416,57 @@ class RunState:
|
||||
ID into a path so a malicious value cannot probe or read files
|
||||
outside ``.specify/workflows/runs/<run_id>/``.
|
||||
"""
|
||||
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
|
||||
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.fullmatch(run_id):
|
||||
raise ValueError(
|
||||
f"Invalid run_id {run_id!r}: must be alphanumeric with "
|
||||
"hyphens/underscores only (and must start with an "
|
||||
"alphanumeric character)."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_installed_origin(
|
||||
installed_workflow_id: str | None,
|
||||
installed_registry_root: str | None,
|
||||
) -> None:
|
||||
"""Validate persisted installed-workflow ownership metadata."""
|
||||
if installed_workflow_id is not None:
|
||||
if not isinstance(installed_workflow_id, str):
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_workflow_id' must be a "
|
||||
f"string or null, got {type(installed_workflow_id).__name__}"
|
||||
)
|
||||
if not _ID_PATTERN.fullmatch(installed_workflow_id):
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_workflow_id' must be a "
|
||||
"lowercase alphanumeric workflow ID with hyphens"
|
||||
)
|
||||
if installed_registry_root is not None:
|
||||
if not isinstance(installed_registry_root, str):
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_registry_root' must be a "
|
||||
f"string or null, got {type(installed_registry_root).__name__}"
|
||||
)
|
||||
if not installed_registry_root or not Path(
|
||||
installed_registry_root
|
||||
).is_absolute():
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_registry_root' must be "
|
||||
"an absolute path or null"
|
||||
)
|
||||
if installed_workflow_id is None:
|
||||
raise ValueError(
|
||||
"Invalid run state: 'installed_registry_root' requires "
|
||||
"'installed_workflow_id'"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_id: str | None = None,
|
||||
workflow_id: str = "",
|
||||
project_root: Path | None = None,
|
||||
installed_workflow_id: str | None = None,
|
||||
installed_registry_root: str | None = None,
|
||||
installed_origin_tracked: bool = True,
|
||||
) -> None:
|
||||
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
|
||||
# string is *not* the same as "omitted" and must be validated like
|
||||
@@ -439,8 +478,22 @@ class RunState:
|
||||
else:
|
||||
self.run_id = run_id
|
||||
self._validate_run_id(self.run_id)
|
||||
self._validate_installed_origin(
|
||||
installed_workflow_id, installed_registry_root
|
||||
)
|
||||
self.workflow_id = workflow_id
|
||||
self.project_root = project_root or Path(".")
|
||||
# Identifies the installed workflow (if any) this run was started
|
||||
# from, and the project root that owns its registry — set by
|
||||
# execute() when the source was resolved to an installed ID (see
|
||||
# workflow_run's ownership mapping). None for a direct/non-installed
|
||||
# YAML source. ``installed_origin_tracked`` distinguishes those
|
||||
# explicit None values from legacy state files that predate both
|
||||
# fields, allowing the CLI to conservatively infer same-project
|
||||
# registry ownership before resuming.
|
||||
self.installed_workflow_id = installed_workflow_id
|
||||
self.installed_registry_root = installed_registry_root
|
||||
self.installed_origin_tracked = installed_origin_tracked
|
||||
self.status = RunStatus.CREATED
|
||||
self.current_step_index = 0
|
||||
self.current_step_id: str | None = None
|
||||
@@ -503,6 +556,8 @@ class RunState:
|
||||
state_data = {
|
||||
"run_id": self.run_id,
|
||||
"workflow_id": self.workflow_id,
|
||||
"installed_workflow_id": self.installed_workflow_id,
|
||||
"installed_registry_root": self.installed_registry_root,
|
||||
"status": self.status.value,
|
||||
"current_step_index": self.current_step_index,
|
||||
"current_step_id": self.current_step_id,
|
||||
@@ -554,11 +609,46 @@ class RunState:
|
||||
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
state_data = json.load(f)
|
||||
if not isinstance(state_data, dict):
|
||||
raise ValueError("Invalid run state: expected a JSON object")
|
||||
missing_fields = [
|
||||
field
|
||||
for field in ("run_id", "workflow_id", "status")
|
||||
if field not in state_data
|
||||
]
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
"Invalid run state: missing required field(s): "
|
||||
+ ", ".join(missing_fields)
|
||||
)
|
||||
|
||||
workflow_id = state_data["workflow_id"]
|
||||
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
|
||||
workflow_id
|
||||
):
|
||||
raise ValueError(
|
||||
"Invalid run state: 'workflow_id' must be a lowercase "
|
||||
"alphanumeric workflow ID with hyphens"
|
||||
)
|
||||
|
||||
has_installed_workflow_id = "installed_workflow_id" in state_data
|
||||
has_installed_registry_root = "installed_registry_root" in state_data
|
||||
if has_installed_workflow_id != has_installed_registry_root:
|
||||
raise ValueError(
|
||||
"Invalid run state: installed workflow origin fields must "
|
||||
"either both be present or both be absent"
|
||||
)
|
||||
|
||||
installed_workflow_id = state_data.get("installed_workflow_id")
|
||||
installed_registry_root = state_data.get("installed_registry_root")
|
||||
|
||||
state = cls(
|
||||
run_id=state_data["run_id"],
|
||||
workflow_id=state_data["workflow_id"],
|
||||
workflow_id=workflow_id,
|
||||
project_root=project_root,
|
||||
installed_workflow_id=installed_workflow_id,
|
||||
installed_registry_root=installed_registry_root,
|
||||
installed_origin_tracked=has_installed_workflow_id,
|
||||
)
|
||||
state.status = RunStatus(state_data["status"])
|
||||
state.current_step_index = state_data.get("current_step_index", 0)
|
||||
@@ -571,7 +661,16 @@ class RunState:
|
||||
if inputs_path.exists():
|
||||
with open(inputs_path, encoding="utf-8") as f:
|
||||
inputs_data = json.load(f)
|
||||
state.inputs = inputs_data.get("inputs", {})
|
||||
if not isinstance(inputs_data, dict):
|
||||
raise ValueError(
|
||||
"Invalid run inputs: expected a JSON object"
|
||||
)
|
||||
inputs = inputs_data.get("inputs", {})
|
||||
if not isinstance(inputs, dict):
|
||||
raise ValueError(
|
||||
"Invalid run inputs: 'inputs' must be a JSON object"
|
||||
)
|
||||
state.inputs = inputs
|
||||
|
||||
return state
|
||||
|
||||
@@ -654,6 +753,8 @@ class WorkflowEngine:
|
||||
definition: WorkflowDefinition,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
run_id: str | None = None,
|
||||
installed_workflow_id: str | None = None,
|
||||
installed_registry_root: Path | None = None,
|
||||
) -> RunState:
|
||||
"""Execute a workflow definition.
|
||||
|
||||
@@ -665,6 +766,12 @@ class WorkflowEngine:
|
||||
User-provided input values.
|
||||
run_id:
|
||||
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
|
||||
installed_workflow_id, installed_registry_root:
|
||||
When the run was started from an installed workflow (as opposed
|
||||
to a direct/non-installed YAML source), identifies it and its
|
||||
owning registry root so a later ``resume`` can re-check the
|
||||
registry's current disabled state before continuing — see
|
||||
``workflow_resume``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -682,6 +789,12 @@ class WorkflowEngine:
|
||||
run_id=effective_run_id,
|
||||
workflow_id=definition.id,
|
||||
project_root=self.project_root,
|
||||
installed_workflow_id=installed_workflow_id,
|
||||
installed_registry_root=(
|
||||
str(installed_registry_root)
|
||||
if installed_registry_root is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
# Persist a copy of the workflow definition so resume can
|
||||
|
||||
@@ -31,6 +31,20 @@ class CommandStep(StepBase):
|
||||
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
|
||||
command = config.get("command", "")
|
||||
input_data = config.get("input", {})
|
||||
# validate() rejects a non-mapping input, but the engine does not
|
||||
# auto-validate before execute(); a workflow that skipped validation can
|
||||
# still reach here. Fail the step with the same contract error rather
|
||||
# than silently coercing to {} and dispatching with empty args — that
|
||||
# would change the command's meaning, hide the config error, and report
|
||||
# COMPLETED, defeating the per-step FAILED / continue_on_error behavior.
|
||||
if not isinstance(input_data, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'input' must be a "
|
||||
f"mapping, got {type(input_data).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Resolve expressions in input
|
||||
resolved_input: dict[str, Any] = {}
|
||||
@@ -50,8 +64,18 @@ class CommandStep(StepBase):
|
||||
# Merge options (workflow defaults ← step overrides)
|
||||
options = dict(context.default_options)
|
||||
step_options = config.get("options", {})
|
||||
if step_options:
|
||||
options.update(step_options)
|
||||
# Same rationale as 'input': a malformed options fails the step rather
|
||||
# than being silently ignored (which would let an invalid step run and
|
||||
# apparently complete).
|
||||
if not isinstance(step_options, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a "
|
||||
f"mapping, got {type(step_options).__name__}."
|
||||
),
|
||||
)
|
||||
options.update(step_options)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
args_str = str(resolved_input.get("args", ""))
|
||||
@@ -155,4 +179,16 @@ class CommandStep(StepBase):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r} is missing 'command' field."
|
||||
)
|
||||
# execute() iterates input.items() and options.update(step_options); a
|
||||
# non-mapping here would raise at run time. Validate the shape like the
|
||||
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
|
||||
# crashed on.
|
||||
if "input" in config and not isinstance(config["input"], dict):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'input' must be a mapping."
|
||||
)
|
||||
if "options" in config and not isinstance(config["options"], dict):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -24,6 +24,24 @@ class FanInStep(StepBase):
|
||||
if not isinstance(output_config, dict):
|
||||
output_config = {}
|
||||
|
||||
# The engine does not auto-validate step config, so an unvalidated run
|
||||
# with a non-list ``wait_for`` reaches here raw. Iterating it then
|
||||
# either crashes the whole run (a scalar like an int or None raises
|
||||
# TypeError) or, worse, silently iterates a string's characters and
|
||||
# yields a bogus join of empty results with a COMPLETED status — the
|
||||
# exact "silent empty result + COMPLETED" wiring bug the engine's
|
||||
# fan-in validation guards against. Fail this step loudly instead,
|
||||
# mirroring the fan-out step's non-list ``items`` handling.
|
||||
if not isinstance(wait_for, list):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' must be "
|
||||
f"a list of step IDs, got {type(wait_for).__name__}."
|
||||
),
|
||||
output={"results": []},
|
||||
)
|
||||
|
||||
# Collect results from referenced steps
|
||||
results = []
|
||||
for step_id in wait_for:
|
||||
|
||||
@@ -6,6 +6,7 @@ contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -63,6 +64,42 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch
|
||||
assert "Spec Kit project" in result.output
|
||||
|
||||
|
||||
def test_remove_reports_clean_error_when_primitive_raises_raw_exception(
|
||||
project: Path,
|
||||
):
|
||||
"""A raw exception from a primitive installer (e.g. an OSError from an
|
||||
unreadable workflow registry surfacing through _WorkflowKindManager's
|
||||
fail-closed construction) must not propagate uncaught through
|
||||
`specify bundle remove` -- the command only catches BundlerError, so
|
||||
without a conversion at the remove_bundle boundary this would exit
|
||||
with an unhandled exception and empty/raw output instead of a clean,
|
||||
actionable message, and no removal side effects should occur either."""
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
|
||||
from specify_cli.bundler.services.installer import install_bundle
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import FakeInstaller
|
||||
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
install_bundle(project, plan, FakeInstaller(), manifest=manifest)
|
||||
|
||||
def boom(self, project_root, component):
|
||||
raise OSError("workflow registry unreadable")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom)
|
||||
result = runner.invoke(app, ["bundle", "remove", "demo-bundle"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert result.output.strip() != ""
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_fail_writes_error_to_stderr_not_stdout(capsys):
|
||||
"""_fail must write to stderr, not stdout: every bundle command routes errors
|
||||
through it, and under --json the error would otherwise corrupt the JSON payload
|
||||
@@ -432,25 +469,16 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
|
||||
# ===== Private GitHub release asset URL resolution =====
|
||||
|
||||
|
||||
class FakeBundleResponse:
|
||||
class FakeBundleResponse(io.BytesIO):
|
||||
"""Minimal context-manager response stub for open_url fakes."""
|
||||
|
||||
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
|
||||
self._data = data
|
||||
super().__init__(data)
|
||||
self._url = url
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
|
||||
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
|
||||
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
from specify_cli.bundler.models.records import load_records, records_path
|
||||
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict
|
||||
@@ -97,6 +97,212 @@ def test_remove_unknown_bundle_errors(tmp_path: Path):
|
||||
remove_bundle(tmp_path, "ghost", FakeInstaller())
|
||||
|
||||
|
||||
def test_remove_converts_raw_installer_exception_to_bundler_error(tmp_path: Path):
|
||||
"""A raw exception from a primitive installer (e.g. an OSError from an
|
||||
unreadable workflow registry surfacing through _WorkflowKindManager's
|
||||
fail-closed construction) must not propagate uncaught out of
|
||||
remove_bundle: install_bundle already converts any non-BundlerError
|
||||
exception into a clean BundlerError, but remove_bundle had no such
|
||||
conversion, so the CLI's `bundle remove` (which only catches
|
||||
BundlerError) would let a raw exception through with no clean message
|
||||
and no removal side effects should occur either."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
def boom(project_root, component):
|
||||
raise OSError("workflow registry unreadable")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "is_installed", boom)
|
||||
with pytest.raises(BundlerError):
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
# No removal side effects: the bundle record must still be present.
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_partial_failure_message_reflects_partial_state(tmp_path: Path):
|
||||
"""A failure can occur after earlier components in the same bundle have
|
||||
already been removed from disk. The bundle record is left unchanged
|
||||
(save_records never runs on this path), so it still claims the bundle
|
||||
fully installed -- but the message must not claim "No changes were
|
||||
recorded" when components were, in fact, already removed."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
real_remove = installer.remove
|
||||
calls = {"n": 0}
|
||||
|
||||
def remove_then_fail(project_root, component):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return real_remove(project_root, component)
|
||||
raise OSError("disk full")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "remove", remove_then_fail)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no changes were recorded" not in message.lower()
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_bundler_error_from_installer_after_partial_removal_reports_partial_state(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""If the primitive installer itself raises BundlerError (not a raw/
|
||||
unexpected exception) after an earlier component in the same bundle was
|
||||
already removed, the surfaced message must still carry the same
|
||||
partial-removal detail as the generic-exception path -- a bare
|
||||
``except BundlerError: raise`` would re-raise the installer's original
|
||||
message verbatim with no mention that the project may now be partially
|
||||
uninstalled."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
real_remove = installer.remove
|
||||
calls = {"n": 0}
|
||||
|
||||
def remove_then_raise_bundler_error(project_root, component):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return real_remove(project_root, component)
|
||||
raise BundlerError("kind manager refused removal")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "remove", remove_then_raise_bundler_error)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no changes were recorded" not in message.lower()
|
||||
assert "kind manager refused removal" in message
|
||||
assert "partially uninstalled" in message.lower()
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_bundler_error_from_installer_with_zero_removed_reports_no_changes(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""When the installer raises BundlerError before anything was actually
|
||||
removed, the message should not misleadingly claim partial state."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
def boom(project_root, component):
|
||||
raise BundlerError("kind manager unavailable")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "is_installed", boom)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no components were removed" in message.lower()
|
||||
assert "no removal was attempted" in message.lower()
|
||||
assert "partially uninstalled" not in message.lower()
|
||||
assert "kind manager unavailable" in message
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_zero_completed_removals_still_cautions_about_partial_changes(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""`result.uninstalled` only records a component after its `remove()`
|
||||
call returns successfully. If the very first `remove()` call itself
|
||||
raises after already deleting some files, zero completed removals are
|
||||
recorded even though the project may already be partially uninstalled --
|
||||
the zero-count message must not claim "No components were removed" as
|
||||
an unqualified fact; it must caution that the failing component may
|
||||
have made partial changes before raising."""
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
def boom(project_root, component):
|
||||
# Simulates a remove() that deletes some files before raising --
|
||||
# from the caller's perspective this component was never recorded
|
||||
# as completed, but disk state may already be partially changed.
|
||||
raise OSError("disk full partway through removal")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(installer, "remove", boom)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no components were removed" in message.lower()
|
||||
assert "partial" in message.lower()
|
||||
assert "partially uninstalled" in message.lower()
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_record_save_failure_reports_partial_state(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
record_file = records_path(tmp_path)
|
||||
original_record = record_file.read_bytes()
|
||||
|
||||
def fail_dump(_data, handle, *_args, **_kwargs):
|
||||
handle.write('{"partial":')
|
||||
handle.flush()
|
||||
raise OSError("disk full")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(
|
||||
"specify_cli.bundler.lib.yamlio.json.dump",
|
||||
fail_dump,
|
||||
)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "disk full" in message
|
||||
assert "partially uninstalled" in message.lower()
|
||||
assert installer.installed == set()
|
||||
assert record_file.read_bytes() == original_record
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_record_save_failure_without_remove_attempt_is_not_partial(
|
||||
tmp_path: Path,
|
||||
):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
installer.installed.clear()
|
||||
|
||||
def fail_save(*_args, **_kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(
|
||||
"specify_cli.bundler.services.installer.save_records",
|
||||
fail_save,
|
||||
)
|
||||
with pytest.raises(BundlerError) as exc_info:
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "no removal was attempted" in message.lower()
|
||||
assert "partially uninstalled" not in message.lower()
|
||||
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
|
||||
|
||||
|
||||
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
@@ -128,7 +334,7 @@ def test_remove_counts_only_components_actually_removed(tmp_path: Path):
|
||||
|
||||
assert len(result.uninstalled) == 3
|
||||
assert (gone.kind, gone.id) not in installer.remove_calls
|
||||
assert gone in result.skipped
|
||||
assert gone not in result.skipped
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
@@ -115,6 +115,63 @@ class TestInitIntegrationFlag:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||
|
||||
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
|
||||
"""`init --here` on a non-empty directory with no confirmation input (empty
|
||||
stdin) must fail fast with guidance to use --force, instead of the bare
|
||||
'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides
|
||||
empty stdin, so typer.confirm raises Abort, which the command converts to the
|
||||
actionable error."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "nonempty-here"
|
||||
project.mkdir()
|
||||
(project / "existing.txt").write_text("keep me", encoding="utf-8")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "--force" in result.output
|
||||
# Aborted before scaffolding: the pre-existing file is untouched.
|
||||
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
def test_init_here_interactive_cancel_exits_zero(self, tmp_path, monkeypatch):
|
||||
"""An interactive Ctrl+C at the merge confirmation (typer.Abort on a TTY)
|
||||
is a normal cancellation — exit 0, "cancelled" — NOT the missing-input
|
||||
--force error, which is reserved for non-interactive EOF. Guards the
|
||||
regression where Abort was caught unconditionally and every cancel became
|
||||
an exit-1 --force error."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
import specify_cli.commands.init as init_mod
|
||||
|
||||
# Simulate an interactive terminal so the Abort is treated as a cancel.
|
||||
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
|
||||
|
||||
project = tmp_path / "cancel-here"
|
||||
project.mkdir()
|
||||
(project / "existing.txt").write_text("keep me", encoding="utf-8")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
# No input → typer.confirm raises Abort (stands in for Ctrl+C).
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "cancelled" in result.output.lower()
|
||||
assert "--force" not in result.output # not the missing-input error
|
||||
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
|
||||
|
||||
def test_integration_copilot_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
@@ -835,7 +892,8 @@ class TestInitIntegrationFlag:
|
||||
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
||||
|
||||
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
|
||||
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
|
||||
"""E2E: confirming the merge with piped "y" (no --force) preserves
|
||||
existing shared infra files (unlike --force, which overwrites them)."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
@@ -201,6 +201,36 @@ class YamlIntegrationTests:
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
assert parsed["prompt"].rstrip("\n") == body
|
||||
|
||||
def test_yaml_prompt_with_control_characters_stays_valid(self):
|
||||
"""A body containing control characters must still produce parseable YAML.
|
||||
|
||||
YAML forbids C0 control characters (except tab and newline), DEL,
|
||||
C1 controls, lone surrogates and U+FFFE/U+FFFF in every scalar form,
|
||||
and YAML 1.1 treats NEL (U+0085), LS (U+2028) and PS (U+2029) as
|
||||
line breaks that corrupt a literal block scalar's structure. The
|
||||
renderer falls back to an escaped double-quoted scalar for such
|
||||
bodies."""
|
||||
for ch in (
|
||||
"\x08", "\x0c", "\x1b", "\x7f",
|
||||
"\x80", "\x84", "\x85", "\x86", "\x9f",
|
||||
"\u2028", "\u2029",
|
||||
"\ud800", "\udfff", "\ufffe", "\uffff",
|
||||
):
|
||||
body = f"before{ch}after\nsecond line"
|
||||
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
|
||||
parsed = yaml.safe_load(rendered)
|
||||
assert parsed["prompt"].rstrip("\n") == body, f"char {ch!r} round-trip"
|
||||
|
||||
def test_yaml_prompt_with_bare_carriage_return_stays_valid(self):
|
||||
"""A bare CR (not part of CRLF) must not break the generated YAML.
|
||||
|
||||
Inside a block scalar a lone \r acts as a line break, corrupting
|
||||
the document structure."""
|
||||
body = "line1\rstill line1\nline2"
|
||||
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
|
||||
parsed = yaml.safe_load(rendered)
|
||||
assert parsed["prompt"].rstrip("\n") == body
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
|
||||
@@ -2675,6 +2675,27 @@ class TestParseIntegrationOptionsEqualsForm:
|
||||
assert result_space["commands_dir"] == "./mydir"
|
||||
assert result_equals["commands_dir"] == "./mydir"
|
||||
|
||||
def test_unbalanced_quote_exits_cleanly(self, capsys):
|
||||
"""An unbalanced quote must exit(1) with a message, not a raw ValueError.
|
||||
|
||||
shlex.split() raises ValueError("No closing quotation") on an unbalanced
|
||||
quote; the parser must translate that into the same clean typer.Exit(1)
|
||||
UX as unknown-option / missing-value, rather than letting the traceback
|
||||
escape (issue #3457).
|
||||
"""
|
||||
import typer
|
||||
|
||||
from specify_cli.integrations._commands import _parse_integration_options
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
integration = get_integration("generic")
|
||||
assert integration is not None
|
||||
|
||||
with pytest.raises(typer.Exit) as excinfo:
|
||||
_parse_integration_options(integration, '--commands-dir "foo')
|
||||
assert excinfo.value.exit_code == 1
|
||||
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestUninstallNoManifestClearsInitOptions:
|
||||
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
|
||||
|
||||
@@ -272,6 +272,20 @@ class TestMultiInstallSafeContracts:
|
||||
f"these files: {sorted(overlap)}"
|
||||
)
|
||||
|
||||
def test_kiro_cli_is_declared_multi_install_safe(self):
|
||||
"""kiro-cli confines itself to an isolated ``.kiro/`` root that no
|
||||
other integration touches, so it must be declared multi-install safe
|
||||
(issue #3471).
|
||||
|
||||
Before the fix, co-installing kiro-cli alongside another integration
|
||||
left ``specify integration status`` permanently in ERROR
|
||||
(``unsafe-multi-install``) with no way to acknowledge it. The
|
||||
parametrized isolation/manifest contracts above already exercise
|
||||
kiro-cli once the flag is set; this pins the declaration itself so a
|
||||
future edit cannot silently drop it and reintroduce the error.
|
||||
"""
|
||||
assert INTEGRATION_REGISTRY["kiro-cli"].multi_install_safe is True
|
||||
|
||||
|
||||
class TestCatalogParity:
|
||||
"""The discovery catalog must list every registered integration."""
|
||||
|
||||
@@ -7896,3 +7896,135 @@ class TestConfigManagerEnvPrefixCollision:
|
||||
cfg = cm._get_env_config()
|
||||
assert "" not in cfg
|
||||
assert cfg == {"a": {"b": "z"}}
|
||||
|
||||
|
||||
class TestConfigManagerCrossExtensionEnvLeak:
|
||||
"""Cross-extension env-var leak: a longer, co-installed sibling ID must
|
||||
own its own env vars instead of leaking them into a shorter-prefix sibling.
|
||||
|
||||
Before the fix, ``SPECKIT_GIT_HOOKS_URL`` (intended for a ``git-hooks``
|
||||
extension) also surfaced inside the ``git`` extension's config as
|
||||
``{'hooks': {'url': ...}}`` because ``SPECKIT_GIT_`` is a strict prefix of
|
||||
``SPECKIT_GIT_HOOKS_``.
|
||||
"""
|
||||
|
||||
def _install(self, project_root, ext_id):
|
||||
extensions_dir = project_root / ".specify" / "extensions"
|
||||
(extensions_dir / ext_id).mkdir(parents=True)
|
||||
# Register in the extension registry — the registry is the source of
|
||||
# truth for "installed" (a bare directory can be a config-only leftover
|
||||
# from ``ExtensionManager.remove(..., keep_config=True)``).
|
||||
ExtensionRegistry(extensions_dir).add(ext_id, {})
|
||||
|
||||
def test_sibling_owns_longer_prefix_env(self, tmp_path, monkeypatch):
|
||||
"""SPECKIT_GIT_HOOKS_URL belongs to git-hooks when co-installed with git."""
|
||||
self._install(tmp_path, "git")
|
||||
self._install(tmp_path, "git-hooks")
|
||||
monkeypatch.setenv("SPECKIT_GIT_URL", "for_git")
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
|
||||
|
||||
git_cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
gh_cfg = ConfigManager(tmp_path, "git-hooks")._get_env_config()
|
||||
|
||||
# 'git' must NOT see the git-hooks var — no cross-extension leak.
|
||||
assert git_cfg == {"url": "for_git"}
|
||||
# 'git-hooks' still receives its own var (unchanged behaviour).
|
||||
assert gh_cfg == {"url": "for_git_hooks"}
|
||||
|
||||
def test_no_sibling_installed_keeps_legacy_absorption(self, tmp_path, monkeypatch):
|
||||
"""Without a longer-prefix sibling installed, the legacy behaviour is
|
||||
preserved: ``SPECKIT_GIT_HOOKS_URL`` is absorbed as a nested key of
|
||||
the ``git`` extension. This keeps the fix strictly to the *collision*
|
||||
case and avoids surprising users who deliberately set a nested key
|
||||
via env with no sibling to disambiguate against.
|
||||
"""
|
||||
self._install(tmp_path, "git")
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
|
||||
|
||||
cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
assert cfg == {"hooks": {"url": "for_git_hooks"}}
|
||||
|
||||
def test_non_prefix_sibling_ignored(self, tmp_path, monkeypatch):
|
||||
"""A sibling whose ID does not extend our own is not a collision.
|
||||
|
||||
e.g. current='git' and sibling='not-git' — 'not-git' normalized to
|
||||
'NOT_GIT' does not start with 'GIT_', so its presence must not
|
||||
influence git's env-var interpretation.
|
||||
"""
|
||||
self._install(tmp_path, "git")
|
||||
self._install(tmp_path, "not-git")
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
|
||||
|
||||
cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
assert cfg == {"hooks": {"url": "for_git_hooks"}}
|
||||
|
||||
def test_boundary_prevents_false_positive(self, tmp_path, monkeypatch):
|
||||
"""Sibling ID 'hook' (not 'hooks') must NOT eat env keys starting
|
||||
with 'hooks'. The trailing-underscore boundary in the sibling prefix
|
||||
prevents this false positive.
|
||||
"""
|
||||
self._install(tmp_path, "git")
|
||||
self._install(tmp_path, "git-hook")
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_key_hooks")
|
||||
|
||||
# git-hook's prefix is 'HOOK_', which does not match 'HOOKS_URL',
|
||||
# so 'git' keeps the env var (single-installed semantics).
|
||||
cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
assert cfg == {"hooks": {"url": "for_git_key_hooks"}}
|
||||
|
||||
def test_missing_extensions_dir_does_not_crash(self, tmp_path, monkeypatch):
|
||||
"""A ConfigManager built against a project without ``.specify/extensions``
|
||||
(fresh project, ad-hoc test harness) must still evaluate env config
|
||||
rather than raising from the sibling scan.
|
||||
"""
|
||||
# Note: no _install call — extensions dir intentionally absent.
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
|
||||
|
||||
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
|
||||
assert cfg == {"url": "v"}
|
||||
|
||||
def test_config_only_leftover_not_treated_as_sibling(self, tmp_path, monkeypatch):
|
||||
"""A directory left behind by ``remove(..., keep_config=True)`` must
|
||||
NOT be treated as an installed sibling.
|
||||
|
||||
``ExtensionManager.remove(keep_config=True)`` preserves the extension
|
||||
directory (config files remain, dormant, for a possible reinstall) but
|
||||
removes the registry entry. The sibling scan is sourced from the
|
||||
registry, so a leftover ``git-hooks/`` directory without a registry
|
||||
entry must not silently discard ``SPECKIT_GIT_HOOKS_*`` from ``git``.
|
||||
"""
|
||||
self._install(tmp_path, "git")
|
||||
# Simulate ``remove('git-hooks', keep_config=True)``: dir present,
|
||||
# config file preserved, but no registry entry.
|
||||
gh_dir = tmp_path / ".specify" / "extensions" / "git-hooks"
|
||||
gh_dir.mkdir(parents=True)
|
||||
(gh_dir / "git-hooks-config.yml").write_text("url: leftover\n")
|
||||
# Sanity: git-hooks is NOT registered.
|
||||
registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
|
||||
assert "git-hooks" not in registry.keys()
|
||||
|
||||
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git")
|
||||
|
||||
cfg = ConfigManager(tmp_path, "git")._get_env_config()
|
||||
# git absorbs the var (no registered sibling owns it).
|
||||
assert cfg == {"hooks": {"url": "for_git"}}
|
||||
|
||||
def test_non_utf8_registry_does_not_crash(self, tmp_path, monkeypatch):
|
||||
"""A registry file with invalid text encoding must NOT propagate
|
||||
``UnicodeDecodeError`` out of the sibling scan and abort every
|
||||
config read. ``ExtensionRegistry._load()`` catches ``JSONDecodeError``
|
||||
/ ``FileNotFoundError`` only, so ``_sibling_extension_ids`` must
|
||||
additionally swallow ``UnicodeError`` and degrade to the documented
|
||||
pre-fix behaviour.
|
||||
"""
|
||||
extensions_dir = tmp_path / ".specify" / "extensions"
|
||||
extensions_dir.mkdir(parents=True)
|
||||
# Write bytes that are not valid UTF-8 to the registry file.
|
||||
(extensions_dir / ExtensionRegistry.REGISTRY_FILE).write_bytes(
|
||||
b"\xff\xfe invalid utf-8 registry \xc3\x28"
|
||||
)
|
||||
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
|
||||
|
||||
# Must not raise; must fall back to the "no siblings" path.
|
||||
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
|
||||
assert cfg == {"url": "v"}
|
||||
|
||||
@@ -152,6 +152,44 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_metadata_lookup_is_bounded_and_redirect_validated(self):
|
||||
"""Release metadata reads stay bounded and use the caller's policy."""
|
||||
captured = {}
|
||||
|
||||
class OversizedResponse:
|
||||
def read(self, amount=None):
|
||||
captured["read_amount"] = amount
|
||||
return b"x" * amount
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def redirect_validator(old_url, new_url):
|
||||
return None
|
||||
|
||||
def fake_open(
|
||||
url,
|
||||
timeout=None,
|
||||
extra_headers=None,
|
||||
redirect_validator=None,
|
||||
):
|
||||
captured["redirect_validator"] = redirect_validator
|
||||
return OversizedResponse()
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1/pack.zip",
|
||||
fake_open,
|
||||
redirect_validator=redirect_validator,
|
||||
max_metadata_bytes=8,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert captured["read_amount"] == 9
|
||||
assert captured["redirect_validator"] is redirect_validator
|
||||
|
||||
def test_tag_with_special_characters_is_url_encoded(self):
|
||||
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
|
||||
captured_urls = []
|
||||
|
||||
@@ -884,6 +884,186 @@ class TestPresetResolver:
|
||||
assert result is not None
|
||||
assert "Custom Spec Template" in result.read_text()
|
||||
|
||||
def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False):
|
||||
"""Create a pack whose manifest declares a NON-convention file: path.
|
||||
|
||||
Returns the pack dir under the project. The declared file lives at
|
||||
custom/spec.md (not the convention templates/spec-template.md).
|
||||
"""
|
||||
presets_dir = project_dir / ".specify" / "presets"
|
||||
pack_dir = presets_dir / "mypack"
|
||||
(pack_dir / "custom").mkdir(parents=True)
|
||||
(pack_dir / "custom" / "spec.md").write_text(
|
||||
"# Manifest-declared Spec\n", encoding="utf-8"
|
||||
)
|
||||
if extra_file:
|
||||
# An undeclared convention-path file the manifest points away from.
|
||||
(pack_dir / "templates").mkdir()
|
||||
(pack_dir / "templates" / "spec-template.md").write_text(
|
||||
"# Stray Convention Spec\n", encoding="utf-8"
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "mypack",
|
||||
"name": "My Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "declares a non-convention file path",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "custom/spec.md",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(pack_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest, f)
|
||||
PresetRegistry(presets_dir).add(
|
||||
"mypack", {"version": "1.0.0", "priority": 10}
|
||||
)
|
||||
return pack_dir
|
||||
|
||||
def test_resolve_uses_manifest_declared_file_path(self, project_dir):
|
||||
"""resolve() must honor a manifest-declared non-convention file: path.
|
||||
|
||||
Previously the tier-2 loop was convention-only, so it returned the
|
||||
core template and resolve_with_source() misattributed source='core',
|
||||
diverging from collect_all_layers()/resolve_content().
|
||||
"""
|
||||
pack_dir = self._install_pack_with_manifest_file(project_dir)
|
||||
resolver = PresetResolver(project_dir)
|
||||
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result == pack_dir / "custom" / "spec.md"
|
||||
assert "Manifest-declared Spec" in result.read_text()
|
||||
|
||||
sourced = resolver.resolve_with_source("spec-template")
|
||||
assert sourced is not None
|
||||
assert "mypack" in sourced["source"]
|
||||
# resolve() must agree with collect_all_layers()'s top layer.
|
||||
layers = resolver.collect_all_layers("spec-template")
|
||||
assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md"
|
||||
|
||||
def test_resolve_manifest_file_wins_over_undeclared_convention_file(
|
||||
self, project_dir
|
||||
):
|
||||
"""A stray convention-path file must not shadow the manifest's file:."""
|
||||
pack_dir = self._install_pack_with_manifest_file(
|
||||
project_dir, extra_file=True
|
||||
)
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result == pack_dir / "custom" / "spec.md"
|
||||
assert "Manifest-declared Spec" in result.read_text()
|
||||
|
||||
def test_resolve_skips_convention_when_manifest_file_missing(self, project_dir):
|
||||
"""When the manifest declares a file: that does not exist, resolve()
|
||||
must NOT fall back to a convention file in the same pack (that would
|
||||
mask a typo) — it skips the pack and resolves core instead."""
|
||||
presets_dir = project_dir / ".specify" / "presets"
|
||||
pack_dir = presets_dir / "mypack"
|
||||
# Manifest declares custom/spec.md (MISSING); a convention file exists
|
||||
# in the pack and must NOT be used.
|
||||
(pack_dir / "templates").mkdir(parents=True)
|
||||
(pack_dir / "templates" / "spec-template.md").write_text(
|
||||
"# Stray Convention Spec\n", encoding="utf-8"
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "mypack",
|
||||
"name": "My Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "declares a missing file path",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "custom/spec.md",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(pack_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest, f)
|
||||
PresetRegistry(presets_dir).add(
|
||||
"mypack", {"version": "1.0.0", "priority": 10}
|
||||
)
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
content = result.read_text()
|
||||
assert "Stray Convention Spec" not in content # pack convention skipped
|
||||
assert "Core Spec Template" in content # fell through to core
|
||||
|
||||
def test_resolve_skips_convention_when_manifest_file_is_directory(
|
||||
self, project_dir
|
||||
):
|
||||
"""When the manifest's file: path resolves to a DIRECTORY (not a regular
|
||||
file), resolve()/collect_all_layers() must treat it as missing — exists()
|
||||
would accept it and downstream read_text() on a directory would crash.
|
||||
The pack is skipped (no convention fallback), so core wins."""
|
||||
presets_dir = project_dir / ".specify" / "presets"
|
||||
pack_dir = presets_dir / "mypack"
|
||||
# Declared file: custom/spec.md is created as a DIRECTORY.
|
||||
(pack_dir / "custom" / "spec.md").mkdir(parents=True)
|
||||
# A convention file also exists and must NOT be used.
|
||||
(pack_dir / "templates").mkdir(parents=True)
|
||||
(pack_dir / "templates" / "spec-template.md").write_text(
|
||||
"# Stray Convention Spec\n", encoding="utf-8"
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "mypack",
|
||||
"name": "My Pack",
|
||||
"version": "1.0.0",
|
||||
"description": "declares a file: that is actually a directory",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "custom/spec.md",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(pack_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest, f)
|
||||
PresetRegistry(presets_dir).add(
|
||||
"mypack", {"version": "1.0.0", "priority": 10}
|
||||
)
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("spec-template")
|
||||
assert result is not None
|
||||
assert result.is_file() # never a directory
|
||||
content = result.read_text()
|
||||
assert "Stray Convention Spec" not in content # pack convention skipped
|
||||
assert "Core Spec Template" in content # fell through to core
|
||||
# collect_all_layers() must agree: the directory is not a layer.
|
||||
layers = resolver.collect_all_layers("spec-template")
|
||||
assert all(Path(layer["path"]).is_file() for layer in layers)
|
||||
assert all(
|
||||
Path(layer["path"]) != pack_dir / "custom" / "spec.md"
|
||||
for layer in layers
|
||||
)
|
||||
|
||||
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
|
||||
"""Test that overrides take priority over installed packs."""
|
||||
# Install the pack
|
||||
@@ -4836,26 +5016,13 @@ class TestPresetAddFromUrlResolution:
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
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({
|
||||
return io.BytesIO(json.dumps({
|
||||
"assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(zip_bytes)
|
||||
return io.BytesIO(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
@@ -4894,22 +5061,9 @@ class TestPresetAddFromUrlResolution:
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
return FakeResponse(zip_bytes)
|
||||
return io.BytesIO(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
@@ -4951,26 +5105,13 @@ class TestPresetAddFromUrlResolution:
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
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({
|
||||
return io.BytesIO(json.dumps({
|
||||
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(zip_bytes)
|
||||
return io.BytesIO(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user