mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800fe33f59 | ||
|
|
4028c50af8 | ||
|
|
67fecd357a | ||
|
|
bb2b49d0ae | ||
|
|
ac2cb5daf5 | ||
|
|
1732b9b62e | ||
|
|
1f9eaf3ff3 | ||
|
|
9e05195d24 | ||
|
|
6d511acfb9 | ||
|
|
06c76533cb | ||
|
|
9768b1eb88 | ||
|
|
c9c02ae790 | ||
|
|
d79a514b30 | ||
|
|
ee17b04784 | ||
|
|
a1b8de68bc | ||
|
|
7bab0568c5 | ||
|
|
7c558ab241 | ||
|
|
39921ddd3b | ||
|
|
d82eed859c |
28
.editorconfig
Normal file
28
.editorconfig
Normal file
@@ -0,0 +1,28 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{json,jsonc}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{sh,bash}]
|
||||
indent_size = 4
|
||||
|
||||
[*.{ps1,psm1,psd1}]
|
||||
indent_size = 4
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -2,6 +2,48 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.9.3] - 2026-06-03
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: render script command hints with active agent separator (#2649)
|
||||
- chore(tests): fix ruff lint violations in tests/ (#2827)
|
||||
- fix(workflows): validate run_id in RunState.load before touching the … (#2813)
|
||||
- feat(cli): implement specify self upgrade (#2475)
|
||||
- feat(workflows): allow resume to accept updated workflow inputs (#2815)
|
||||
- catalog: rename "superpowers-bridge" to "superspec" (v1.0.1) (#2772)
|
||||
- fix(cli): force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError (#2817)
|
||||
- fix(plan): clarify quickstart validation guide scope (#2805)
|
||||
- chore: release 0.9.2, begin 0.9.3.dev0 development (#2823)
|
||||
|
||||
## [0.9.2] - 2026-06-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Update agent parity governance preset catalog entry (#2777)
|
||||
- fix: resolve GitHub release asset API URL for private repo extension downloads (#2792)
|
||||
- fix: remove unsupported mode: frontmatter from Copilot skills mode (fixes #2799) (#2819)
|
||||
- refactor(integrations): co-locate integration commands in integrations/ domain dir (PR-5/8) (#2720)
|
||||
- Update Product Forge extension to v1.6.0 (#2820)
|
||||
- feat(workflows): add continue_on_error step field for non-halting failures (#2663)
|
||||
- chore: add .editorconfig for consistent code formatting (#2366)
|
||||
- fix(shared-infra): record skipped files in speckit.manifest.json (#2483)
|
||||
- chore: release 0.9.1, begin 0.9.2.dev0 development (#2818)
|
||||
|
||||
## [0.9.1] - 2026-06-02
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O (#2686)
|
||||
- docs: list Hermes in supported integrations table (#2768)
|
||||
- fix(copilot): resolve active spec template (#2765)
|
||||
- fix: add missing agent-context extension entries to Cline _expected_files (#2797)
|
||||
- Add spec-kit-linear extension to community catalog (#2795)
|
||||
- feat: add native Cline integration (#2508)
|
||||
- Update workflow-preset community catalog entry (#2756)
|
||||
- chore: release 0.9.0, begin 0.9.1.dev0 development (#2794)
|
||||
- Add RAG Azure Builder extension to community catalog (#2793)
|
||||
|
||||
## [0.9.0] - 2026-06-01
|
||||
|
||||
### Changed
|
||||
|
||||
22
README.md
22
README.md
@@ -59,6 +59,24 @@ specify init my-project --integration copilot
|
||||
cd my-project
|
||||
```
|
||||
|
||||
To check for updates or upgrade the installed CLI, use the self-management commands. See the [Upgrade Guide](./docs/upgrade.md) for detailed scenarios and customization options.
|
||||
|
||||
```bash
|
||||
# Check whether a newer release is available (read-only — does not modify anything)
|
||||
specify self check
|
||||
|
||||
# Preview what would run, without actually upgrading
|
||||
specify self upgrade --dry-run
|
||||
|
||||
# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Or pin a specific release tag (replace vX.Y.Z[suffix] with your desired release tag)
|
||||
specify self upgrade --tag vX.Y.Z[suffix]
|
||||
```
|
||||
|
||||
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. For `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work, including dev, alpha/beta/rc, or build metadata suffixes. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed).
|
||||
|
||||
### 3. Establish project principles
|
||||
|
||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||
@@ -133,7 +151,7 @@ Run `specify integration list` to see all available integrations in your install
|
||||
|
||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration <agent> --integration-options="--skills"` installs agent skills instead of slash-command prompt files.
|
||||
|
||||
#### Core Commands
|
||||
### Core Commands
|
||||
|
||||
Essential commands for the Spec-Driven Development workflow:
|
||||
|
||||
@@ -146,7 +164,7 @@ Essential commands for the Spec-Driven Development workflow:
|
||||
| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution |
|
||||
| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan |
|
||||
|
||||
#### Optional Commands
|
||||
### Optional Commands
|
||||
|
||||
Additional commands for enhanced quality and validation:
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Forge | Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
@@ -114,8 +114,8 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
|
||||
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
|
||||
@@ -8,7 +8,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Keeps shared AI-agent instructions aligned across project-defined agent guidance surfaces and documents intentional deviations | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| Agent Parity Governance | Keeps shared AI-agent instructions aligned and adds agent-neutral Spec Kit model-routing guidance across project-defined agent guidance surfaces | 9 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| 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) |
|
||||
|
||||
@@ -88,6 +88,8 @@ specify version
|
||||
|
||||
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
|
||||
|
||||
**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md).
|
||||
|
||||
After initialization, you should see the following commands available in your coding agent:
|
||||
|
||||
- `/speckit.specify` - Create specifications
|
||||
|
||||
@@ -28,8 +28,18 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
|
||||
specify workflow resume <run_id>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | -------------------------------------------------------- |
|
||||
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
|
||||
|
||||
Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure.
|
||||
|
||||
Supplied `--input` values are merged over the run's stored inputs and re-validated against the workflow's input types, then the blocked step is re-run with the updated values. This lets a run continue with information that only became available after it paused, or with a corrected value after a failure:
|
||||
|
||||
```bash
|
||||
specify workflow resume <run_id> --input cmd="exit 0"
|
||||
```
|
||||
|
||||
## Workflow Status
|
||||
|
||||
```bash
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
|
||||
| What to Upgrade | Command | When to Use |
|
||||
|----------------|---------|-------------|
|
||||
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
|
||||
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
|
||||
| **CLI Tool (recommended)** | `specify self upgrade` | Latest stable release, in place. Auto-detects whether you installed via `uv tool` or `pipx`. |
|
||||
| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms. |
|
||||
| **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. |
|
||||
| **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. |
|
||||
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||
|
||||
@@ -19,12 +21,32 @@
|
||||
|
||||
The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes.
|
||||
|
||||
Before upgrading, you can check whether a newer released version is available:
|
||||
### Recommended: `specify self upgrade`
|
||||
|
||||
The CLI ships with two self-management commands that handle the common case automatically:
|
||||
|
||||
```bash
|
||||
# Check whether a newer release is available (read-only — does not modify anything)
|
||||
specify self check
|
||||
|
||||
# Preview what would run, without actually upgrading
|
||||
specify self upgrade --dry-run
|
||||
|
||||
# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Or pin a specific release tag (replace vX.Y.Z[suffix] with the tag you want)
|
||||
specify self upgrade --tag vX.Y.Z[suffix]
|
||||
```
|
||||
|
||||
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything.
|
||||
|
||||
Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected.
|
||||
|
||||
Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases.
|
||||
|
||||
If your installed CLI is older than the release that introduced `specify self upgrade`, use the manual equivalents below. These commands are also useful when you want explicit control over the installer command.
|
||||
|
||||
### If you installed with `uv tool install`
|
||||
|
||||
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
|
||||
@@ -54,10 +76,14 @@ pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
### Verify the upgrade
|
||||
|
||||
```bash
|
||||
# Confirms the CLI is working and shows installed tools
|
||||
specify check
|
||||
|
||||
# Confirms the installed version against the latest GitHub release
|
||||
specify self check
|
||||
```
|
||||
|
||||
This shows installed tools and confirms the CLI is working. Use `specify version` to confirm which persistent CLI version is currently on your `PATH`.
|
||||
`specify check` shows the surrounding tool environment; `specify self check` is read-only and tells you whether you're now on the latest release (`Up to date: X.Y.Z`) or if a newer one became available between releases.
|
||||
|
||||
---
|
||||
|
||||
@@ -186,8 +212,8 @@ Restart your IDE to refresh the command list.
|
||||
### Scenario 1: "I just want new slash commands"
|
||||
|
||||
```bash
|
||||
# Upgrade CLI (if using persistent install)
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
||||
# Upgrade CLI (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Update project files to get new commands
|
||||
specify init --here --force --integration copilot
|
||||
@@ -204,7 +230,7 @@ cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||
cp -r .specify/templates /tmp/templates-backup
|
||||
|
||||
# 2. Upgrade CLI
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
||||
specify self upgrade
|
||||
|
||||
# 3. Update project
|
||||
specify init --here --force --integration copilot
|
||||
@@ -388,15 +414,19 @@ Only Spec Kit infrastructure files:
|
||||
|
||||
### "CLI upgrade doesn't seem to work"
|
||||
|
||||
If a command behaves like an older Spec Kit version, first check for local CLI drift:
|
||||
If a command behaves like an older Spec Kit version, first ask the CLI itself:
|
||||
|
||||
```bash
|
||||
# Read-only — prints "Up to date: X.Y.Z" or "Update available: X.Y.Z → vY.Z.W"
|
||||
specify self check
|
||||
|
||||
# Preview the install method, current version, and target tag the upgrade would use
|
||||
specify self upgrade --dry-run
|
||||
```
|
||||
|
||||
`specify check` is an offline environment scan; `specify self check` is the CLI version lookup.
|
||||
|
||||
Verify the installation:
|
||||
If `self check` shows the wrong version, verify the installation:
|
||||
|
||||
```bash
|
||||
# Check installed tools
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-01T00:00:00Z",
|
||||
"updated_at": "2026-06-02T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -2055,10 +2055,10 @@
|
||||
"product-forge": {
|
||||
"name": "Product Forge",
|
||||
"id": "product-forge",
|
||||
"description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model",
|
||||
"description": "Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies",
|
||||
"author": "VaiYav",
|
||||
"version": "1.5.1",
|
||||
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip",
|
||||
"version": "1.6.0",
|
||||
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.6.0.zip",
|
||||
"repository": "https://github.com/VaiYav/speckit-product-forge",
|
||||
"homepage": "https://github.com/VaiYav/speckit-product-forge",
|
||||
"documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md",
|
||||
@@ -2068,7 +2068,7 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 29,
|
||||
"commands": 31,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -2082,7 +2082,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-28T00:00:00Z",
|
||||
"updated_at": "2026-04-24T15:52:00Z"
|
||||
"updated_at": "2026-06-02T00:00:00Z"
|
||||
},
|
||||
"qa": {
|
||||
"name": "QA Testing Extension",
|
||||
@@ -3039,13 +3039,13 @@
|
||||
"created_at": "2026-03-30T00:00:00Z",
|
||||
"updated_at": "2026-05-24T01:07:34Z"
|
||||
},
|
||||
"superpowers-bridge": {
|
||||
"name": "Superpowers Bridge",
|
||||
"id": "superpowers-bridge",
|
||||
"superspec": {
|
||||
"name": "Superspec",
|
||||
"id": "superspec",
|
||||
"description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.",
|
||||
"author": "WangX0111",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/WangX0111/superspec",
|
||||
"homepage": "https://github.com/WangX0111/superspec",
|
||||
"documentation": "https://github.com/WangX0111/superspec/blob/main/README.md",
|
||||
@@ -3070,7 +3070,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-04-22T00:00:00Z"
|
||||
"updated_at": "2026-05-30T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
@@ -3607,4 +3607,4 @@
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-28T00:00:00Z",
|
||||
"updated_at": "2026-05-31T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -34,11 +34,11 @@
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Keeps shared AI-agent guidance aligned across a project-defined set of agent instruction surfaces.",
|
||||
"version": "0.2.0",
|
||||
"description": "Keeps shared AI-agent guidance aligned and adds agent-neutral Spec Kit model-routing guidance across declared agent instruction surfaces.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -46,18 +46,20 @@
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 6,
|
||||
"templates": 9,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"agents",
|
||||
"governance",
|
||||
"parity",
|
||||
"agent-md",
|
||||
"agent-guidance",
|
||||
"model-routing",
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
"updated_at": "2026-05-31T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.9.1.dev0"
|
||||
version = "0.9.3"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -117,20 +117,20 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
|
||||
echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
|
||||
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for tasks.md if required
|
||||
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run __SPECKIT_COMMAND_TASKS__ first to create the task list." >&2
|
||||
echo "Run $(format_speckit_command tasks "$REPO_ROOT") first to create the task list." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -307,6 +307,83 @@ has_jq() {
|
||||
command -v jq >/dev/null 2>&1
|
||||
}
|
||||
|
||||
get_invoke_separator() {
|
||||
local repo_root="${1:-$(get_repo_root)}"
|
||||
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
||||
printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local integration_json="$repo_root/.specify/integration.json"
|
||||
local separator="."
|
||||
local parsed_with_jq=0
|
||||
|
||||
if [[ -f "$integration_json" ]]; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
local jq_separator
|
||||
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
|
||||
parsed_with_jq=1
|
||||
case "$jq_separator" in
|
||||
"."|"-") separator="$jq_separator" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
||||
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
state = json.load(fh)
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
separator = "."
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
separator = entry["invoke_separator"]
|
||||
print(separator)
|
||||
except Exception:
|
||||
print(".")
|
||||
PY
|
||||
); then
|
||||
case "$separator" in
|
||||
"."|"-") ;;
|
||||
*) separator="." ;;
|
||||
esac
|
||||
else
|
||||
separator="."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
||||
printf '%s\n' "$separator"
|
||||
}
|
||||
|
||||
format_speckit_command() {
|
||||
local command_name="$1"
|
||||
local repo_root="${2:-$(get_repo_root)}"
|
||||
local separator
|
||||
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
||||
separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
||||
else
|
||||
separator=$(get_invoke_separator "$repo_root")
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
||||
fi
|
||||
|
||||
command_name="${command_name#/}"
|
||||
command_name="${command_name#speckit.}"
|
||||
command_name="${command_name#speckit-}"
|
||||
command_name="${command_name//./$separator}"
|
||||
|
||||
printf '/speckit%s%s\n' "$separator" "$command_name"
|
||||
}
|
||||
|
||||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||||
json_escape() {
|
||||
|
||||
@@ -35,13 +35,13 @@ fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
|
||||
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
|
||||
echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -89,20 +89,23 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GI
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure."
|
||||
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $specifyCommand first to create the feature structure."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan."
|
||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $planCommand first to create the implementation plan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for tasks.md if required
|
||||
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
||||
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run __SPECKIT_COMMAND_TASKS__ first to create the task list."
|
||||
$tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $tasksCommand first to create the task list."
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -355,6 +355,58 @@ function Test-DirHasFiles {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InvokeSeparator {
|
||||
param([string]$RepoRoot = (Get-RepoRoot))
|
||||
|
||||
if ($null -eq $script:SpecKitInvokeSeparatorCache) {
|
||||
$script:SpecKitInvokeSeparatorCache = @{}
|
||||
}
|
||||
if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) {
|
||||
return $script:SpecKitInvokeSeparatorCache[$RepoRoot]
|
||||
}
|
||||
|
||||
$separator = '.'
|
||||
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
|
||||
if (Test-Path -LiteralPath $integrationJson -PathType Leaf) {
|
||||
try {
|
||||
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
|
||||
$key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' }
|
||||
if ($key -and $state.integration_settings) {
|
||||
$settingProperty = $state.integration_settings.PSObject.Properties[$key]
|
||||
if ($settingProperty) {
|
||||
$setting = $settingProperty.Value
|
||||
if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) {
|
||||
$separator = [string]$setting.invoke_separator
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$separator = '.'
|
||||
}
|
||||
}
|
||||
|
||||
$script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator
|
||||
return $separator
|
||||
}
|
||||
|
||||
function Format-SpecKitCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CommandName,
|
||||
[string]$RepoRoot = (Get-RepoRoot)
|
||||
)
|
||||
|
||||
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
|
||||
$name = $CommandName.TrimStart('/')
|
||||
if ($name.StartsWith('speckit.')) {
|
||||
$name = $name.Substring(8)
|
||||
} elseif ($name.StartsWith('speckit-')) {
|
||||
$name = $name.Substring(8)
|
||||
}
|
||||
$name = $name -replace '\.', $separator
|
||||
|
||||
return "/speckit$separator$name"
|
||||
}
|
||||
|
||||
# Find a usable Python 3 executable (python3, python, or py -3).
|
||||
# Returns the command/arguments as an array, or $null if none found.
|
||||
function Get-Python3Command {
|
||||
|
||||
@@ -28,13 +28,15 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan.")
|
||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
||||
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure.")
|
||||
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
|
||||
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
"""specify extension * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -151,13 +151,15 @@ def register(app: typer.Typer) -> None:
|
||||
# Lazy imports to avoid circular dependency — __init__.py imports this module
|
||||
from .. import (
|
||||
_install_shared_infra_or_exit,
|
||||
_parse_integration_options,
|
||||
_print_cli_warning,
|
||||
_update_agent_context_config_file,
|
||||
_write_integration_json,
|
||||
ensure_executable_scripts,
|
||||
save_init_options,
|
||||
)
|
||||
from ..integrations._commands import (
|
||||
_parse_integration_options,
|
||||
_write_integration_json,
|
||||
)
|
||||
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
||||
|
||||
show_banner()
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
"""specify integration * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -1,2 +0,0 @@
|
||||
"""specify preset * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -1,2 +0,0 @@
|
||||
"""specify workflow * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -1749,13 +1749,59 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
from specify_cli.authentication.http import build_request
|
||||
return build_request(url)
|
||||
|
||||
def _open_url(self, url: str, timeout: int = 10):
|
||||
def _open_url(
|
||||
self,
|
||||
url: str,
|
||||
timeout: int = 10,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
"""Open a URL with provider-based auth, trying each configured provider.
|
||||
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
"""
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout)
|
||||
return open_url(url, timeout, extra_headers=extra_headers)
|
||||
|
||||
def _resolve_github_release_asset_api_url(
|
||||
self,
|
||||
download_url: str,
|
||||
timeout: int = 60,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a GitHub release asset URL to its API asset URL."""
|
||||
import urllib.error
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
|
||||
if (
|
||||
parsed.hostname == "api.github.com"
|
||||
and len(parts) >= 6
|
||||
and parts[:1] == ["repos"]
|
||||
and parts[3:5] == ["releases", "assets"]
|
||||
):
|
||||
return download_url
|
||||
|
||||
if parsed.hostname != "github.com":
|
||||
return None
|
||||
|
||||
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
|
||||
return None
|
||||
|
||||
owner, repo, tag = parts[0], parts[1], parts[4]
|
||||
asset_name = "/".join(parts[5:])
|
||||
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
|
||||
|
||||
try:
|
||||
with self._open_url(release_url, timeout=timeout) as response:
|
||||
release_data = json.loads(response.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
for asset in release_data.get("assets", []):
|
||||
if asset.get("name") == asset_name and asset.get("url"):
|
||||
return str(asset["url"])
|
||||
|
||||
return None
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
"""Get the ordered list of active catalogs.
|
||||
@@ -2155,9 +2201,15 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
zip_filename = f"{extension_id}-{version}.zip"
|
||||
zip_path = target_dir / zip_filename
|
||||
|
||||
extra_headers = None
|
||||
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
|
||||
if resolved_download_url:
|
||||
download_url = resolved_download_url
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
# Download the ZIP file
|
||||
try:
|
||||
with self._open_url(download_url, timeout=60) as response:
|
||||
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
|
||||
zip_data = response.read()
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
|
||||
34
src/specify_cli/integrations/_commands.py
Normal file
34
src/specify_cli/integrations/_commands.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""specify integration * commands — app objects and register() entry point."""
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from .._assets import get_speckit_version # noqa: F401 — re-exported for monkeypatching in tests
|
||||
|
||||
# Re-export helpers used by commands/init.py and tests
|
||||
from ._helpers import ( # noqa: F401
|
||||
_cli_error_detail,
|
||||
_cli_phase_label,
|
||||
_parse_integration_options,
|
||||
_write_integration_json,
|
||||
)
|
||||
|
||||
integration_app = typer.Typer(
|
||||
name="integration",
|
||||
help="Manage coding agent integrations",
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
integration_catalog_app = typer.Typer(
|
||||
name="catalog",
|
||||
help="Manage integration catalog sources",
|
||||
add_completion=False,
|
||||
)
|
||||
integration_app.add_typer(integration_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
from . import _install_commands # noqa: F401 — registers handlers via decorators
|
||||
from . import _migrate_commands # noqa: F401
|
||||
from . import _query_commands # noqa: F401
|
||||
app.add_typer(integration_app, name="integration")
|
||||
402
src/specify_cli/integrations/_helpers.py
Normal file
402
src/specify_cli/integrations/_helpers.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""specify integration helpers — internal utilities shared across command modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from .._agent_config import SCRIPT_TYPE_CHOICES
|
||||
from .._console import console
|
||||
from ..integration_runtime import (
|
||||
invoke_separator_for_integration as _invoke_separator_for_integration,
|
||||
resolve_integration_options as _resolve_integration_options_impl,
|
||||
with_integration_setting as _with_integration_setting,
|
||||
)
|
||||
from ..integration_state import (
|
||||
INTEGRATION_JSON,
|
||||
INTEGRATION_STATE_SCHEMA,
|
||||
integration_setting as _integration_setting,
|
||||
try_read_integration_json as _try_read_integration_json,
|
||||
write_integration_json as _write_integration_json_file,
|
||||
)
|
||||
|
||||
|
||||
def _get_speckit_version() -> str:
|
||||
"""Return the current Spec Kit version.
|
||||
|
||||
Resolved lazily through ``_commands.get_speckit_version`` so that tests
|
||||
that monkeypatch ``specify_cli.integrations._commands.get_speckit_version``
|
||||
still affect helpers called from the command handlers.
|
||||
"""
|
||||
from . import _commands # noqa: PLC0415 — intentional late import to avoid circular + enable patching
|
||||
return _commands.get_speckit_version()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON read / write helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_integration_json(project_root: Path) -> dict[str, Any]:
|
||||
"""Load ``.specify/integration.json``. Returns normalized state when present.
|
||||
|
||||
Delegates the parse / schema-guard logic to the shared
|
||||
:func:`_try_read_integration_json` helper so the CLI and workflow engine
|
||||
cannot drift on validation rules. Each error variant is translated into
|
||||
the existing loud-fail UX (console message + ``typer.Exit(1)``).
|
||||
"""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
state, error = _try_read_integration_json(project_root)
|
||||
if error is None:
|
||||
return state or {}
|
||||
if error.kind == "decode":
|
||||
console.print(f"[red]Error:[/red] {path} contains invalid JSON or is not valid UTF-8.")
|
||||
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
||||
console.print(f"[dim]Details:[/dim] {error.detail}")
|
||||
elif error.kind == "os":
|
||||
console.print(f"[red]Error:[/red] Could not read {path}.")
|
||||
console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.")
|
||||
console.print(f"[dim]Details:[/dim] {error.detail}")
|
||||
elif error.kind == "not_object":
|
||||
console.print(
|
||||
f"[red]Error:[/red] {path} must contain a JSON object, got {error.detail}."
|
||||
)
|
||||
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
||||
elif error.kind == "schema_too_new":
|
||||
console.print(
|
||||
f"[red]Error:[/red] {path} uses integration state schema {error.schema}, "
|
||||
f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}."
|
||||
)
|
||||
console.print("Please upgrade Spec Kit before modifying integrations.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _write_integration_json(
|
||||
project_root: Path,
|
||||
integration_key: str | None,
|
||||
installed_integrations: list[str] | None = None,
|
||||
integration_settings: dict[str, dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Write ``.specify/integration.json`` with legacy-compatible state."""
|
||||
_write_integration_json_file(
|
||||
project_root,
|
||||
version=_get_speckit_version(),
|
||||
integration_key=integration_key,
|
||||
installed_integrations=installed_integrations,
|
||||
settings=integration_settings,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# init-options.json helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _refresh_init_options_speckit_version(project_root: Path) -> None:
|
||||
"""Refresh only the Spec Kit version recorded in init-options.json."""
|
||||
from .. import load_init_options, save_init_options
|
||||
opts = load_init_options(project_root)
|
||||
if not isinstance(opts, dict) or not opts:
|
||||
return
|
||||
opts["speckit_version"] = _get_speckit_version()
|
||||
save_init_options(project_root, opts)
|
||||
|
||||
|
||||
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
|
||||
"""Clear active integration keys from init-options.json when they match.
|
||||
|
||||
Also clears ``context_file`` from the agent-context extension config so
|
||||
no stale path is left behind when the integration is uninstalled.
|
||||
"""
|
||||
from .. import (
|
||||
_AGENT_CTX_EXT_CONFIG,
|
||||
_update_agent_context_config_file,
|
||||
load_init_options,
|
||||
save_init_options,
|
||||
)
|
||||
opts = load_init_options(project_root)
|
||||
has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts)
|
||||
# Remove legacy fields that older versions may have written.
|
||||
opts.pop("context_file", None)
|
||||
opts.pop("context_markers", None)
|
||||
|
||||
if opts.get("integration") == integration_key or opts.get("ai") == integration_key:
|
||||
opts.pop("integration", None)
|
||||
opts.pop("ai", None)
|
||||
opts.pop("ai_skills", None)
|
||||
save_init_options(project_root, opts)
|
||||
# Clear context_file in the extension config if it already exists.
|
||||
# Avoid creating the config (and parent dirs) in projects where the
|
||||
# agent-context extension was never installed.
|
||||
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
if ext_cfg_path.exists():
|
||||
_update_agent_context_config_file(
|
||||
project_root, "", preserve_markers=True
|
||||
)
|
||||
elif has_legacy_context_keys:
|
||||
save_init_options(project_root, opts)
|
||||
|
||||
|
||||
def _remove_integration_json(project_root: Path) -> None:
|
||||
"""Remove ``.specify/integration.json`` if it exists."""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error sentinels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError)
|
||||
|
||||
|
||||
class _SharedTemplateRefreshError(RuntimeError):
|
||||
"""Raised when default integration metadata should not be persisted."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script type resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_script_type(script_type: str, source: str) -> str:
|
||||
"""Normalize and validate a script type from CLI/config sources."""
|
||||
normalized = script_type.strip().lower()
|
||||
if normalized in SCRIPT_TYPE_CHOICES:
|
||||
return normalized
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. "
|
||||
f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
|
||||
"""Resolve the script type from the CLI flag or init-options.json."""
|
||||
from .. import load_init_options
|
||||
if script_type:
|
||||
return _normalize_script_type(script_type, "--script")
|
||||
opts = load_init_options(project_root)
|
||||
saved = opts.get("script")
|
||||
if isinstance(saved, str) and saved.strip():
|
||||
return _normalize_script_type(saved, ".specify/init-options.json")
|
||||
return "ps" if os.name == "nt" else "sh"
|
||||
|
||||
|
||||
def _resolve_integration_script_type(
|
||||
project_root: Path,
|
||||
state: dict[str, Any],
|
||||
key: str,
|
||||
script_type: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve script type for an integration, preferring stored settings."""
|
||||
if script_type:
|
||||
return _normalize_script_type(script_type, "--script")
|
||||
|
||||
stored = _integration_setting(state, key).get("script")
|
||||
if isinstance(stored, str) and stored.strip():
|
||||
return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script")
|
||||
|
||||
return _resolve_script_type(project_root, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None:
|
||||
"""Parse --integration-options string into a dict matching the integration's declared options.
|
||||
|
||||
Returns ``None`` when no options are provided.
|
||||
"""
|
||||
import shlex
|
||||
parsed: dict[str, Any] = {}
|
||||
tokens = shlex.split(raw_options)
|
||||
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))
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
if not token.startswith("-"):
|
||||
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
|
||||
if allowed:
|
||||
console.print(f"Allowed options: {allowed}")
|
||||
raise typer.Exit(1)
|
||||
name = token.lstrip("-")
|
||||
value: str | None = None
|
||||
# Handle --name=value syntax
|
||||
if "=" in name:
|
||||
name, value = name.split("=", 1)
|
||||
opt = declared.get(name)
|
||||
if not opt:
|
||||
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
|
||||
if allowed:
|
||||
console.print(f"Allowed options: {allowed}")
|
||||
raise typer.Exit(1)
|
||||
key = name.replace("-", "_")
|
||||
if opt.is_flag:
|
||||
if value is not None:
|
||||
console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.")
|
||||
raise typer.Exit(1)
|
||||
parsed[key] = True
|
||||
i += 1
|
||||
elif value is not None:
|
||||
parsed[key] = value
|
||||
i += 1
|
||||
elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
|
||||
parsed[key] = tokens[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.")
|
||||
raise typer.Exit(1)
|
||||
return parsed or None
|
||||
|
||||
|
||||
def _resolve_integration_options(
|
||||
integration: Any,
|
||||
state: dict[str, Any],
|
||||
key: str,
|
||||
raw_options: str | None,
|
||||
) -> tuple[str | None, dict[str, Any] | None]:
|
||||
"""Resolve raw and parsed options for an integration operation."""
|
||||
return _resolve_integration_options_impl(
|
||||
integration,
|
||||
state,
|
||||
key,
|
||||
raw_options,
|
||||
parse_options=_parse_integration_options,
|
||||
)
|
||||
|
||||
|
||||
def _update_init_options_for_integration(
|
||||
project_root: Path,
|
||||
integration: Any,
|
||||
script_type: str | None = None,
|
||||
) -> None:
|
||||
"""Update init-options.json and the agent-context extension config to
|
||||
reflect *integration* as the active one.
|
||||
|
||||
``context_file`` and ``context_markers`` are stored in the agent-context
|
||||
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
|
||||
not in ``init-options.json``. Existing user-customised markers are
|
||||
always preserved when the config already exists; invalid marker values
|
||||
are silently ignored at runtime by ``_resolve_context_markers()`` which
|
||||
falls back to the class-level defaults.
|
||||
"""
|
||||
from .. import (
|
||||
_AGENT_CTX_EXT_CONFIG,
|
||||
_update_agent_context_config_file,
|
||||
load_init_options,
|
||||
save_init_options,
|
||||
)
|
||||
from .base import SkillsIntegration
|
||||
opts = load_init_options(project_root)
|
||||
opts["integration"] = integration.key
|
||||
opts["ai"] = integration.key
|
||||
# Remove legacy fields if they were written by an older version.
|
||||
opts.pop("context_file", None)
|
||||
opts.pop("context_markers", None)
|
||||
opts["speckit_version"] = _get_speckit_version()
|
||||
if script_type:
|
||||
opts["script"] = script_type
|
||||
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
|
||||
opts["ai_skills"] = True
|
||||
else:
|
||||
opts.pop("ai_skills", None)
|
||||
|
||||
# Update the agent-context extension config BEFORE init-options.json
|
||||
# so a failure here doesn't leave init-options partially updated.
|
||||
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
if ext_cfg_path.exists():
|
||||
_update_agent_context_config_file(
|
||||
project_root,
|
||||
integration.context_file,
|
||||
preserve_markers=True,
|
||||
)
|
||||
elif integration.context_file:
|
||||
# Extension config doesn't exist yet (extension not installed).
|
||||
# Write defaults so scripts have something to read.
|
||||
_update_agent_context_config_file(
|
||||
project_root,
|
||||
integration.context_file,
|
||||
preserve_markers=False,
|
||||
)
|
||||
|
||||
save_init_options(project_root, opts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default integration persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _set_default_integration(
|
||||
project_root: Path,
|
||||
state: dict[str, Any],
|
||||
key: str,
|
||||
integration: Any,
|
||||
installed_keys: list[str],
|
||||
*,
|
||||
script_type: str | None = None,
|
||||
raw_options: str | None = None,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
refresh_templates: bool = True,
|
||||
refresh_templates_force: bool = False,
|
||||
refresh_hint: str | None = None,
|
||||
) -> None:
|
||||
"""Persist *key* as default and align active runtime metadata."""
|
||||
from .. import _install_shared_infra
|
||||
resolved_script = _resolve_integration_script_type(project_root, state, key, script_type)
|
||||
settings = _with_integration_setting(
|
||||
state,
|
||||
key,
|
||||
integration,
|
||||
script_type=resolved_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
|
||||
if refresh_templates:
|
||||
try:
|
||||
_install_shared_infra(
|
||||
project_root,
|
||||
resolved_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
integration, {"integration_settings": settings}, key, parsed_options
|
||||
),
|
||||
force=refresh_templates_force,
|
||||
refresh_managed=True,
|
||||
refresh_hint=refresh_hint,
|
||||
)
|
||||
except (ValueError, OSError) as exc:
|
||||
raise _SharedTemplateRefreshError(
|
||||
f"Failed to refresh shared infrastructure for '{key}': {exc}"
|
||||
) from exc
|
||||
|
||||
_write_integration_json(project_root, key, installed_keys, settings)
|
||||
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
|
||||
|
||||
|
||||
def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
|
||||
try:
|
||||
_set_default_integration(*args, **kwargs)
|
||||
except _SharedTemplateRefreshError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI formatting helpers (re-exported from _commands.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cli_error_detail(exc: BaseException) -> str:
|
||||
"""Return a compact one-line exception detail for CLI output."""
|
||||
return str(exc).replace("\n", " ").strip() or exc.__class__.__name__
|
||||
|
||||
|
||||
def _cli_phase_label(phase: str, target_kind: str, target: str | None = None) -> str:
|
||||
"""Format a stable operation label for user-visible diagnostics."""
|
||||
label = f"{phase} {target_kind}".strip()
|
||||
if target:
|
||||
label = f"{label} '{target}'"
|
||||
return label
|
||||
309
src/specify_cli/integrations/_install_commands.py
Normal file
309
src/specify_cli/integrations/_install_commands.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""specify integration install / uninstall command handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import typer
|
||||
|
||||
from .._console import console
|
||||
from .._utils import _display_project_path
|
||||
from ..integration_runtime import (
|
||||
invoke_separator_for_integration as _invoke_separator_for_integration,
|
||||
with_integration_setting as _with_integration_setting,
|
||||
)
|
||||
from ..integration_state import (
|
||||
dedupe_integration_keys as _dedupe_integration_keys,
|
||||
default_integration_key as _default_integration_key,
|
||||
installed_integration_keys as _installed_integration_keys,
|
||||
integration_settings as _integration_settings,
|
||||
)
|
||||
from ._commands import integration_app
|
||||
from ._helpers import (
|
||||
_MANIFEST_READ_ERRORS,
|
||||
_clear_init_options_for_integration,
|
||||
_cli_error_detail,
|
||||
_cli_phase_label,
|
||||
_get_speckit_version,
|
||||
_read_integration_json,
|
||||
_refresh_init_options_speckit_version,
|
||||
_remove_integration_json,
|
||||
_resolve_integration_options,
|
||||
_resolve_script_type,
|
||||
_set_default_integration_or_exit,
|
||||
_update_init_options_for_integration,
|
||||
_write_integration_json,
|
||||
)
|
||||
|
||||
|
||||
@integration_app.command("install")
|
||||
def integration_install(
|
||||
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
"""Install an integration into an existing project."""
|
||||
from . import INTEGRATION_REGISTRY, get_integration
|
||||
from .manifest import IntegrationManifest
|
||||
from .. import _require_specify_project, _install_shared_infra_or_exit
|
||||
|
||||
project_root = _require_specify_project()
|
||||
integration = get_integration(key)
|
||||
if integration is None:
|
||||
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
||||
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
|
||||
console.print(f"Available integrations: {available}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
current = _read_integration_json(project_root)
|
||||
default_key = _default_integration_key(current)
|
||||
installed_keys = _installed_integration_keys(current)
|
||||
|
||||
if key in installed_keys:
|
||||
console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
|
||||
if default_key == key:
|
||||
console.print("It is already the default integration.")
|
||||
else:
|
||||
console.print(
|
||||
f"To make it the default integration, run "
|
||||
f"[cyan]specify integration use {key}[/cyan]."
|
||||
)
|
||||
console.print(
|
||||
f"To refresh its managed files or options, run "
|
||||
f"[cyan]specify integration upgrade {key}[/cyan]."
|
||||
)
|
||||
console.print("No files were changed.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
if installed_keys and not force:
|
||||
unsafe_keys = []
|
||||
for installed_key in installed_keys:
|
||||
installed_integration = get_integration(installed_key)
|
||||
if not installed_integration or not getattr(installed_integration, "multi_install_safe", False):
|
||||
unsafe_keys.append(installed_key)
|
||||
if unsafe_keys or not getattr(integration, "multi_install_safe", False):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}."
|
||||
)
|
||||
if default_key:
|
||||
console.print(f"Default integration: [cyan]{default_key}[/cyan].")
|
||||
console.print(
|
||||
"Installing multiple integrations is only automatic when all involved "
|
||||
"integrations are declared multi-install safe."
|
||||
)
|
||||
console.print(
|
||||
f"To replace the default integration, run "
|
||||
f"[cyan]specify integration switch {key}[/cyan]."
|
||||
)
|
||||
console.print(
|
||||
f"To install '{key}' alongside the existing integrations anyway, "
|
||||
"retry the same install command with [cyan]--force[/cyan]."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
selected_script = _resolve_script_type(project_root, script)
|
||||
|
||||
# Build parsed options from --integration-options so the integration
|
||||
# can determine its effective invoke separator before shared infra
|
||||
# is installed.
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
integration, current, key, integration_options
|
||||
)
|
||||
|
||||
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||
# _install_shared_infra merges missing files without overwriting).
|
||||
infra_integration = integration
|
||||
infra_key = key
|
||||
infra_parsed = parsed_options
|
||||
if default_key:
|
||||
default_integration = get_integration(default_key)
|
||||
if default_integration is not None:
|
||||
infra_integration = default_integration
|
||||
infra_key = default_key
|
||||
_, infra_parsed = _resolve_integration_options(
|
||||
default_integration, current, default_key, None
|
||||
)
|
||||
_install_shared_infra_or_exit(
|
||||
project_root,
|
||||
selected_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
infra_integration, current, infra_key, infra_parsed
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
from .. import ensure_executable_scripts
|
||||
ensure_executable_scripts(project_root)
|
||||
|
||||
manifest = IntegrationManifest(
|
||||
integration.key, project_root, version=_get_speckit_version()
|
||||
)
|
||||
|
||||
try:
|
||||
integration.setup(
|
||||
project_root, manifest,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
)
|
||||
manifest.save()
|
||||
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
|
||||
new_default = default_key or integration.key
|
||||
settings = _with_integration_setting(
|
||||
current,
|
||||
integration.key,
|
||||
integration,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
_write_integration_json(project_root, new_default, new_installed, settings)
|
||||
if new_default == integration.key:
|
||||
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||
else:
|
||||
_refresh_init_options_speckit_version(project_root)
|
||||
|
||||
except Exception as exc:
|
||||
# Attempt rollback of any files written by setup
|
||||
try:
|
||||
integration.teardown(project_root, manifest, force=True)
|
||||
except Exception as rollback_err:
|
||||
# Suppress so the original setup error remains the primary failure
|
||||
from .. import _print_cli_warning
|
||||
_print_cli_warning(
|
||||
"rollback",
|
||||
"integration",
|
||||
key,
|
||||
rollback_err,
|
||||
continuing="The original install failure is still the primary error.",
|
||||
)
|
||||
if installed_keys:
|
||||
_write_integration_json(
|
||||
project_root, default_key, installed_keys, _integration_settings(current)
|
||||
)
|
||||
else:
|
||||
_remove_integration_json(project_root)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: "
|
||||
f"{_cli_error_detail(exc)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
name = (integration.config or {}).get("name", key)
|
||||
console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully")
|
||||
if default_key:
|
||||
console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]")
|
||||
|
||||
|
||||
@integration_app.command("uninstall")
|
||||
def integration_uninstall(
|
||||
key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"),
|
||||
force: bool = typer.Option(False, "--force", help="Remove files even if modified"),
|
||||
):
|
||||
"""Uninstall an integration, safely preserving modified files."""
|
||||
from . import get_integration
|
||||
from .manifest import IntegrationManifest
|
||||
from .. import _require_specify_project
|
||||
|
||||
project_root = _require_specify_project()
|
||||
current = _read_integration_json(project_root)
|
||||
default_key = _default_integration_key(current)
|
||||
installed_keys = _installed_integration_keys(current)
|
||||
|
||||
if key is None:
|
||||
if not default_key:
|
||||
console.print("[yellow]No integration is currently installed.[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
key = default_key
|
||||
|
||||
if key not in installed_keys:
|
||||
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
integration = get_integration(key)
|
||||
|
||||
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||
if not manifest_path.exists():
|
||||
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]")
|
||||
remaining = [installed for installed in installed_keys if installed != key]
|
||||
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
|
||||
if remaining:
|
||||
if default_key == key and new_default and (new_integration := get_integration(new_default)):
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
new_integration, current, new_default, None
|
||||
)
|
||||
_set_default_integration_or_exit(
|
||||
project_root,
|
||||
current,
|
||||
new_default,
|
||||
new_integration,
|
||||
remaining,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
else:
|
||||
_write_integration_json(
|
||||
project_root, new_default, remaining, _integration_settings(current)
|
||||
)
|
||||
else:
|
||||
_remove_integration_json(project_root)
|
||||
if default_key == key:
|
||||
_clear_init_options_for_integration(project_root, key)
|
||||
raise typer.Exit(0)
|
||||
|
||||
try:
|
||||
manifest = IntegrationManifest.load(key, project_root)
|
||||
except _MANIFEST_READ_ERRORS as exc:
|
||||
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.")
|
||||
console.print(f"Manifest: {manifest_path}")
|
||||
console.print(
|
||||
f"To recover, delete the unreadable manifest, run "
|
||||
f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, "
|
||||
f"then run [cyan]specify integration install {key}[/cyan] to regenerate."
|
||||
)
|
||||
console.print(f"[dim]Details:[/dim] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not integration:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Integration '{key}' not found "
|
||||
"in registry. Falling back to manifest-based cleanup."
|
||||
)
|
||||
removed, skipped = manifest.uninstall(project_root, force=force)
|
||||
else:
|
||||
removed, skipped = integration.teardown(project_root, manifest, force=force)
|
||||
|
||||
remaining = [installed for installed in installed_keys if installed != key]
|
||||
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
|
||||
if remaining:
|
||||
if default_key == key and new_default and (new_integration := get_integration(new_default)):
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
new_integration, current, new_default, None
|
||||
)
|
||||
_set_default_integration_or_exit(
|
||||
project_root,
|
||||
current,
|
||||
new_default,
|
||||
new_integration,
|
||||
remaining,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
else:
|
||||
_write_integration_json(
|
||||
project_root, new_default, remaining, _integration_settings(current)
|
||||
)
|
||||
else:
|
||||
_remove_integration_json(project_root)
|
||||
|
||||
if default_key == key:
|
||||
_clear_init_options_for_integration(project_root, key)
|
||||
|
||||
name = (integration.config or {}).get("name", key) if integration else key
|
||||
console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled")
|
||||
if removed:
|
||||
console.print(f" Removed {len(removed)} file(s)")
|
||||
if skipped:
|
||||
console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:")
|
||||
for path in skipped:
|
||||
rel = _display_project_path(project_root, path)
|
||||
console.print(f" {rel}")
|
||||
490
src/specify_cli/integrations/_migrate_commands.py
Normal file
490
src/specify_cli/integrations/_migrate_commands.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""specify integration switch / upgrade command handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import typer
|
||||
|
||||
from .._console import console
|
||||
from ..integration_runtime import (
|
||||
invoke_separator_for_integration as _invoke_separator_for_integration,
|
||||
with_integration_setting as _with_integration_setting,
|
||||
)
|
||||
from ..integration_state import (
|
||||
dedupe_integration_keys as _dedupe_integration_keys,
|
||||
default_integration_key as _default_integration_key,
|
||||
installed_integration_keys as _installed_integration_keys,
|
||||
integration_settings as _integration_settings,
|
||||
)
|
||||
from ._commands import integration_app
|
||||
from ._helpers import (
|
||||
_MANIFEST_READ_ERRORS,
|
||||
_SharedTemplateRefreshError,
|
||||
_clear_init_options_for_integration,
|
||||
_cli_error_detail,
|
||||
_cli_phase_label,
|
||||
_get_speckit_version,
|
||||
_read_integration_json,
|
||||
_refresh_init_options_speckit_version,
|
||||
_remove_integration_json,
|
||||
_resolve_integration_options,
|
||||
_resolve_integration_script_type,
|
||||
_resolve_script_type,
|
||||
_set_default_integration,
|
||||
_set_default_integration_or_exit,
|
||||
_update_init_options_for_integration,
|
||||
_write_integration_json,
|
||||
)
|
||||
|
||||
|
||||
@integration_app.command("switch")
|
||||
def integration_switch(
|
||||
target: str = typer.Argument(help="Integration key to switch to"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
||||
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
||||
):
|
||||
"""Switch from the current integration to a different one."""
|
||||
from . import INTEGRATION_REGISTRY, get_integration
|
||||
from .manifest import IntegrationManifest
|
||||
from .. import _print_cli_warning, _require_specify_project, _install_shared_infra_or_exit
|
||||
|
||||
project_root = _require_specify_project()
|
||||
target_integration = get_integration(target)
|
||||
if target_integration is None:
|
||||
console.print(f"[red]Error:[/red] Unknown integration '{target}'")
|
||||
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
|
||||
console.print(f"Available integrations: {available}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
current = _read_integration_json(project_root)
|
||||
installed_keys = _installed_integration_keys(current)
|
||||
installed_key = _default_integration_key(current)
|
||||
|
||||
if installed_key == target:
|
||||
if integration_options is not None:
|
||||
console.print(
|
||||
"[red]Error:[/red] --integration-options cannot be used when switching "
|
||||
"to an already installed integration."
|
||||
)
|
||||
console.print(
|
||||
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
|
||||
"to update managed files/options."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if force:
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
target_integration, current, target, None
|
||||
)
|
||||
_set_default_integration_or_exit(
|
||||
project_root,
|
||||
current,
|
||||
target,
|
||||
target_integration,
|
||||
installed_keys,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
refresh_templates_force=True,
|
||||
)
|
||||
console.print(
|
||||
f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; "
|
||||
"shared infrastructure refreshed."
|
||||
)
|
||||
raise typer.Exit(0)
|
||||
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
if target in installed_keys:
|
||||
if integration_options is not None:
|
||||
console.print(
|
||||
"[red]Error:[/red] --integration-options cannot be used when switching "
|
||||
"to an already installed integration."
|
||||
)
|
||||
console.print(
|
||||
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
|
||||
f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
target_integration, current, target, None
|
||||
)
|
||||
_set_default_integration_or_exit(
|
||||
project_root,
|
||||
current,
|
||||
target,
|
||||
target_integration,
|
||||
installed_keys,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
refresh_templates_force=force,
|
||||
)
|
||||
console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
|
||||
raise typer.Exit(0)
|
||||
|
||||
selected_script = _resolve_script_type(project_root, script)
|
||||
|
||||
# Phase 1: Uninstall current integration (if any)
|
||||
if installed_key:
|
||||
current_integration = get_integration(installed_key)
|
||||
manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json"
|
||||
|
||||
if current_integration and manifest_path.exists():
|
||||
console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]")
|
||||
try:
|
||||
old_manifest = IntegrationManifest.load(installed_key, project_root)
|
||||
except _MANIFEST_READ_ERRORS as exc:
|
||||
console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}")
|
||||
console.print(f"[dim]{exc}[/dim]")
|
||||
console.print(
|
||||
f"To recover, delete the unreadable manifest at {manifest_path}, "
|
||||
f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
removed, skipped = current_integration.teardown(
|
||||
project_root, old_manifest, force=force,
|
||||
)
|
||||
if removed:
|
||||
console.print(f" Removed {len(removed)} file(s)")
|
||||
if skipped:
|
||||
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
|
||||
elif not current_integration and manifest_path.exists():
|
||||
# Integration removed from registry but manifest exists — use manifest-only uninstall
|
||||
console.print(f"Uninstalling unknown integration '{installed_key}' via manifest")
|
||||
try:
|
||||
old_manifest = IntegrationManifest.load(installed_key, project_root)
|
||||
removed, skipped = old_manifest.uninstall(project_root, force=force)
|
||||
if removed:
|
||||
console.print(f" Removed {len(removed)} file(s)")
|
||||
if skipped:
|
||||
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
|
||||
except _MANIFEST_READ_ERRORS as exc:
|
||||
console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.")
|
||||
console.print(
|
||||
f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, "
|
||||
f"then retry [cyan]specify integration switch {target}[/cyan]."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Unregister extension commands for the old agent so they don't
|
||||
# remain as orphans in the old agent's directory.
|
||||
try:
|
||||
from ..extensions import ExtensionManager
|
||||
|
||||
ext_mgr = ExtensionManager(project_root)
|
||||
ext_mgr.unregister_agent_artifacts(installed_key)
|
||||
except Exception as ext_err:
|
||||
_print_cli_warning(
|
||||
"clean up extension artifacts for",
|
||||
"integration",
|
||||
installed_key,
|
||||
ext_err,
|
||||
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
|
||||
)
|
||||
|
||||
# Clear metadata so a failed Phase 2 doesn't leave stale references
|
||||
installed_keys = [installed for installed in installed_keys if installed != installed_key]
|
||||
_clear_init_options_for_integration(project_root, installed_key)
|
||||
if installed_keys:
|
||||
fallback_key = installed_keys[0]
|
||||
fallback_integration = get_integration(fallback_key)
|
||||
if fallback_integration is not None:
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
fallback_integration, current, fallback_key, None
|
||||
)
|
||||
_set_default_integration_or_exit(
|
||||
project_root,
|
||||
current,
|
||||
fallback_key,
|
||||
fallback_integration,
|
||||
installed_keys,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
else:
|
||||
_write_integration_json(
|
||||
project_root, fallback_key, installed_keys, _integration_settings(current)
|
||||
)
|
||||
else:
|
||||
_remove_integration_json(project_root)
|
||||
current = _read_integration_json(project_root)
|
||||
|
||||
# Build parsed options from --integration-options so the integration
|
||||
# can determine its effective invoke separator before shared infra
|
||||
# is installed.
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
target_integration, current, target, integration_options
|
||||
)
|
||||
|
||||
# Refresh shared infrastructure to the current CLI version. Switching
|
||||
# integrations is exactly when stale vendored shared scripts (e.g.
|
||||
# update-agent-context.sh that pre-dates the target integration's
|
||||
# supported-agent list) would silently break the new integration.
|
||||
#
|
||||
# Use refresh_managed=True so only files that match their previously
|
||||
# recorded hash are overwritten — user customizations are detected via
|
||||
# hash divergence and preserved with a warning. Pass
|
||||
# --refresh-shared-infra to overwrite customizations as well. See #2293.
|
||||
_install_shared_infra_or_exit(
|
||||
project_root,
|
||||
selected_script,
|
||||
force=refresh_shared_infra,
|
||||
refresh_managed=True,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
target_integration, current, target, parsed_options
|
||||
),
|
||||
refresh_hint=(
|
||||
"To overwrite customizations, re-run with "
|
||||
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
from .. import ensure_executable_scripts
|
||||
ensure_executable_scripts(project_root)
|
||||
|
||||
# Phase 2: Install target integration
|
||||
console.print(f"Installing integration: [cyan]{target}[/cyan]")
|
||||
manifest = IntegrationManifest(
|
||||
target_integration.key, project_root, version=_get_speckit_version()
|
||||
)
|
||||
|
||||
try:
|
||||
target_integration.setup(
|
||||
project_root, manifest,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
)
|
||||
manifest.save()
|
||||
_set_default_integration(
|
||||
project_root,
|
||||
current,
|
||||
target_integration.key,
|
||||
target_integration,
|
||||
_dedupe_integration_keys([*installed_keys, target_integration.key]),
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
|
||||
# Re-register extension commands for the new agent so that
|
||||
# previously-installed extensions are available in the new integration.
|
||||
try:
|
||||
from ..extensions import ExtensionManager
|
||||
|
||||
ext_mgr = ExtensionManager(project_root)
|
||||
ext_mgr.register_enabled_extensions_for_agent(target)
|
||||
except Exception as ext_err:
|
||||
_print_cli_warning(
|
||||
"register extension artifacts for",
|
||||
"integration",
|
||||
target,
|
||||
ext_err,
|
||||
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
# Attempt rollback of any files written by setup
|
||||
try:
|
||||
target_integration.teardown(project_root, manifest, force=True)
|
||||
except Exception as rollback_err:
|
||||
# Suppress so the original setup error remains the primary failure
|
||||
_print_cli_warning(
|
||||
"rollback",
|
||||
"integration",
|
||||
target,
|
||||
rollback_err,
|
||||
continuing="The original switch failure is still the primary error.",
|
||||
)
|
||||
if installed_keys:
|
||||
fallback_key = installed_keys[0]
|
||||
fallback_integration = get_integration(fallback_key)
|
||||
if fallback_integration is not None:
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
fallback_integration, current, fallback_key, None
|
||||
)
|
||||
try:
|
||||
_set_default_integration(
|
||||
project_root,
|
||||
current,
|
||||
fallback_key,
|
||||
fallback_integration,
|
||||
installed_keys,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
except _SharedTemplateRefreshError as restore_err:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Failed to restore default "
|
||||
f"integration '{fallback_key}': {restore_err}"
|
||||
)
|
||||
else:
|
||||
_write_integration_json(
|
||||
project_root, fallback_key, installed_keys, _integration_settings(current)
|
||||
)
|
||||
else:
|
||||
_remove_integration_json(project_root)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} "
|
||||
f"during switch: {_cli_error_detail(exc)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
name = (target_integration.config or {}).get("name", target)
|
||||
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
|
||||
|
||||
|
||||
@integration_app.command("upgrade")
|
||||
def integration_upgrade(
|
||||
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
|
||||
):
|
||||
"""Upgrade an integration by reinstalling with diff-aware file handling.
|
||||
|
||||
Compares manifest hashes to detect locally modified files and
|
||||
blocks the upgrade unless --force is used.
|
||||
"""
|
||||
from . import get_integration
|
||||
from .manifest import IntegrationManifest
|
||||
from .. import _require_specify_project, _install_shared_infra_or_exit, _install_shared_infra
|
||||
|
||||
project_root = _require_specify_project()
|
||||
current = _read_integration_json(project_root)
|
||||
installed_key = _default_integration_key(current)
|
||||
installed_keys = _installed_integration_keys(current)
|
||||
|
||||
if key is None:
|
||||
if not installed_key:
|
||||
console.print("[yellow]No integration is currently installed.[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
key = installed_key
|
||||
|
||||
if key not in installed_keys:
|
||||
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
integration = get_integration(key)
|
||||
if integration is None:
|
||||
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||
if not manifest_path.exists():
|
||||
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]")
|
||||
console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
try:
|
||||
old_manifest = IntegrationManifest.load(key, project_root)
|
||||
except _MANIFEST_READ_ERRORS as exc:
|
||||
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Detect modified files via manifest hashes
|
||||
modified = old_manifest.check_modified()
|
||||
if modified and not force:
|
||||
console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:")
|
||||
for rel in modified:
|
||||
console.print(f" {rel}")
|
||||
console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
selected_script = _resolve_integration_script_type(project_root, current, key, script)
|
||||
|
||||
# Build parsed options from --integration-options so the integration
|
||||
# can determine its effective invoke separator before shared infra
|
||||
# is installed.
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
integration, current, key, integration_options
|
||||
)
|
||||
|
||||
# Ensure shared infrastructure is up to date; --force overwrites existing files.
|
||||
infra_integration = integration
|
||||
infra_key = key
|
||||
infra_parsed = parsed_options
|
||||
if installed_key and installed_key != key:
|
||||
default_integration = get_integration(installed_key)
|
||||
if default_integration is not None:
|
||||
infra_integration = default_integration
|
||||
infra_key = installed_key
|
||||
_, infra_parsed = _resolve_integration_options(
|
||||
default_integration, current, installed_key, None
|
||||
)
|
||||
_install_shared_infra_or_exit(
|
||||
project_root,
|
||||
selected_script,
|
||||
force=force,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
infra_integration, current, infra_key, infra_parsed
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
from .. import ensure_executable_scripts
|
||||
ensure_executable_scripts(project_root)
|
||||
|
||||
# Phase 1: Install new files (overwrites existing; old-only files remain)
|
||||
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
||||
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
|
||||
|
||||
try:
|
||||
integration.setup(
|
||||
project_root,
|
||||
new_manifest,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
)
|
||||
settings = _with_integration_setting(
|
||||
current,
|
||||
key,
|
||||
integration,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
if installed_key == key:
|
||||
try:
|
||||
_install_shared_infra(
|
||||
project_root,
|
||||
selected_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
integration, {"integration_settings": settings}, key, parsed_options
|
||||
),
|
||||
force=force,
|
||||
refresh_managed=True,
|
||||
)
|
||||
except (ValueError, OSError) as exc:
|
||||
raise _SharedTemplateRefreshError(
|
||||
f"Failed to refresh shared infrastructure for '{key}': {exc}"
|
||||
) from exc
|
||||
new_manifest.save()
|
||||
_write_integration_json(project_root, installed_key, installed_keys, settings)
|
||||
if installed_key == key:
|
||||
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||
else:
|
||||
_refresh_init_options_speckit_version(project_root)
|
||||
except Exception as exc:
|
||||
# Don't teardown — setup overwrites in-place, so teardown would
|
||||
# delete files that were working before the upgrade. Just report.
|
||||
console.print(f"[red]Error:[/red] Failed to {_cli_phase_label('upgrade', 'integration', key)}.")
|
||||
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
|
||||
console.print("[yellow]The previous integration files may still be in place.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Phase 2: Remove stale files from old manifest that are not in the new one
|
||||
old_files = old_manifest.files
|
||||
new_files = new_manifest.files
|
||||
stale_keys = set(old_files) - set(new_files)
|
||||
if stale_keys:
|
||||
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
|
||||
stale_manifest._files = {k: old_files[k] for k in stale_keys}
|
||||
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
|
||||
if stale_removed:
|
||||
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
|
||||
|
||||
name = (integration.config or {}).get("name", key)
|
||||
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
|
||||
464
src/specify_cli/integrations/_query_commands.py
Normal file
464
src/specify_cli/integrations/_query_commands.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
|
||||
from .._console import console
|
||||
from ..integration_state import (
|
||||
default_integration_key as _default_integration_key,
|
||||
installed_integration_keys as _installed_integration_keys,
|
||||
)
|
||||
from ._commands import integration_app, integration_catalog_app
|
||||
from ._helpers import (
|
||||
_read_integration_json,
|
||||
_resolve_integration_options,
|
||||
_set_default_integration_or_exit,
|
||||
)
|
||||
|
||||
|
||||
@integration_app.command("list")
|
||||
def integration_list(
|
||||
catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
|
||||
):
|
||||
"""List available integrations and installed status."""
|
||||
from . import INTEGRATION_REGISTRY
|
||||
from .. import _require_specify_project
|
||||
|
||||
project_root = _require_specify_project()
|
||||
current = _read_integration_json(project_root)
|
||||
default_key = _default_integration_key(current)
|
||||
installed_keys = set(_installed_integration_keys(current))
|
||||
|
||||
if catalog:
|
||||
from .catalog import IntegrationCatalog, IntegrationCatalogError
|
||||
|
||||
ic = IntegrationCatalog(project_root)
|
||||
try:
|
||||
entries = ic.search()
|
||||
except IntegrationCatalogError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not entries:
|
||||
console.print("[yellow]No integrations found in catalog.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Integration Catalog")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Version")
|
||||
table.add_column("Source")
|
||||
table.add_column("Status")
|
||||
table.add_column("Multi-install Safe")
|
||||
|
||||
for entry in sorted(entries, key=lambda e: e["id"]):
|
||||
eid = entry["id"]
|
||||
cat_name = entry.get("_catalog_name", "")
|
||||
install_allowed = entry.get("_install_allowed", True)
|
||||
if eid == default_key:
|
||||
status = "[green]installed (default)[/green]"
|
||||
elif eid in installed_keys:
|
||||
status = "[green]installed[/green]"
|
||||
elif eid in INTEGRATION_REGISTRY:
|
||||
status = "built-in"
|
||||
elif install_allowed is False:
|
||||
status = "discovery-only"
|
||||
else:
|
||||
status = ""
|
||||
safe = ""
|
||||
if eid in INTEGRATION_REGISTRY:
|
||||
reg_integ = INTEGRATION_REGISTRY[eid]
|
||||
safe = "yes" if getattr(reg_integ, "multi_install_safe", False) else "no"
|
||||
table.add_row(
|
||||
eid,
|
||||
entry.get("name", eid),
|
||||
entry.get("version", ""),
|
||||
cat_name,
|
||||
status,
|
||||
safe,
|
||||
)
|
||||
console.print(table)
|
||||
return
|
||||
|
||||
if not INTEGRATION_REGISTRY:
|
||||
console.print("[yellow]No integrations available.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title="Coding Agent Integrations")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Status")
|
||||
table.add_column("CLI Required")
|
||||
table.add_column("Multi-install Safe")
|
||||
|
||||
for key in sorted(INTEGRATION_REGISTRY.keys()):
|
||||
integration = INTEGRATION_REGISTRY[key]
|
||||
cfg = integration.config or {}
|
||||
name = cfg.get("name", key)
|
||||
requires_cli = cfg.get("requires_cli", False)
|
||||
if key == default_key:
|
||||
status = "[green]installed (default)[/green]"
|
||||
elif key in installed_keys:
|
||||
status = "[green]installed[/green]"
|
||||
else:
|
||||
status = ""
|
||||
cli_req = "yes" if requires_cli else "no (IDE)"
|
||||
safe = "yes" if getattr(integration, "multi_install_safe", False) else "no"
|
||||
table.add_row(key, name, status, cli_req, safe)
|
||||
|
||||
console.print(table)
|
||||
|
||||
if installed_keys:
|
||||
console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]")
|
||||
console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]")
|
||||
else:
|
||||
console.print("\n[yellow]No integration currently installed.[/yellow]")
|
||||
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
|
||||
|
||||
|
||||
@integration_app.command("use")
|
||||
def integration_use(
|
||||
key: str = typer.Argument(help="Installed integration key to make the default"),
|
||||
force: bool = typer.Option(False, "--force", help="Overwrite existing shared infrastructure files, including customizations, while changing the default"),
|
||||
):
|
||||
"""Set the default integration without uninstalling other integrations."""
|
||||
from . import get_integration
|
||||
from .. import _require_specify_project
|
||||
|
||||
project_root = _require_specify_project()
|
||||
current = _read_integration_json(project_root)
|
||||
installed_keys = _installed_integration_keys(current)
|
||||
if key not in installed_keys:
|
||||
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
||||
if installed_keys:
|
||||
console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}")
|
||||
else:
|
||||
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
integration = get_integration(key)
|
||||
if integration is None:
|
||||
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
raw_options, parsed_options = _resolve_integration_options(integration, current, key, None)
|
||||
_set_default_integration_or_exit(
|
||||
project_root,
|
||||
current,
|
||||
key,
|
||||
integration,
|
||||
installed_keys,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
refresh_templates_force=force,
|
||||
refresh_hint=(
|
||||
"To overwrite customizations, re-run with "
|
||||
f"[cyan]specify integration use {key} --force[/cyan]."
|
||||
),
|
||||
)
|
||||
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
|
||||
|
||||
|
||||
# ===== Integration catalog discovery commands =====
|
||||
#
|
||||
# These commands mirror the workflow catalog CLI shape:
|
||||
# - `search` / `info` for discovery over the active catalog stack
|
||||
# - `catalog list/add/remove` for managing catalog sources
|
||||
#
|
||||
# They deliberately do NOT add `integration add/remove/enable/disable/
|
||||
# set-priority`: integrations are single-active (install / uninstall / switch),
|
||||
# not additive like extensions and presets.
|
||||
@integration_app.command("search")
|
||||
def integration_search(
|
||||
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
|
||||
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
|
||||
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
|
||||
):
|
||||
"""Search for integrations in the active catalog stack."""
|
||||
from . import INTEGRATION_REGISTRY
|
||||
from .catalog import (
|
||||
IntegrationCatalog,
|
||||
IntegrationCatalogError,
|
||||
IntegrationValidationError,
|
||||
)
|
||||
from .. import _require_specify_project
|
||||
|
||||
project_root = _require_specify_project()
|
||||
integration_config = _read_integration_json(project_root)
|
||||
installed_key = _default_integration_key(integration_config)
|
||||
catalog = IntegrationCatalog(project_root)
|
||||
|
||||
try:
|
||||
results = catalog.search(query=query, tag=tag, author=author)
|
||||
except IntegrationValidationError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
console.print(
|
||||
"\nTip: Check the configuration file path shown above for invalid catalog configuration "
|
||||
"(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
except IntegrationCatalogError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
|
||||
console.print(
|
||||
"\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid "
|
||||
"catalog URL, or unset it to use the configured catalog files "
|
||||
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
|
||||
)
|
||||
else:
|
||||
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not results:
|
||||
console.print("\n[yellow]No integrations found matching criteria[/yellow]")
|
||||
if query or tag or author:
|
||||
console.print("\nTry:")
|
||||
console.print(" • Broader search terms")
|
||||
console.print(" • Remove filters")
|
||||
console.print(" • specify integration search (show all)")
|
||||
return
|
||||
|
||||
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
|
||||
for integ in sorted(results, key=lambda e: e.get("id", "")):
|
||||
iid = integ.get("id", "?")
|
||||
name = integ.get("name", iid)
|
||||
version = integ.get("version", "?")
|
||||
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
|
||||
desc = integ.get("description", "")
|
||||
if desc:
|
||||
console.print(f" {desc}")
|
||||
|
||||
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
|
||||
tags = integ.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
|
||||
|
||||
cat_name = integ.get("_catalog_name", "")
|
||||
install_allowed = integ.get("_install_allowed", True)
|
||||
if cat_name:
|
||||
if install_allowed:
|
||||
console.print(f" [dim]Catalog:[/dim] {cat_name}")
|
||||
else:
|
||||
console.print(
|
||||
f" [dim]Catalog:[/dim] {cat_name} "
|
||||
"[yellow](discovery only — not installable)[/yellow]"
|
||||
)
|
||||
|
||||
if iid == installed_key:
|
||||
console.print("\n [green]✓ Installed[/green] (currently active)")
|
||||
elif iid in INTEGRATION_REGISTRY:
|
||||
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
|
||||
elif install_allowed:
|
||||
console.print(
|
||||
"\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs "
|
||||
"can be installed with 'specify integration install'."
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'."
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
@integration_app.command("info")
|
||||
def integration_info(
|
||||
integration_id: str = typer.Argument(..., help="Integration ID"),
|
||||
):
|
||||
"""Show catalog details for a single integration."""
|
||||
from . import INTEGRATION_REGISTRY
|
||||
from .catalog import (
|
||||
IntegrationCatalog,
|
||||
IntegrationCatalogError,
|
||||
IntegrationValidationError,
|
||||
)
|
||||
from .. import _require_specify_project
|
||||
|
||||
project_root = _require_specify_project()
|
||||
catalog = IntegrationCatalog(project_root)
|
||||
installed_key = _default_integration_key(_read_integration_json(project_root))
|
||||
|
||||
try:
|
||||
info = catalog.get_integration_info(integration_id)
|
||||
except IntegrationCatalogError as exc:
|
||||
info = None
|
||||
# Keep the live exception so the fallback branch below can give
|
||||
# different guidance for local-config vs. network failures.
|
||||
catalog_error: Optional[IntegrationCatalogError] = exc
|
||||
else:
|
||||
catalog_error = None
|
||||
|
||||
if info:
|
||||
name = info.get("name", integration_id)
|
||||
version = info.get("version", "?")
|
||||
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
|
||||
if info.get("description"):
|
||||
console.print(f" {info['description']}")
|
||||
console.print()
|
||||
|
||||
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
|
||||
if info.get("license"):
|
||||
console.print(f" [dim]License:[/dim] {info['license']}")
|
||||
|
||||
tags = info.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
|
||||
|
||||
cat_name = info.get("_catalog_name", "")
|
||||
install_allowed = info.get("_install_allowed", True)
|
||||
if cat_name:
|
||||
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
|
||||
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
|
||||
|
||||
if info.get("repository"):
|
||||
console.print(f" [dim]Repository:[/dim] {info['repository']}")
|
||||
|
||||
if integration_id == installed_key:
|
||||
console.print("\n [green]✓ Installed[/green] (currently active)")
|
||||
elif integration_id in INTEGRATION_REGISTRY:
|
||||
console.print("\n [dim]Built-in integration (not currently active)[/dim]")
|
||||
return
|
||||
|
||||
if integration_id in INTEGRATION_REGISTRY:
|
||||
integration = INTEGRATION_REGISTRY[integration_id]
|
||||
cfg = integration.config or {}
|
||||
name = cfg.get("name", integration_id)
|
||||
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})")
|
||||
console.print(" [dim]Built-in integration (not listed in catalog)[/dim]")
|
||||
if integration_id == installed_key:
|
||||
console.print("\n [green]✓ Installed[/green] (currently active)")
|
||||
if catalog_error:
|
||||
console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}")
|
||||
return
|
||||
|
||||
if catalog_error:
|
||||
console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}")
|
||||
if isinstance(catalog_error, IntegrationValidationError):
|
||||
console.print(
|
||||
"\nCheck the configuration file path shown above "
|
||||
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), "
|
||||
"or use a built-in integration ID directly."
|
||||
)
|
||||
elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
|
||||
console.print(
|
||||
"\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, "
|
||||
"or unset it to use the configured catalog files, or use a built-in integration ID directly."
|
||||
)
|
||||
else:
|
||||
console.print("\nTry again when online, or use a built-in integration ID directly.")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
|
||||
console.print("\nTry: specify integration search")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@integration_catalog_app.command("list")
|
||||
def integration_catalog_list():
|
||||
"""List configured integration catalog sources."""
|
||||
from .catalog import IntegrationCatalog, IntegrationCatalogError
|
||||
from .. import _require_specify_project
|
||||
|
||||
project_root = _require_specify_project()
|
||||
catalog = IntegrationCatalog(project_root)
|
||||
env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip()
|
||||
|
||||
try:
|
||||
if env_override:
|
||||
project_configs = None
|
||||
configs = catalog.get_catalog_configs()
|
||||
else:
|
||||
project_configs = catalog.get_project_catalog_configs()
|
||||
configs = project_configs if project_configs is not None else catalog.get_catalog_configs()
|
||||
except IntegrationCatalogError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n")
|
||||
if env_override:
|
||||
console.print(
|
||||
" SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files."
|
||||
)
|
||||
console.print(
|
||||
" Project/user catalog sources are not active while the env override is set.\n"
|
||||
)
|
||||
console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n")
|
||||
elif project_configs is None:
|
||||
console.print(" No project-level catalog sources configured.\n")
|
||||
console.print("[bold]Active catalog sources (non-removable here):[/bold]\n")
|
||||
else:
|
||||
console.print("[bold]Project catalog sources (removable):[/bold]\n")
|
||||
|
||||
for i, cfg in enumerate(configs):
|
||||
install_status = (
|
||||
"[green]install allowed[/green]"
|
||||
if cfg.get("install_allowed")
|
||||
else "[yellow]discovery only[/yellow]"
|
||||
)
|
||||
raw_name = cfg.get("name")
|
||||
display_name = str(raw_name).strip() if raw_name is not None else ""
|
||||
if not display_name:
|
||||
display_name = f"catalog-{i + 1}"
|
||||
if env_override or project_configs is None:
|
||||
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
|
||||
else:
|
||||
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
|
||||
console.print(f" {cfg.get('url', '')}")
|
||||
if cfg.get("description"):
|
||||
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@integration_catalog_app.command("add")
|
||||
def integration_catalog_add(
|
||||
url: str = typer.Argument(
|
||||
...,
|
||||
help=(
|
||||
"Catalog URL to add (HTTPS required, except http://localhost, "
|
||||
"http://127.0.0.1, or http://[::1] for local testing)"
|
||||
),
|
||||
),
|
||||
name: Optional[str] = typer.Option(None, "--name", help="Catalog name"),
|
||||
):
|
||||
"""Add an integration catalog source to the project config."""
|
||||
from .catalog import IntegrationCatalog, IntegrationCatalogError
|
||||
from .. import _require_specify_project
|
||||
|
||||
project_root = _require_specify_project()
|
||||
catalog = IntegrationCatalog(project_root)
|
||||
|
||||
# Normalize once here so the success message reflects what was actually
|
||||
# stored. ``IntegrationCatalog.add_catalog`` strips again defensively.
|
||||
normalized_url = url.strip()
|
||||
|
||||
try:
|
||||
catalog.add_catalog(normalized_url, name)
|
||||
except IntegrationCatalogError as exc:
|
||||
# Covers both URL validation (base class) and config-file validation
|
||||
# (IntegrationValidationError subclass).
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")
|
||||
|
||||
|
||||
@integration_catalog_app.command("remove")
|
||||
def integration_catalog_remove(
|
||||
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
|
||||
):
|
||||
"""Remove an integration catalog source by 0-based index."""
|
||||
from .catalog import IntegrationCatalog, IntegrationCatalogError
|
||||
from .. import _require_specify_project
|
||||
|
||||
project_root = _require_specify_project()
|
||||
catalog = IntegrationCatalog(project_root)
|
||||
|
||||
try:
|
||||
removed_name = catalog.remove_catalog(index)
|
||||
except IntegrationCatalogError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
|
||||
@@ -283,58 +283,13 @@ class CopilotIntegration(IntegrationBase):
|
||||
return f"speckit.{template_name}.agent.md"
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject shared hook guidance and Copilot ``mode:`` frontmatter.
|
||||
"""Inject shared hook guidance into Copilot skill content.
|
||||
|
||||
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
|
||||
Copilot can associate the skill with its agent mode.
|
||||
Delegates to :class:`_CopilotSkillsHelper` for shared post-processing.
|
||||
The ``mode:`` frontmatter field is intentionally omitted: VS Code
|
||||
Copilot Agent Skills do not support it (see issue #2799).
|
||||
"""
|
||||
updated = _CopilotSkillsHelper().post_process_skill_content(content)
|
||||
lines = updated.splitlines(keepends=True)
|
||||
|
||||
# Extract skill name from frontmatter to derive the mode value
|
||||
dash_count = 0
|
||||
skill_name = ""
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1:
|
||||
if stripped.startswith("mode:"):
|
||||
return updated # already present
|
||||
if stripped.startswith("name:"):
|
||||
# Parse: name: "speckit-plan" → speckit.plan
|
||||
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
# Convert speckit-plan → speckit.plan
|
||||
if val.startswith("speckit-"):
|
||||
skill_name = "speckit." + val[len("speckit-"):]
|
||||
else:
|
||||
skill_name = val
|
||||
|
||||
if not skill_name:
|
||||
return updated
|
||||
|
||||
# Inject mode: before the closing --- of frontmatter
|
||||
out: list[str] = []
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2 and not injected:
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
elif line.endswith("\n"):
|
||||
eol = "\n"
|
||||
else:
|
||||
eol = ""
|
||||
out.append(f"mode: {skill_name}{eol}")
|
||||
injected = True
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
return _CopilotSkillsHelper().post_process_skill_content(content)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
|
||||
@@ -115,6 +115,7 @@ class IntegrationManifest:
|
||||
self.project_root = project_root.resolve()
|
||||
self.version = version
|
||||
self._files: dict[str, str] = {} # rel_path → sha256 hex
|
||||
self._recovered_files: set[str] = set()
|
||||
self._installed_at: str = ""
|
||||
|
||||
# -- Manifest file location -------------------------------------------
|
||||
@@ -131,6 +132,9 @@ class IntegrationManifest:
|
||||
|
||||
Creates parent directories as needed. Returns the absolute path
|
||||
of the written file.
|
||||
If the path was previously marked as recovered via
|
||||
``record_existing(recovered=True)``, the recovered marker is
|
||||
cleared because the bytes are now produced, not merely observed.
|
||||
|
||||
Raises ``ValueError`` if *rel_path* resolves outside the project root.
|
||||
"""
|
||||
@@ -144,17 +148,77 @@ class IntegrationManifest:
|
||||
|
||||
normalized = abs_path.relative_to(self.project_root).as_posix()
|
||||
self._files[normalized] = hashlib.sha256(content).hexdigest()
|
||||
# ``record_file`` writes *produced* content, so any prior
|
||||
# recovered marker for this path is no longer accurate.
|
||||
self._recovered_files.discard(normalized)
|
||||
return abs_path
|
||||
|
||||
def record_existing(self, rel_path: str | Path) -> None:
|
||||
"""Record the hash of an already-existing file at *rel_path*.
|
||||
def record_existing(self, rel_path: str | Path, *, recovered: bool = False) -> None:
|
||||
"""Record the hash of an already-existing regular file at *rel_path*.
|
||||
|
||||
Raises ``ValueError`` if *rel_path* resolves outside the project root.
|
||||
When ``recovered=True``, the path is also marked in the manifest's
|
||||
``recovered_files`` list to signal that the file's on-disk hash was
|
||||
*observed* during install (because the file already existed and was not
|
||||
overwritten), not *produced* by the install. Future ``refresh_managed``
|
||||
runs should consult ``is_recovered`` before treating the recorded hash
|
||||
as a managed baseline.
|
||||
|
||||
Raises:
|
||||
ValueError: if *rel_path* resolves outside the project root, is
|
||||
a symlink, or is not a regular file. A directory or other
|
||||
non-file path cannot be silently recorded — its hash would
|
||||
be meaningless and ``check_modified``/``uninstall`` would
|
||||
treat the entry as permanently broken.
|
||||
OSError: if the underlying filesystem call (``is_symlink``,
|
||||
``is_file``, or the file-read used to compute the hash)
|
||||
fails — for example a ``PermissionError`` on the path.
|
||||
Callers should be prepared to handle ``OSError`` (and its
|
||||
subclasses such as ``PermissionError``) in addition to
|
||||
``ValueError``.
|
||||
"""
|
||||
rel = Path(rel_path)
|
||||
# Cheap lexical pre-check first so absolute / parent-traversal paths
|
||||
# don't trigger a filesystem stat outside the project root before
|
||||
# ``_validate_rel_path`` raises. ``_validate_rel_path`` produces the
|
||||
# canonical error messages used elsewhere.
|
||||
if rel.is_absolute() or ".." in rel.parts:
|
||||
_validate_rel_path(rel, self.project_root)
|
||||
# _validate_rel_path raised for any actually-escaping path. If we reach
|
||||
# here the path normalizes inside root (e.g. ``dir/../file.txt``).
|
||||
# Reject anyway: manifest keys must be canonical so ``check_modified``
|
||||
# and ``uninstall`` cannot key the same file under two paths.
|
||||
raise ValueError(
|
||||
f"Manifest paths must be canonical; '..' segments are not "
|
||||
f"allowed (got {rel})"
|
||||
)
|
||||
# Walk each path component before resolution so a symlinked ancestor
|
||||
# (e.g. ``linked_dir/file.txt`` where ``linked_dir`` is a symlink)
|
||||
# cannot be silently followed by ``_validate_rel_path().resolve()``
|
||||
# down to a target outside the project root. ``_ensure_safe_manifest_directory``
|
||||
# uses the same pattern.
|
||||
_walk = self.project_root
|
||||
for part in rel.parts:
|
||||
_walk = _walk / part
|
||||
if _walk.is_symlink():
|
||||
raise ValueError(
|
||||
f"Refusing to record symlinked manifest path: {rel} "
|
||||
f"(symlinked at {_walk.relative_to(self.project_root).as_posix()})"
|
||||
)
|
||||
abs_path = _validate_rel_path(rel, self.project_root)
|
||||
if not abs_path.is_file():
|
||||
raise ValueError(
|
||||
f"Manifest path is not a regular file: {rel}"
|
||||
)
|
||||
normalized = abs_path.relative_to(self.project_root).as_posix()
|
||||
self._files[normalized] = _sha256(abs_path)
|
||||
if recovered:
|
||||
self._recovered_files.add(normalized)
|
||||
else:
|
||||
# ``recovered=False`` means the caller is asserting this path is
|
||||
# managed-baseline now, not merely observed; drop any stale
|
||||
# recovered marker so future is_recovered() queries reflect the
|
||||
# transition. ``discard`` is a no-op when the key is absent.
|
||||
self._recovered_files.discard(normalized)
|
||||
|
||||
# -- Querying ---------------------------------------------------------
|
||||
|
||||
@@ -163,6 +227,37 @@ class IntegrationManifest:
|
||||
"""Return a copy of the ``{rel_path: sha256}`` mapping."""
|
||||
return dict(self._files)
|
||||
|
||||
@property
|
||||
def recovered_files(self) -> set[str]:
|
||||
"""Return a copy of the set of paths recorded with ``recovered=True``.
|
||||
|
||||
These entries had their hashes observed (not produced) during install
|
||||
because the file already existed on disk and the install skipped it.
|
||||
Their on-disk bytes may be user customizations — callers that would
|
||||
overwrite based on hash equality (e.g. ``refresh_managed``) MUST check
|
||||
``is_recovered`` first.
|
||||
"""
|
||||
return set(self._recovered_files)
|
||||
|
||||
def is_recovered(self, rel_path: str | Path) -> bool:
|
||||
"""Return True if *rel_path* was recorded via ``record_existing(recovered=True)``.
|
||||
|
||||
Input is normalized through the same pipeline as ``record_existing``:
|
||||
absolute paths, paths escaping the project root, AND paths containing
|
||||
``'..'`` segments are rejected (returned as ``False``). This mirrors
|
||||
``record_existing``'s canonicalization guard — such paths can never
|
||||
appear as stored keys, so the answer is always ``False``.
|
||||
"""
|
||||
rel = Path(rel_path)
|
||||
if rel.is_absolute() or ".." in rel.parts:
|
||||
return False
|
||||
try:
|
||||
abs_path = _validate_rel_path(rel, self.project_root)
|
||||
normalized = abs_path.relative_to(self.project_root).as_posix()
|
||||
except ValueError:
|
||||
return False
|
||||
return normalized in self._recovered_files
|
||||
|
||||
def check_modified(self) -> list[str]:
|
||||
"""Return relative paths of tracked files whose content changed on disk."""
|
||||
modified: list[str] = []
|
||||
@@ -269,6 +364,11 @@ class IntegrationManifest:
|
||||
"version": self.version,
|
||||
"installed_at": self._installed_at,
|
||||
"files": self._files,
|
||||
**(
|
||||
{"recovered_files": sorted(self._recovered_files)}
|
||||
if self._recovered_files
|
||||
else {}
|
||||
),
|
||||
}
|
||||
path = self.manifest_path
|
||||
content = json.dumps(data, indent=2) + "\n"
|
||||
@@ -320,6 +420,20 @@ class IntegrationManifest:
|
||||
inst._installed_at = data.get("installed_at", "")
|
||||
inst._files = files
|
||||
|
||||
recovered = data.get("recovered_files", [])
|
||||
if not isinstance(recovered, list) or not all(
|
||||
isinstance(p, str) for p in recovered
|
||||
):
|
||||
raise ValueError(
|
||||
f"Integration manifest 'recovered_files' at {path} must be a "
|
||||
"list of string paths"
|
||||
)
|
||||
inst._recovered_files = set(recovered)
|
||||
# Drop any recovered_files entries that don't correspond to tracked
|
||||
# files — defensive against externally-edited or partially-corrupted
|
||||
# manifests. Inconsistent state self-corrects on next save().
|
||||
inst._recovered_files &= set(inst._files.keys())
|
||||
|
||||
stored_key = data.get("integration", "")
|
||||
if stored_key and stored_key != key:
|
||||
raise ValueError(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -194,6 +195,37 @@ def _write_shared_bytes(
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
_BASH_FORMAT_COMMAND_RE = re.compile(
|
||||
r"\$\(\s*format_speckit_command\s+(['\"]?)([A-Za-z0-9_.-]+)\1(?:\s+[^)]*)?\)"
|
||||
)
|
||||
_POWERSHELL_FORMAT_COMMAND_RE = re.compile(
|
||||
r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?"
|
||||
)
|
||||
|
||||
|
||||
def _format_speckit_command(command_name: str, separator: str) -> str:
|
||||
name = command_name.strip().lstrip("/")
|
||||
if name.startswith("speckit."):
|
||||
name = name[len("speckit.") :]
|
||||
elif name.startswith("speckit-"):
|
||||
name = name[len("speckit-") :]
|
||||
name = name.replace(".", separator)
|
||||
return f"/speckit{separator}{name}"
|
||||
|
||||
|
||||
def _resolve_dynamic_command_refs(content: str, separator: str) -> str:
|
||||
"""Render script runtime command helpers for managed shared infra copies."""
|
||||
|
||||
content = _BASH_FORMAT_COMMAND_RE.sub(
|
||||
lambda match: _format_speckit_command(match.group(2), separator),
|
||||
content,
|
||||
)
|
||||
return _POWERSHELL_FORMAT_COMMAND_RE.sub(
|
||||
lambda match: f"'{_format_speckit_command(match.group(2), separator)}'",
|
||||
content,
|
||||
)
|
||||
|
||||
|
||||
def refresh_shared_templates(
|
||||
project_path: Path,
|
||||
*,
|
||||
@@ -365,12 +397,30 @@ def install_shared_infra(
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
# Record the existing-on-disk file in the manifest so a
|
||||
# fresh manifest run against an already-populated
|
||||
# ``.specify/`` tree does not silently drop it (#2107).
|
||||
# ``prior_hashes`` is the function-scope snapshot taken
|
||||
# at entry, so this membership check is O(1) and avoids
|
||||
# the repeated ``dict(self._files)`` copy that
|
||||
# ``manifest.files`` performs on every access.
|
||||
if dst_path.is_file() and rel not in prior_hashes:
|
||||
try:
|
||||
manifest.record_existing(rel, recovered=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
# Tolerate races / permission issues / non-file
|
||||
# collisions so one weird path does not abort
|
||||
# the whole install.
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
content = src_path.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
content = _resolve_dynamic_command_refs(content, invoke_separator)
|
||||
planned_copies.append(
|
||||
(
|
||||
dst_path,
|
||||
@@ -398,6 +448,23 @@ def install_shared_infra(
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
# Record the existing-on-disk template in the manifest so a
|
||||
# fresh manifest run against an already-populated
|
||||
# ``.specify/`` tree does not silently drop it (#2107).
|
||||
# ``prior_hashes`` is the function-scope snapshot taken at
|
||||
# entry, so this membership check is O(1) and avoids the
|
||||
# repeated ``dict(self._files)`` copy that ``manifest.files``
|
||||
# performs on every access.
|
||||
if dst.is_file() and rel not in prior_hashes:
|
||||
try:
|
||||
manifest.record_existing(rel, recovered=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
# Tolerate races / permission issues / non-file
|
||||
# collisions so one weird path does not abort
|
||||
# the whole install.
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
content = src.read_text(encoding="utf-8")
|
||||
@@ -416,7 +483,7 @@ def install_shared_infra(
|
||||
|
||||
if skipped_files:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure path(s) already exist and were not updated:"
|
||||
)
|
||||
for path in skipped_files:
|
||||
console.print(f" {path}")
|
||||
|
||||
@@ -232,6 +232,22 @@ def _validate_steps(
|
||||
step_errors = step_impl.validate(step_config)
|
||||
errors.extend(step_errors)
|
||||
|
||||
# Validate optional `continue_on_error` field. The engine honours
|
||||
# this on any step that returns StepStatus.FAILED so the pipeline can route
|
||||
# around the failure via a downstream `if` or `switch` (or a
|
||||
# `gate` that surfaces the failure to the operator via message
|
||||
# interpolation). The field must be a literal boolean —
|
||||
# coercion from truthy strings is deliberately not supported so
|
||||
# authoring mistakes surface at validation time rather than
|
||||
# silently changing run semantics.
|
||||
if "continue_on_error" in step_config:
|
||||
coe = step_config["continue_on_error"]
|
||||
if not isinstance(coe, bool):
|
||||
errors.append(
|
||||
f"Step {step_id!r}: 'continue_on_error' must be a "
|
||||
f"boolean, got {type(coe).__name__}."
|
||||
)
|
||||
|
||||
# Recursively validate nested steps
|
||||
for nested_key in ("then", "else", "steps"):
|
||||
nested = step_config.get(nested_key)
|
||||
@@ -265,16 +281,49 @@ def _validate_steps(
|
||||
class RunState:
|
||||
"""Manages workflow run state for persistence and resume."""
|
||||
|
||||
# ``run_id`` is interpolated into a filesystem path (``runs/<run_id>``)
|
||||
# by both ``save()`` and ``load()``. Constrain it to a charset that
|
||||
# cannot contain path separators (``/`` ``\``), parent-directory
|
||||
# segments (``..``), or NULs — anything that could escape the
|
||||
# ``.specify/workflows/runs/`` directory or be mis-interpreted by the
|
||||
# filesystem. The first-character anchor blocks IDs that start with
|
||||
# ``-`` (which would be mistaken for a CLI flag in error messages
|
||||
# and shell completions).
|
||||
_RUN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
||||
|
||||
@classmethod
|
||||
def _validate_run_id(cls, run_id: str) -> None:
|
||||
"""Raise ``ValueError`` if ``run_id`` is not a safe path component.
|
||||
|
||||
This is the single source of truth for what counts as a valid
|
||||
``run_id``. ``__init__`` calls it to reject malformed IDs at
|
||||
construction time; ``load`` calls it *before* interpolating the
|
||||
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):
|
||||
raise ValueError(
|
||||
f"Invalid run_id {run_id!r}: must be alphanumeric with "
|
||||
"hyphens/underscores only (and must start with an "
|
||||
"alphanumeric character)."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_id: str | None = None,
|
||||
workflow_id: str = "",
|
||||
project_root: Path | None = None,
|
||||
) -> None:
|
||||
self.run_id = run_id or str(uuid.uuid4())[:8]
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id):
|
||||
msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only."
|
||||
raise ValueError(msg)
|
||||
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
|
||||
# string is *not* the same as "omitted" and must be validated like
|
||||
# any other caller-provided value — otherwise ``__init__("")``
|
||||
# would silently substitute a UUID while ``load("")`` rejects, and
|
||||
# the two entry points would diverge on the empty-string vector.
|
||||
if run_id is None:
|
||||
self.run_id = str(uuid.uuid4())[:8]
|
||||
else:
|
||||
self.run_id = run_id
|
||||
self._validate_run_id(self.run_id)
|
||||
self.workflow_id = workflow_id
|
||||
self.project_root = project_root or Path(".")
|
||||
self.status = RunStatus.CREATED
|
||||
@@ -315,7 +364,20 @@ class RunState:
|
||||
|
||||
@classmethod
|
||||
def load(cls, run_id: str, project_root: Path) -> RunState:
|
||||
"""Load a run state from disk."""
|
||||
"""Load a run state from disk.
|
||||
|
||||
Validates ``run_id`` against ``_RUN_ID_PATTERN`` *before* building
|
||||
the lookup path. Without this guard, a caller passing a value like
|
||||
``../escape`` (e.g. via ``specify workflow resume`` CLI argument)
|
||||
would interpolate path-traversal segments into
|
||||
``runs_dir`` below, letting ``state_path.exists()`` probe arbitrary
|
||||
paths and ``json.load`` read attacker-planted JSON from outside
|
||||
the project's ``runs/`` directory. ``__init__`` already runs this
|
||||
check on the stored ``state_data["run_id"]``, but that fires
|
||||
*after* the file lookup — too late to prevent the disclosure.
|
||||
Mirrors the precedent in ``agents._ensure_within_directory``.
|
||||
"""
|
||||
cls._validate_run_id(run_id)
|
||||
runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id
|
||||
state_path = runs_dir / "state.json"
|
||||
if not state_path.exists():
|
||||
@@ -491,8 +553,19 @@ class WorkflowEngine:
|
||||
state.save()
|
||||
return state
|
||||
|
||||
def resume(self, run_id: str) -> RunState:
|
||||
"""Resume a paused or failed workflow run."""
|
||||
def resume(
|
||||
self,
|
||||
run_id: str,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
) -> RunState:
|
||||
"""Resume a paused or failed workflow run.
|
||||
|
||||
When ``inputs`` is provided, the values are merged over the run's
|
||||
persisted inputs and re-resolved through the same typed validation
|
||||
path used by :meth:`execute`, so the resumed step sees updated
|
||||
workflow inputs. Keys not supplied keep their persisted values; an
|
||||
empty/``None`` ``inputs`` leaves the run's inputs unchanged.
|
||||
"""
|
||||
state = RunState.load(run_id, self.project_root)
|
||||
if state.status not in (RunStatus.PAUSED, RunStatus.FAILED):
|
||||
msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}."
|
||||
@@ -508,6 +581,12 @@ class WorkflowEngine:
|
||||
else:
|
||||
definition = self.load_workflow(state.workflow_id)
|
||||
|
||||
# Merge any newly-supplied inputs over the persisted ones and
|
||||
# re-validate through the same typing path as the initial run.
|
||||
if inputs:
|
||||
merged = {**state.inputs, **inputs}
|
||||
state.inputs = self._resolve_inputs(definition, merged)
|
||||
|
||||
# Restore context
|
||||
context = StepContext(
|
||||
inputs=state.inputs,
|
||||
@@ -629,7 +708,10 @@ class WorkflowEngine:
|
||||
|
||||
# Handle failures
|
||||
if result.status == StepStatus.FAILED:
|
||||
# Gate abort (output.aborted) maps to ABORTED status
|
||||
# Gate abort (output.aborted) maps to ABORTED status.
|
||||
# Aborts are deliberate operator decisions, so
|
||||
# `continue_on_error` does NOT override them — that flag
|
||||
# is for transient/expected step failures only.
|
||||
if result.output.get("aborted"):
|
||||
state.status = RunStatus.ABORTED
|
||||
state.append_log(
|
||||
@@ -638,15 +720,49 @@ class WorkflowEngine:
|
||||
"step_id": step_id,
|
||||
}
|
||||
)
|
||||
else:
|
||||
state.status = RunStatus.FAILED
|
||||
state.save()
|
||||
return
|
||||
|
||||
# `continue_on_error: true` lets the pipeline route
|
||||
# around the failure instead of halting. The step
|
||||
# result (including exit_code, stderr, status) is
|
||||
# still recorded so a downstream `if` or `switch`
|
||||
# can branch on it (or a `gate` can surface it to the
|
||||
# operator via message interpolation). Log a single,
|
||||
# unambiguous event per failure resolution — either
|
||||
# the run continued past it, or it halted.
|
||||
#
|
||||
# Use identity comparison (`is True`) rather than
|
||||
# truthiness so that only a literal boolean enables
|
||||
# the behaviour, even if validation was skipped.
|
||||
# Validation rejects non-bool values at parse time,
|
||||
# but `WorkflowEngine.execute()` does not auto-validate
|
||||
# (see `WorkflowEngine.load_workflow`, whose docstring
|
||||
# explicitly notes "not yet validated; call
|
||||
# `validate_workflow()` or `engine.validate()`
|
||||
# separately"), so a caller passing an unvalidated
|
||||
# definition could otherwise see truthy non-bool
|
||||
# values like the string `"true"` silently change
|
||||
# run semantics.
|
||||
if step_config.get("continue_on_error") is True:
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
"event": "step_continue_on_error",
|
||||
"step_id": step_id,
|
||||
"error": result.error,
|
||||
}
|
||||
)
|
||||
state.save()
|
||||
continue
|
||||
|
||||
state.status = RunStatus.FAILED
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
"step_id": step_id,
|
||||
"error": result.error,
|
||||
}
|
||||
)
|
||||
state.save()
|
||||
return
|
||||
|
||||
|
||||
@@ -147,7 +147,14 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate
|
||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Agent context update**:
|
||||
3. **Create quickstart validation guide** → `quickstart.md`:
|
||||
- Document runnable validation scenarios that prove the feature works end-to-end
|
||||
- Include prerequisites, setup commands, test/run commands, and expected outcomes
|
||||
- Use links or references to contracts and data model details instead of duplicating them
|
||||
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
|
||||
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
|
||||
|
||||
4. **Agent context update**:
|
||||
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path)
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
|
||||
|
||||
@@ -81,3 +81,72 @@ def _isolate_auth_config(monkeypatch):
|
||||
# Also clear the per-process cache so tests that unset _config_override
|
||||
# won't see a previously cached real-file result.
|
||||
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_environ(monkeypatch):
|
||||
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
|
||||
|
||||
def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts):
|
||||
"""Create a fake executable under tmp_path and point sys.argv[0] at it."""
|
||||
monkeypatch.setenv(env_name, str(tmp_path))
|
||||
fake_dir = tmp_path.joinpath(*path_parts)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify")
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
fake_specify.chmod(0o755)
|
||||
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
||||
return fake_specify
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uv_tool_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin")
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
"HOME",
|
||||
(".local", "share", "uv", "tools", "specify-cli", "bin"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipx_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated pipx install path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin")
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uvx_ephemeral_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
"LOCALAPPDATA",
|
||||
("uv", "cache", "archive-v0", "abc123", "bin"),
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unsupported_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a path that does not match any installer prefix."""
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", ("random", "location", "bin")
|
||||
)
|
||||
|
||||
@@ -371,7 +371,7 @@ class TestCreateFeaturePowerShell:
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# pwsh may prefix warnings to stdout; find the JSON line
|
||||
json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")]
|
||||
json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
|
||||
assert json_line, f"No JSON in output: {result.stdout}"
|
||||
data = json.loads(json_line[-1])
|
||||
assert "BRANCH_NAME" in data
|
||||
|
||||
15
tests/http_helpers.py
Normal file
15
tests/http_helpers.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""HTTP test helpers shared by version-related CLI tests."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
"""Build a urlopen context-manager mock whose read returns JSON."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
@@ -131,5 +131,5 @@ class TestAgyHookCommandNote:
|
||||
)
|
||||
result = AgyIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [l for l in lines if "replace dots" in l][0]
|
||||
note_line = [ln for ln in lines if "replace dots" in ln][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
@@ -269,10 +269,10 @@ class MarkdownIntegrationTests:
|
||||
files.append(f"{cmd_dir}/speckit.{stem}.md")
|
||||
|
||||
# Framework files
|
||||
files.append(f".specify/integration.json")
|
||||
files.append(f".specify/init-options.json")
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(f".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
|
||||
|
||||
@@ -152,7 +152,7 @@ class YamlIntegrationTests:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
# Strip trailing source comment before parsing
|
||||
lines = content.split("\n")
|
||||
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
|
||||
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
|
||||
try:
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
except Exception as exc:
|
||||
@@ -183,7 +183,7 @@ class YamlIntegrationTests:
|
||||
content = cmd_files[0].read_text(encoding="utf-8")
|
||||
# Strip source comment for parsing
|
||||
lines = content.split("\n")
|
||||
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
|
||||
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
|
||||
assert "description:" not in parsed["prompt"]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import codecs
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
@@ -577,3 +578,204 @@ class TestClaudeHookCommandNote:
|
||||
assert "user-invocable: true" in result
|
||||
assert "disable-model-invocation: false" in result
|
||||
assert "replace dots" in result
|
||||
|
||||
|
||||
class TestSpeckitManifestRecordsSkippedFiles:
|
||||
"""Regression test for issue #2107.
|
||||
|
||||
``install_shared_infra`` must record every shared-infrastructure file
|
||||
under ``.specify/`` in ``speckit.manifest.json``, including files that
|
||||
were *skipped* because they already existed on disk and ``force=False``.
|
||||
|
||||
Before the fix, the skip branches in the scripts and templates loops
|
||||
appended to ``skipped_files`` without calling ``manifest.record_existing``.
|
||||
So when ``install_shared_infra`` ran with a fresh (or lost) manifest
|
||||
against an already-populated ``.specify/`` tree, every file went down the
|
||||
skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and
|
||||
``manifest.save()`` wrote an empty ``files`` field — leaving the
|
||||
integration believing nothing was installed.
|
||||
|
||||
Reproduction (without the fix) using ``install_shared_infra`` directly:
|
||||
|
||||
install_shared_infra(p, "sh", ..., force=False) # 1st run → 10 files
|
||||
(p / ".specify/integrations/speckit.manifest.json").unlink()
|
||||
install_shared_infra(p, "sh", ..., force=False) # 2nd run → 0 files
|
||||
# ^^ BUG: empty
|
||||
"""
|
||||
|
||||
def _read_manifest_files(self, project_path: Path) -> dict:
|
||||
manifest_path = (
|
||||
project_path / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
)
|
||||
assert manifest_path.exists(), (
|
||||
f"speckit.manifest.json not written at {manifest_path}"
|
||||
)
|
||||
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
# ``IntegrationManifest.save`` serialises a ``files`` dict — assert
|
||||
# the schema explicitly so a regression to a different key (e.g.
|
||||
# the internal ``_files`` attribute name) fails loudly instead of
|
||||
# being masked by a silent fallback.
|
||||
assert isinstance(data, dict), (
|
||||
f"manifest root is not a dict, got {type(data).__name__}"
|
||||
)
|
||||
assert "files" in data, (
|
||||
f"manifest missing 'files' key, got keys: {sorted(data.keys())}"
|
||||
)
|
||||
files = data["files"]
|
||||
assert isinstance(files, dict), (
|
||||
f"manifest 'files' is not a dict, got {type(files).__name__}"
|
||||
)
|
||||
return files
|
||||
|
||||
def test_install_shared_infra_records_skipped_files(self, tmp_path):
|
||||
"""With ``force=False`` and ``.specify/`` already populated, the
|
||||
manifest must still record every file — the skip branches are not
|
||||
allowed to drop files from the manifest."""
|
||||
from rich.console import Console
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
# Resolve the project's own packaged sources by walking up from this
|
||||
# test file to the repo root (which contains ``scripts/`` and
|
||||
# ``templates/`` that ``shared_scripts_source`` looks for).
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
console = Console(quiet=True)
|
||||
|
||||
# First run — fresh project, manifest gets populated normally.
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"sh",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=repo_root,
|
||||
console=console,
|
||||
force=False,
|
||||
)
|
||||
first_files = self._read_manifest_files(tmp_path)
|
||||
assert first_files, "first install produced an empty manifest"
|
||||
|
||||
# Simulate a lost manifest while ``.specify/`` is still on disk
|
||||
# (e.g. the manifest was deleted, corrupted, or the layout was
|
||||
# extracted out-of-band).
|
||||
manifest_path = (
|
||||
tmp_path / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
)
|
||||
manifest_path.unlink()
|
||||
|
||||
# Second run — every file already exists, so every iteration takes
|
||||
# the skip branch. With the fix, those files are still recorded.
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"sh",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=repo_root,
|
||||
console=console,
|
||||
force=False,
|
||||
)
|
||||
second_files = self._read_manifest_files(tmp_path)
|
||||
assert second_files, (
|
||||
"speckit.manifest.json files dict is empty after install with "
|
||||
"skipped files (issue #2107) — every file went down the skip "
|
||||
"branch but none were recorded"
|
||||
)
|
||||
|
||||
# The recovered manifest must cover everything the first run tracked.
|
||||
missing = set(first_files) - set(second_files)
|
||||
assert not missing, (
|
||||
f"these files were tracked on the first install but missing after "
|
||||
f"the skipped-files re-install: {sorted(missing)[:5]}"
|
||||
)
|
||||
|
||||
def test_install_shared_infra_handles_directory_at_script_destination(
|
||||
self, tmp_path
|
||||
):
|
||||
"""A non-file (directory) at a script's destination must NOT crash
|
||||
``install_shared_infra`` and must NOT be recorded in the manifest —
|
||||
the path still appears in the user-visible skipped-paths warning.
|
||||
"""
|
||||
from io import StringIO
|
||||
from rich.console import Console
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
output = StringIO()
|
||||
console = Console(file=output, force_terminal=False, width=200)
|
||||
|
||||
# Pre-create the .specify/scripts/bash tree, then plant a directory
|
||||
# where a script file is expected so the skip branch hits a
|
||||
# non-regular-file path.
|
||||
bash_dir = tmp_path / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True)
|
||||
(bash_dir / "common.sh").mkdir() # collision: dir where file expected
|
||||
|
||||
# Must not crash.
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"sh",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=repo_root,
|
||||
console=console,
|
||||
force=False,
|
||||
)
|
||||
|
||||
files = self._read_manifest_files(tmp_path)
|
||||
assert ".specify/scripts/bash/common.sh" not in files, (
|
||||
"directory at script dst must not be recorded in the manifest"
|
||||
)
|
||||
text = output.getvalue()
|
||||
assert "common.sh" in text, (
|
||||
"directory-at-script-dst path must surface in the skipped warning"
|
||||
)
|
||||
|
||||
def test_install_shared_infra_handles_directory_at_template_destination(
|
||||
self, tmp_path
|
||||
):
|
||||
"""Symmetric coverage for the templates loop: a directory at a
|
||||
template's destination must NOT crash install nor be recorded."""
|
||||
from io import StringIO
|
||||
from rich.console import Console
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
output = StringIO()
|
||||
console = Console(file=output, force_terminal=False, width=200)
|
||||
|
||||
templates_dir = tmp_path / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
|
||||
src_templates = repo_root / "templates"
|
||||
real_template = next(
|
||||
(
|
||||
p.name
|
||||
for p in src_templates.iterdir()
|
||||
if p.is_file()
|
||||
and not p.name.startswith(".")
|
||||
and p.name != "vscode-settings.json"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert real_template, (
|
||||
"no real template found in repo to collide against"
|
||||
)
|
||||
(templates_dir / real_template).mkdir() # collision
|
||||
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"sh",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=repo_root,
|
||||
console=console,
|
||||
force=False,
|
||||
)
|
||||
|
||||
files = self._read_manifest_files(tmp_path)
|
||||
template_rel = f".specify/templates/{real_template}"
|
||||
assert template_rel not in files, (
|
||||
"directory at template dst must not be recorded in manifest"
|
||||
)
|
||||
text = output.getvalue()
|
||||
assert real_template in text, (
|
||||
"directory-at-template-dst path must surface in the skipped warning"
|
||||
)
|
||||
|
||||
@@ -426,8 +426,8 @@ class TestCopilotSkillsMode:
|
||||
|
||||
# -- Copilot-specific post-processing ---------------------------------
|
||||
|
||||
def test_post_process_skill_content_injects_mode(self):
|
||||
"""post_process_skill_content() should inject mode: field."""
|
||||
def test_post_process_skill_content_does_not_inject_mode(self):
|
||||
"""post_process_skill_content() must NOT inject mode: — VS Code Copilot does not support it."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
@@ -437,10 +437,10 @@ class TestCopilotSkillsMode:
|
||||
"\nBody content\n"
|
||||
)
|
||||
updated = copilot.post_process_skill_content(content)
|
||||
assert "mode: speckit.plan" in updated
|
||||
assert "mode:" not in updated
|
||||
|
||||
def test_post_process_skill_content_injects_hook_note(self):
|
||||
"""post_process_skill_content() should inject shared hook guidance."""
|
||||
"""post_process_skill_content() should inject shared hook guidance but not mode:."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
@@ -451,7 +451,7 @@ class TestCopilotSkillsMode:
|
||||
)
|
||||
updated = copilot.post_process_skill_content(content)
|
||||
assert "replace dots" in updated
|
||||
assert "mode: speckit.specify" in updated
|
||||
assert "mode:" not in updated
|
||||
|
||||
def test_post_process_idempotent(self):
|
||||
"""post_process_skill_content() must be idempotent."""
|
||||
@@ -467,8 +467,8 @@ class TestCopilotSkillsMode:
|
||||
second = copilot.post_process_skill_content(first)
|
||||
assert first == second
|
||||
|
||||
def test_skills_have_mode_in_frontmatter(self, tmp_path):
|
||||
"""Generated SKILL.md files should have mode: field from post-processing."""
|
||||
def test_skills_do_not_have_mode_in_frontmatter(self, tmp_path):
|
||||
"""Generated SKILL.md files must NOT contain mode: — VS Code Copilot does not support it."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
@@ -477,11 +477,7 @@ class TestCopilotSkillsMode:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "mode" in fm, f"{f} frontmatter missing 'mode'"
|
||||
# mode should be speckit.<stem>
|
||||
skill_dir_name = f.parent.name
|
||||
stem = skill_dir_name.removeprefix("speckit-")
|
||||
assert fm["mode"] == f"speckit.{stem}"
|
||||
assert "mode" not in fm, f"{f} frontmatter must not contain unsupported 'mode' field"
|
||||
|
||||
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
||||
"""Generated skills with hook sections should include shared hook guidance."""
|
||||
|
||||
@@ -185,6 +185,20 @@ class TestGenericIntegration:
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_plan_defines_quickstart_as_validation_guide(self, tmp_path):
|
||||
"""The generated plan command should keep quickstart.md out of implementation scope."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
|
||||
assert "Create quickstart validation guide" in content
|
||||
assert "runnable validation scenarios" in content
|
||||
assert "Do not include full implementation code" in content
|
||||
assert "implementation details belong in `tasks.md` and the implementation phase" in content
|
||||
|
||||
def test_implement_loads_constitution_context(self, tmp_path):
|
||||
"""The generated implement command should load constitution governance context."""
|
||||
i = get_integration("generic")
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
@@ -242,9 +241,9 @@ class TestIntegrationInstall:
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
import specify_cli.integrations._commands as _int_cmds
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "install", "codex",
|
||||
@@ -898,11 +897,10 @@ class TestIntegrationSwitch:
|
||||
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
|
||||
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
|
||||
|
||||
# Verify Copilot-specific frontmatter: mode field should map from
|
||||
# skill name (speckit-git-feature) back to dot notation (speckit.git-feature)
|
||||
# Verify Copilot skill frontmatter does NOT contain mode: — VS Code Copilot does not support it
|
||||
skill_content = copilot_git_feature.read_text(encoding="utf-8")
|
||||
assert "mode: speckit.git-feature" in skill_content, (
|
||||
"Copilot skill frontmatter should contain mode mapped from skill name"
|
||||
assert "mode:" not in skill_content, (
|
||||
"Copilot skill frontmatter must not contain unsupported 'mode' field"
|
||||
)
|
||||
|
||||
registry = json.loads(
|
||||
@@ -1210,9 +1208,9 @@ class TestIntegrationUpgrade:
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
import specify_cli.integrations._commands as _int_cmds
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
@@ -1236,9 +1234,9 @@ class TestIntegrationUpgrade:
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
import specify_cli.integrations._commands as _int_cmds
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
@@ -1472,7 +1470,7 @@ class TestScriptTypeValidation:
|
||||
class TestParseIntegrationOptionsEqualsForm:
|
||||
def test_equals_form_parsed(self):
|
||||
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
|
||||
from specify_cli import _parse_integration_options
|
||||
from specify_cli.integrations._commands import _parse_integration_options
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
integration = get_integration("generic")
|
||||
|
||||
@@ -34,6 +34,57 @@ class TestManifestRecordFile:
|
||||
assert m.files["existing.txt"] == _sha256(f)
|
||||
|
||||
|
||||
class TestManifestRecordExistingErrors:
|
||||
"""Error-case coverage for ``record_existing`` symlink + non-file guards.
|
||||
|
||||
Added in #2483 — Copilot review flagged these as un-tested regressions
|
||||
after the ``is_symlink``/``is_file`` guards were introduced.
|
||||
"""
|
||||
|
||||
def test_rejects_symlink_target(self, tmp_path):
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("target content", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
link.symlink_to(target)
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
m.record_existing("link.txt")
|
||||
|
||||
def test_rejects_dangling_symlink(self, tmp_path):
|
||||
# A symlink pointing nowhere should still be rejected before the
|
||||
# ``is_file()`` check (which would itself be False on a dangler).
|
||||
link = tmp_path / "dangler.txt"
|
||||
link.symlink_to(tmp_path / "no-such-target.txt")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
m.record_existing("dangler.txt")
|
||||
|
||||
def test_rejects_directory_path(self, tmp_path):
|
||||
(tmp_path / "a_dir").mkdir()
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="not a regular file"):
|
||||
m.record_existing("a_dir")
|
||||
|
||||
def test_rejects_missing_path(self, tmp_path):
|
||||
# ``is_file()`` is False for non-existent paths too; the same error
|
||||
# surface keeps callers from having to distinguish "missing" from
|
||||
# "wrong kind" — both mean "cannot hash this".
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="not a regular file"):
|
||||
m.record_existing("never-existed.txt")
|
||||
|
||||
def test_lexical_prevalidation_for_absolute_path(self, tmp_path):
|
||||
# ``record_existing`` must reject absolute paths via the lexical
|
||||
# pre-check, NOT via the filesystem-touching ``is_symlink()`` call.
|
||||
# Verified by passing an absolute path that points to a directory
|
||||
# outside the project root — the canonical "Absolute paths" error
|
||||
# must surface before any stat on the absolute path.
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
|
||||
with pytest.raises(ValueError, match="Absolute paths"):
|
||||
m.record_existing(abs_path)
|
||||
|
||||
|
||||
class TestManifestPathTraversal:
|
||||
def test_record_file_rejects_parent_traversal(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
@@ -245,3 +296,160 @@ class TestManifestLoadValidation:
|
||||
path.write_text("{not valid json", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="invalid JSON"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_filters_recovered_files_not_in_files(self, tmp_path):
|
||||
# Finding B (Round-9): a recovered_files entry referencing a path
|
||||
# not present in files indicates an internally-inconsistent manifest
|
||||
# (e.g. external edit). load() filters those entries silently so the
|
||||
# manifest self-heals on next save(); is_recovered then returns the
|
||||
# truthful False for the orphan.
|
||||
path = tmp_path / ".specify" / "integrations" / "test.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({
|
||||
"integration": "test",
|
||||
"files": {"kept.txt": "abc123"},
|
||||
"recovered_files": ["kept.txt", "orphan.txt"],
|
||||
}), encoding="utf-8")
|
||||
m = IntegrationManifest.load("test", tmp_path)
|
||||
assert m.recovered_files == {"kept.txt"}
|
||||
assert m.is_recovered("kept.txt") is True
|
||||
assert m.is_recovered("orphan.txt") is False
|
||||
|
||||
|
||||
class TestManifestRecoveredFiles:
|
||||
"""Coverage for the ``recovered_files`` channel added in #2483.
|
||||
|
||||
When ``shared_infra`` skips an existing file (because the user already has
|
||||
it on disk) it now records the file with ``recovered=True``. The path
|
||||
appears in ``manifest.recovered_files`` and ``is_recovered(path)`` returns
|
||||
True. ``refresh_managed`` (out of scope for this PR) consults this list
|
||||
before treating the recorded hash as a managed baseline, defending against
|
||||
silent overwrite of user customizations after manifest loss.
|
||||
"""
|
||||
|
||||
def test_record_existing_default_is_not_recovered(self, tmp_path):
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt")
|
||||
assert m.is_recovered("f.txt") is False
|
||||
assert m.recovered_files == set()
|
||||
|
||||
def test_record_existing_with_recovered_flag(self, tmp_path):
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt", recovered=True)
|
||||
assert m.is_recovered("f.txt") is True
|
||||
assert m.recovered_files == {"f.txt"}
|
||||
# File still hashed normally so check_modified/uninstall keep working
|
||||
assert m.files["f.txt"] == _sha256(tmp_path / "f.txt")
|
||||
|
||||
def test_recovered_files_round_trips_through_save_load(self, tmp_path):
|
||||
(tmp_path / "a.txt").write_text("aaa", encoding="utf-8")
|
||||
(tmp_path / "b.txt").write_text("bbb", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path, version="9.9")
|
||||
m.record_existing("a.txt", recovered=True)
|
||||
m.record_existing("b.txt") # not recovered
|
||||
m.save()
|
||||
loaded = IntegrationManifest.load("test", tmp_path)
|
||||
assert loaded.is_recovered("a.txt") is True
|
||||
assert loaded.is_recovered("b.txt") is False
|
||||
assert loaded.recovered_files == {"a.txt"}
|
||||
|
||||
def test_save_omits_empty_recovered_files(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "x")
|
||||
path = m.save()
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert "recovered_files" not in data
|
||||
|
||||
def test_load_rejects_non_list_recovered_files(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(
|
||||
json.dumps({"files": {}, "recovered_files": "not-a-list"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValueError, match="recovered_files"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_is_recovered_absolute_path_returns_false(self, tmp_path):
|
||||
# Copilot round-5 finding: passing an absolute path silently returned
|
||||
# False because the stored keys are relative POSIX strings. Round-7
|
||||
# made this explicit: ``is_recovered`` now rejects absolute paths
|
||||
# up front via a lexical ``rel.is_absolute()`` guard and returns
|
||||
# False without calling ``_validate_rel_path`` at all — matching
|
||||
# ``record_existing``'s canonical-key guard so the two methods
|
||||
# agree on which inputs can ever be stored keys.
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt", recovered=True)
|
||||
import sys
|
||||
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
|
||||
assert m.is_recovered(abs_input) is False
|
||||
|
||||
def test_is_recovered_escaping_path_returns_false(self, tmp_path):
|
||||
# A relative path containing ``..`` segments cannot be a stored key:
|
||||
# Round-7 added the same lexical ``".." in rel.parts`` guard to
|
||||
# ``is_recovered`` that ``record_existing`` already enforces, so the
|
||||
# method returns False immediately without reaching
|
||||
# ``_validate_rel_path``. The try/except around ``_validate_rel_path``
|
||||
# remains as defense-in-depth for paths that pass the lexical guard
|
||||
# but still resolve outside the project root via a symlinked
|
||||
# ancestor.
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
# Don't record anything — the path is impossible to record anyway.
|
||||
assert m.is_recovered("../escape.txt") is False
|
||||
|
||||
def test_record_existing_clears_recovered_when_false(self, tmp_path):
|
||||
# Finding A: re-recording the same path with recovered=False must
|
||||
# drop the prior recovered marker (transition to managed baseline).
|
||||
f = tmp_path / "x.txt"
|
||||
f.write_text("v1", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("x.txt", recovered=True)
|
||||
assert m.is_recovered("x.txt") is True
|
||||
m.record_existing("x.txt", recovered=False)
|
||||
assert m.is_recovered("x.txt") is False
|
||||
|
||||
def test_record_file_clears_recovered(self, tmp_path):
|
||||
# Finding A: record_file writes produced content; the path can no
|
||||
# longer be considered "merely observed" once we wrote bytes.
|
||||
(tmp_path / "y.txt").write_text("observed", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("y.txt", recovered=True)
|
||||
assert m.is_recovered("y.txt") is True
|
||||
m.record_file("y.txt", "produced")
|
||||
assert m.is_recovered("y.txt") is False
|
||||
|
||||
def test_is_recovered_rejects_dotdot_segment(self, tmp_path):
|
||||
# Finding B: record_existing rejects ``..`` segments via the lexical
|
||||
# pre-check; is_recovered must match that behavior and return False
|
||||
# without raising, mirroring the canonicalization guard.
|
||||
(tmp_path / "z.txt").write_text("v1", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("z.txt", recovered=True)
|
||||
# Same file via dotdot-normalizing path — must be False, not raise.
|
||||
assert m.is_recovered("subdir/../z.txt") is False
|
||||
|
||||
|
||||
class TestRecordExistingNewGuards:
|
||||
"""Coverage for the two new guards added by Copilot's 2026-05-18 review."""
|
||||
|
||||
def test_rejects_symlinked_ancestor(self, tmp_path):
|
||||
real_dir = tmp_path / "real_dir"
|
||||
real_dir.mkdir()
|
||||
(real_dir / "file.txt").write_text("payload", encoding="utf-8")
|
||||
(tmp_path / "linked_dir").symlink_to(real_dir, target_is_directory=True)
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
m.record_existing("linked_dir/file.txt")
|
||||
|
||||
def test_rejects_inside_root_dotdot_with_explicit_message(self, tmp_path):
|
||||
# ``dir/../file.txt`` normalizes inside root, so the old "escapes
|
||||
# project root" message was misleading. The new message names the
|
||||
# actual reason: canonicalization.
|
||||
(tmp_path / "dir").mkdir()
|
||||
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
||||
m.record_existing("dir/../file.txt")
|
||||
|
||||
64
tests/self_upgrade_helpers.py
Normal file
64
tests/self_upgrade_helpers.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Shared fixtures and helpers for `specify self upgrade` tests.
|
||||
|
||||
These helpers patch subprocess, PATH lookup, and release-tag resolution so
|
||||
the focused test modules stay isolated from the real environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli._version import (
|
||||
_InstallMethod,
|
||||
_UpgradePlan,
|
||||
_assemble_installer_argv,
|
||||
_detect_install_method,
|
||||
_verify_upgrade,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
__all__ = (
|
||||
"SENTINEL_GH_TOKEN",
|
||||
"SENTINEL_GITHUB_TOKEN",
|
||||
"_InstallMethod",
|
||||
"_UpgradePlan",
|
||||
"_assemble_installer_argv",
|
||||
"_completed_process",
|
||||
"_detect_install_method",
|
||||
"_verify_upgrade",
|
||||
"mock_urlopen_response",
|
||||
"requires_posix",
|
||||
"runner",
|
||||
"strip_ansi",
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Some installer error-path tests create a relative `./uv` fixture, `chdir`
|
||||
# into the tmp dir, and assert POSIX executable-bit semantics (chmod / X_OK).
|
||||
# None of that maps cleanly onto Windows: `os.access(path, X_OK)` ignores the
|
||||
# mode bits, and pytest cannot rmtree a tmp dir that is still the cwd, so the
|
||||
# fixtures raise PermissionError during teardown. Skip these on Windows — the
|
||||
# realistic absolute-path and bare-PATH-command branches stay covered there.
|
||||
requires_posix = pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="relative-path / executable-bit semantics are POSIX-only",
|
||||
)
|
||||
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
|
||||
def _completed_process(
|
||||
returncode: int, stdout: str = "", stderr: str = ""
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Build a subprocess.CompletedProcess for installer / verification calls."""
|
||||
return subprocess.CompletedProcess(
|
||||
args=["mocked"],
|
||||
returncode=returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
@@ -573,7 +573,9 @@ class TestAuthenticatedHttp:
|
||||
mock_opener = MagicMock()
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener.open.side_effect = fake_open
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
@@ -588,7 +590,9 @@ class TestAuthenticatedHttp:
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://example.com/file.json")
|
||||
@@ -601,7 +605,9 @@ class TestAuthenticatedHttp:
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://github.com/org/repo")
|
||||
@@ -615,12 +621,16 @@ class TestAuthenticatedHttp:
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
call_count = 0
|
||||
def fake_side_effect(req, timeout=None):
|
||||
nonlocal call_count; call_count += 1
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = fake_side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
|
||||
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
|
||||
open_url("https://github.com/org/repo")
|
||||
@@ -692,7 +702,6 @@ class TestLoadConfigCaching:
|
||||
"""_load_config() should call load_auth_config only once per process."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
# Allow the real load path (no override)
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
@@ -825,8 +834,11 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
def side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
|
||||
resp = MagicMock(); resp.read.return_value = body
|
||||
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
return captured, side_effect
|
||||
|
||||
@@ -836,7 +848,8 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
|
||||
|
||||
@@ -13,12 +13,6 @@ def test_commands_init_importable():
|
||||
assert callable(mod.register)
|
||||
|
||||
|
||||
def test_commands_stubs_importable():
|
||||
for name in ("integration", "preset", "extension", "workflow"):
|
||||
mod = importlib.import_module(f"specify_cli.commands.{name}")
|
||||
assert mod is not None
|
||||
|
||||
|
||||
def test_agent_config_importable():
|
||||
from specify_cli._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
@@ -35,7 +29,7 @@ def test_agent_config_importable():
|
||||
|
||||
|
||||
def test_agent_config_re_exported_from_init():
|
||||
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES
|
||||
from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
from specify_cli import (
|
||||
console,
|
||||
StepTracker,
|
||||
get_key,
|
||||
select_with_arrows,
|
||||
BannerGroup,
|
||||
show_banner,
|
||||
BANNER,
|
||||
TAGLINE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from pathlib import Path
|
||||
from specify_cli.extensions import (
|
||||
ExtensionManifest,
|
||||
ExtensionManager,
|
||||
ExtensionError,
|
||||
)
|
||||
|
||||
|
||||
@@ -241,7 +240,7 @@ class TestExtensionSkillRegistration:
|
||||
"""Skills should be created when ai_skills is enabled."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
@@ -784,7 +783,7 @@ class TestExtensionSkillEdgeCases:
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
@@ -803,7 +802,7 @@ class TestExtensionSkillEdgeCases:
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
@@ -819,10 +818,10 @@ class TestExtensionSkillEdgeCases:
|
||||
ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest_a = manager.install_from_directory(
|
||||
manager.install_from_directory(
|
||||
ext_dir_a, "0.1.0", register_commands=False
|
||||
)
|
||||
manifest_b = manager.install_from_directory(
|
||||
manager.install_from_directory(
|
||||
ext_dir_b, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
@@ -880,7 +879,7 @@ class TestExtensionSkillEdgeCases:
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
# Should not raise
|
||||
manifest = manager.install_from_directory(
|
||||
manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
|
||||
@@ -2804,17 +2804,33 @@ class TestExtensionCatalog:
|
||||
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = zip_bytes
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
release_response = MagicMock()
|
||||
release_response.read.return_value = json.dumps(
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"name": "test-ext.zip",
|
||||
"url": "https://api.github.com/repos/org/repo/releases/assets/1",
|
||||
}
|
||||
]
|
||||
}
|
||||
).encode()
|
||||
release_response.__enter__ = lambda s: s
|
||||
release_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = {}
|
||||
asset_response = MagicMock()
|
||||
asset_response.read.return_value = zip_bytes
|
||||
asset_response.__enter__ = lambda s: s
|
||||
asset_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = []
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
return mock_response
|
||||
captured.append(req)
|
||||
if req.full_url.endswith("/releases/tags/v1"):
|
||||
return release_response
|
||||
return asset_response
|
||||
|
||||
mock_opener.open.side_effect = fake_open
|
||||
|
||||
@@ -2829,7 +2845,56 @@ class TestExtensionCatalog:
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1"
|
||||
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
|
||||
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[1].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
|
||||
"""download_extension can use a GitHub REST release asset URL directly."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, "w") as zf:
|
||||
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
asset_response = MagicMock()
|
||||
asset_response.read.return_value = zip_bytes
|
||||
asset_response.__enter__ = lambda s: s
|
||||
asset_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
captured = []
|
||||
mock_opener = MagicMock()
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
captured.append(req)
|
||||
return asset_response
|
||||
|
||||
mock_opener.open.side_effect = fake_open
|
||||
|
||||
ext_info = {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://api.github.com/repos/org/repo/releases/assets/1",
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
|
||||
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[0].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
|
||||
|
||||
|
||||
887
tests/test_self_upgrade_detection.py
Normal file
887
tests/test_self_upgrade_detection.py
Normal file
@@ -0,0 +1,887 @@
|
||||
"""Detection, argv assembly, and dry-run tests for `specify self upgrade`."""
|
||||
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import specify_cli
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
_InstallMethod,
|
||||
_assemble_installer_argv,
|
||||
_completed_process,
|
||||
_detect_install_method,
|
||||
mock_urlopen_response,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
|
||||
class TestDetectionUvTool:
|
||||
"""Tier-1 path-prefix detection for uv-tool installs."""
|
||||
|
||||
def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 1
|
||||
assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/")
|
||||
|
||||
def test_detection_is_deterministic(self, uv_tool_argv0):
|
||||
a = _detect_install_method()
|
||||
b = _detect_install_method()
|
||||
assert a == b == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version._editable_marker_seen", return_value=False
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0):
|
||||
result = _detect_install_method(include_signals=False)
|
||||
assert isinstance(result, _InstallMethod)
|
||||
|
||||
def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path):
|
||||
if os.name == "nt":
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
|
||||
else:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = (
|
||||
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
|
||||
)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: str(fake_specify) if name == "specify" else None,
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin"
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version._editable_marker_seen", return_value=False
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_when_registry_lists_exact_name(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\nother-tool v1.2.3\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 3
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
|
||||
def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.installer_registries_consulted == ()
|
||||
|
||||
def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
missing_specify = tmp_path / "missing" / "specify"
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "specify":
|
||||
return str(missing_specify)
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 3
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
|
||||
def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
if os.name == "nt":
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
|
||||
else:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = (
|
||||
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
|
||||
)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", ["./bin/specify"])
|
||||
|
||||
def fake_which(name):
|
||||
return str(fake_specify) if name == "specify" else None
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which):
|
||||
method = _detect_install_method()
|
||||
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_tier3_uv_tool_ignores_substring_false_positive(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="my-specify-cli-helper v0.1.0\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
venv_bin = tmp_path / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
fake_specify = venv_bin / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
fake_specify.chmod(0o755)
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "specify":
|
||||
return str(fake_specify)
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.matched_tier is None
|
||||
assert signals.installer_registries_consulted == ()
|
||||
|
||||
|
||||
class TestPrefixExpansion:
|
||||
"""Path-prefix expansion edge cases."""
|
||||
|
||||
def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path):
|
||||
prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli"
|
||||
prefix = str(prefix_path)
|
||||
|
||||
expanded = specify_cli._version._expand_prefix(prefix)
|
||||
|
||||
assert expanded == prefix_path.resolve()
|
||||
|
||||
def test_unresolved_posix_variable_is_rejected(self):
|
||||
assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None
|
||||
|
||||
def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path):
|
||||
prefix = str(tmp_path / "specify-cli")
|
||||
|
||||
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
assert specify_cli._version._expand_prefix(prefix) is None
|
||||
|
||||
|
||||
class TestArgv0Resolution:
|
||||
"""Entrypoint path resolution edge cases."""
|
||||
|
||||
def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path):
|
||||
argv0 = tmp_path / "specify"
|
||||
|
||||
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0
|
||||
|
||||
def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self):
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", return_value="/broken/specify"
|
||||
), patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
result = specify_cli._version._resolved_argv0_path("specify")
|
||||
|
||||
# Compare as Path objects: on Windows the same logical path renders
|
||||
# with backslashes, so a raw string compare against the POSIX form
|
||||
# would spuriously fail.
|
||||
assert result == Path("/broken/specify")
|
||||
|
||||
|
||||
class TestArgvAssemblyUvTool:
|
||||
"""uv-tool installer argv shape."""
|
||||
|
||||
def test_stable_tag_produces_expected_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6")
|
||||
assert argv == [
|
||||
"uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
]
|
||||
|
||||
def test_dev_suffix_tag_embedded_literally(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0")
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv
|
||||
assert (
|
||||
"upgrade" not in argv
|
||||
) # never `uv tool upgrade` — does not accept --tag pinning
|
||||
|
||||
def test_missing_uv_returns_no_installer_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None):
|
||||
assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None
|
||||
|
||||
|
||||
class TestBareUpgradeUvTool:
|
||||
"""uv-tool happy path, bare invocation."""
|
||||
|
||||
def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0), # installer
|
||||
_completed_process(0, stdout="specify 0.7.6\n"), # verify
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
|
||||
assert mock_run.call_count == 2
|
||||
for call in mock_run.call_args_list:
|
||||
assert call.kwargs.get("shell", False) is False
|
||||
|
||||
def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ):
|
||||
# The single `invoke` represents the single user action — no prompt.
|
||||
# If a prompt existed, runner.invoke would hang waiting for input.
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestAlreadyLatestUvTool:
|
||||
"""already on latest, no installer launched."""
|
||||
|
||||
def test_already_latest_exits_zero_no_subprocess(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.6"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on latest release: v0.7.6" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_trailing_zero_equivalent_version_reports_latest_not_newer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# Version("1.0") == Version("1.0.0") under packaging even though their
|
||||
# canonical strings differ. The no-op message must use Version equality
|
||||
# so this prints "Already on latest release", not "... or newer".
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="1.0"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release: v1.0.0" in out
|
||||
assert "or newer" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dev_build_ahead_of_release_reports_newer_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_unparseable_current_version_does_not_false_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release" not in out
|
||||
assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_unparseable_resolved_target_fails_before_literal_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
out = strip_ansi(result.output)
|
||||
assert "not a comparable version" in out
|
||||
assert "release-main" not in out
|
||||
assert "Already on latest release" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_pinned_older_tag_still_runs_installer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.6"
|
||||
):
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.5\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release" not in out
|
||||
# A pinned older tag is a downgrade and must be labelled as such.
|
||||
assert "Downgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
|
||||
assert "Upgrading specify-cli" not in out
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_pinned_rc_tag_uses_canonical_version_equality_for_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestDryRunUvTool:
|
||||
"""--dry-run preview path + --dry-run combined with --tag."""
|
||||
|
||||
def test_dry_run_without_tag_resolves_network_but_no_subprocess(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Dry run — no changes will be made." in out
|
||||
assert "Detected install method: uv tool" in out
|
||||
assert "Current version: 0.7.5" in out
|
||||
assert "Target version: v0.7.6" in out
|
||||
assert "Command that would be executed:" in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ):
|
||||
# --dry-run with --tag must NOT hit the network.
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
), patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0" in strip_ansi(result.output)
|
||||
mock_urlopen.assert_not_called()
|
||||
|
||||
def test_dry_run_rejects_unparseable_network_tag_before_preview(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response(
|
||||
{"tag_name": "v0.9.0;echo unsafe"}
|
||||
)
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
|
||||
out = strip_ansi(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "not a comparable version" in out
|
||||
assert "v0.9.0;echo unsafe" not in out
|
||||
assert "Command that would be executed:" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dry_run_with_missing_uv_flags_unresolved_installer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Command that would be executed: (installer uv not found on PATH)" in out
|
||||
assert "uv tool install" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 4 — User Story 2: `pipx` immediate upgrade (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestDetectionPipx:
|
||||
"""Pipx detection — tier 1 (path) and tier 3 (registry)."""
|
||||
|
||||
def test_posix_pipx_prefix_matches(self, pipx_argv0):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.PIPX
|
||||
assert signals.matched_tier == 1
|
||||
|
||||
def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.PIPX
|
||||
assert signals.matched_tier == 3
|
||||
assert "pipx list --json" in signals.installer_registries_consulted
|
||||
|
||||
def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_pipx_ignores_malformed_json_output(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="not json but mentions specify-cli",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
if name == "pipx":
|
||||
return "pipx"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.matched_tier is None
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
assert "pipx list --json" in signals.installer_registries_consulted
|
||||
|
||||
|
||||
class TestEditableInstallMetadata:
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(importlib.metadata, "InvalidMetadataError"),
|
||||
reason=(
|
||||
"importlib.metadata.InvalidMetadataError does not exist on this "
|
||||
"Python; _editable_direct_url_path only catches it when present, so "
|
||||
"fabricating it would exercise a path that cannot fire in production"
|
||||
),
|
||||
)
|
||||
def test_editable_marker_false_when_metadata_is_invalid(self):
|
||||
invalid_metadata_error = importlib.metadata.InvalidMetadataError
|
||||
|
||||
with patch(
|
||||
"importlib.metadata.distribution",
|
||||
side_effect=invalid_metadata_error("bad metadata"),
|
||||
):
|
||||
assert specify_cli._version._editable_marker_seen() is False
|
||||
assert specify_cli._version._source_checkout_path() is None
|
||||
|
||||
def test_direct_url_editable_install_marks_source_checkout(self, tmp_path):
|
||||
project_root = tmp_path / "spec-kit"
|
||||
project_root.mkdir()
|
||||
(project_root / ".git").mkdir()
|
||||
|
||||
class FakeDist:
|
||||
files = []
|
||||
|
||||
def read_text(self, name):
|
||||
if name == "direct_url.json":
|
||||
return json.dumps(
|
||||
{
|
||||
"dir_info": {"editable": True},
|
||||
"url": project_root.as_uri(),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
def locate_file(self, file):
|
||||
return file
|
||||
|
||||
with patch("importlib.metadata.distribution", return_value=FakeDist()):
|
||||
assert specify_cli._version._editable_marker_seen() is True
|
||||
assert specify_cli._version._source_checkout_path() == project_root.resolve()
|
||||
|
||||
def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path):
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / ".git").mkdir()
|
||||
venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py"
|
||||
venv_file.parent.mkdir(parents=True)
|
||||
venv_file.write_text("# installed module\n")
|
||||
|
||||
class FakeDist:
|
||||
files = ["specify_cli.py"]
|
||||
|
||||
def read_text(self, name):
|
||||
return None
|
||||
|
||||
def locate_file(self, file):
|
||||
return venv_file
|
||||
|
||||
with patch("importlib.metadata.distribution", return_value=FakeDist()):
|
||||
assert specify_cli._version._editable_marker_seen() is False
|
||||
|
||||
|
||||
class TestTagValidationWhitespace:
|
||||
def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.8.0\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "v0.8.0" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestArgvAssemblyPipx:
|
||||
"""pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`."""
|
||||
|
||||
def test_pipx_argv_uses_install_force_positional_not_upgrade(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="pipx"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6")
|
||||
assert argv == [
|
||||
"pipx",
|
||||
"install",
|
||||
"--force",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
]
|
||||
assert "upgrade" not in argv # pipx upgrade does not accept arbitrary refs
|
||||
assert "--spec" not in argv # pipx 1.5+ dropped the --spec flag
|
||||
|
||||
def test_missing_pipx_returns_no_installer_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None):
|
||||
assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None
|
||||
|
||||
|
||||
class TestBareUpgradePipx:
|
||||
"""pipx happy path."""
|
||||
|
||||
def test_happy_path(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "via pipx:" in out
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
|
||||
|
||||
|
||||
class TestDetectionShortCircuit:
|
||||
"""Tier-1 path-prefix matches short-circuit before registry checks."""
|
||||
|
||||
def test_pipx_argv0_prefix_short_circuits_before_registry_checks(
|
||||
self,
|
||||
pipx_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.PIPX
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestDryRunPipx:
|
||||
def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "Detected install method: pipx" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
542
tests/test_self_upgrade_execution.py
Normal file
542
tests/test_self_upgrade_execution.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""Installer execution, verification, and error-path tests for `specify self upgrade`."""
|
||||
|
||||
import errno
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
_completed_process,
|
||||
mock_urlopen_response,
|
||||
requires_posix,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 6 — User Story 4: failure recovery (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInstallerMissing:
|
||||
"""Installer disappeared between detection and run → exit 3."""
|
||||
|
||||
def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ):
|
||||
which_results = {"specify": "/usr/local/bin/specify"}
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n)
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert "Installer uv not found on PATH; reinstall it and retry." in out
|
||||
assert "Upgrading specify-cli" not in out
|
||||
|
||||
def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ):
|
||||
which_results = {}
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n)
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert "Installer pipx not found on PATH" in strip_ansi(result.output)
|
||||
|
||||
def test_absolute_installer_path_does_not_require_path_lookup(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._verify_upgrade", return_value="0.7.6"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(0)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_does_not_require_path_lookup(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "uv"
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._verify_upgrade", return_value="0.7.6"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(0)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args.args[0][0] == "./uv"
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_missing_gets_path_specific_message(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
"Installer path ./uv no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
assert "not found on PATH" not in strip_ansi(result.output)
|
||||
|
||||
def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
fake_uv.unlink()
|
||||
raise FileNotFoundError(str(fake_uv))
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: str(fake_uv) if name == "uv" else None,
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
def test_absolute_installer_path_not_executable_gets_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o644)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.os.access", return_value=False), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} is not an executable file; fix the path or reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_not_executable_gets_path_specific_message(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "uv"
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o644)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.os.access", return_value=False), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
out = strip_ansi(result.output)
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
"Installer path ./uv is not an executable file; fix the path or reinstall it and retry."
|
||||
in out
|
||||
)
|
||||
assert "Installer ./uv is not executable" not in out
|
||||
|
||||
def test_real_installer_exit_126_is_not_treated_as_invalid_path(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(126)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 126
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 126." in out
|
||||
assert "not an executable file" not in out
|
||||
|
||||
def test_absolute_installer_path_missing_gets_path_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "missing-installer" / "uv"
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_exec_oserror_is_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run",
|
||||
side_effect=PermissionError("Permission denied"),
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert f"Installer path {fake_uv} is not an executable file" in out
|
||||
assert "not found on PATH" not in out
|
||||
|
||||
def test_bare_invalid_installer_message_does_not_call_it_a_path(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run",
|
||||
side_effect=PermissionError("Permission denied"),
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert "Installer uv is not executable" in out
|
||||
assert "Installer path uv" not in out
|
||||
|
||||
def test_exec_oserror_errno_is_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
invalid_error = OSError(errno.ENOEXEC, "Exec format error")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=invalid_error):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert f"Installer path {fake_uv} is not an executable file" in out
|
||||
assert "not found on PATH" not in out
|
||||
|
||||
def test_transient_exec_oserror_is_not_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
transient_error = OSError(errno.EMFILE, "Too many open files")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=transient_error):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
# Transient/unknown OSErrors are re-raised rather than mapped to the
|
||||
# invalid-installer exit 3, so the CLI surfaces them as an uncaught
|
||||
# error: exit code 1 with the original OSError preserved.
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, OSError)
|
||||
|
||||
|
||||
class TestInstallerFailed:
|
||||
"""Installer non-zero exit → propagate code, print rollback hint."""
|
||||
|
||||
def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)] # installer fails
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 2." in out
|
||||
assert "Try again or run the command manually:" in out
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.6" in out
|
||||
assert (
|
||||
"To pin back to the previous version: "
|
||||
"uv tool install specify-cli --force --from "
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
# No verification attempted after a failed installer run.
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(127)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 127
|
||||
|
||||
def test_installer_timeout_prints_timeout_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
subprocess.TimeoutExpired(cmd=["uv"], timeout=12)
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 124
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade timed out while waiting for the installer subprocess." in out
|
||||
assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out
|
||||
|
||||
def test_non_finite_timeout_warns_and_runs_without_timeout(
|
||||
self, uv_tool_argv0, clean_environ, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS='nan'" in strip_ansi(
|
||||
result.output
|
||||
)
|
||||
assert mock_run.call_args_list[0].kwargs["timeout"] is None
|
||||
|
||||
def test_real_installer_exit_124_is_not_treated_as_timeout(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(124)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 124
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 124." in out
|
||||
assert "Upgrade timed out while waiting for the installer subprocess." not in out
|
||||
|
||||
def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert (
|
||||
"To pin back to the previous version: pipx install --force "
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
|
||||
def test_rollback_hint_accepts_normalizable_stable_snapshot(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="v0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert (
|
||||
"To pin back to the previous version: uv tool install specify-cli --force "
|
||||
"--from git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
assert "Previous version was not an exact stable release tag" not in out
|
||||
|
||||
def test_prerelease_failure_degrades_rollback_hint_to_releases_page(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Previous version was not an exact stable release tag" in out
|
||||
assert "https://github.com/github/spec-kit/releases" in out
|
||||
assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out
|
||||
184
tests/test_self_upgrade_guidance.py
Normal file
184
tests/test_self_upgrade_guidance.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Non-upgradable path guidance tests for `specify self upgrade`."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
mock_urlopen_response,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 5 — User Story 3: non-upgradable path guidance (P3)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestUvxEphemeral:
|
||||
"""uvx ephemeral path emits exact one-liner, no installer call."""
|
||||
|
||||
def test_uvx_argv0_prints_exact_one_liner_and_exits_zero(
|
||||
self,
|
||||
uvx_ephemeral_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
expected = (
|
||||
"Running via uvx (ephemeral); the next uvx invocation already "
|
||||
"resolves to latest — no upgrade action needed."
|
||||
)
|
||||
assert expected in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_offline_still_exits_zero_without_tag_resolution(
|
||||
self,
|
||||
uvx_ephemeral_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=AssertionError("non-upgradable uvx path must not hit network"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
assert "uvx (ephemeral)" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestSourceCheckout:
|
||||
"""Editable install path emits git pull guidance."""
|
||||
|
||||
def test_source_checkout_prints_git_pull_guidance(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
tmp_path,
|
||||
clean_environ,
|
||||
):
|
||||
fake_tree = tmp_path / "worktree"
|
||||
fake_tree.mkdir()
|
||||
(fake_tree / ".git").mkdir()
|
||||
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
|
||||
"specify_cli._version._source_checkout_path", return_value=fake_tree
|
||||
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert f"Running from a source checkout at {fake_tree}" in out
|
||||
assert "git pull" in out
|
||||
assert "pip install -e ." in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_source_checkout_without_path_mentions_checkout_directory(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
|
||||
"specify_cli._version._source_checkout_path", return_value=None
|
||||
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
out = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "checkout path could not be detected" in out
|
||||
assert "from your checkout directory" in out
|
||||
assert "(path unavailable)" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
|
||||
class TestUnsupported:
|
||||
"""Unsupported path enumerates manual reinstall commands."""
|
||||
|
||||
def test_unsupported_prints_both_reinstall_commands(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Could not identify your install method automatically" in out
|
||||
assert (
|
||||
"uv tool install specify-cli --force --from "
|
||||
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
||||
) in out
|
||||
assert (
|
||||
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
||||
in out
|
||||
)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_unsupported_offline_degrades_to_placeholder_manual_commands(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=AssertionError("unsupported guidance should not require network"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Could not identify your install method automatically" in out
|
||||
assert (
|
||||
"uv tool install specify-cli --force --from "
|
||||
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
||||
) in out
|
||||
assert (
|
||||
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
||||
in out
|
||||
)
|
||||
|
||||
|
||||
class TestDryRunNonUpgradablePaths:
|
||||
"""--dry-run on non-upgradable paths emits guidance, not preview."""
|
||||
|
||||
def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview(
|
||||
self,
|
||||
uvx_ephemeral_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Dry run — no changes will be made." not in out
|
||||
assert "uvx (ephemeral)" in out
|
||||
|
||||
def test_dry_run_on_unsupported_emits_manual_commands(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "Could not identify your install method" in strip_ansi(result.output)
|
||||
649
tests/test_self_upgrade_verification.py
Normal file
649
tests/test_self_upgrade_verification.py
Normal file
@@ -0,0 +1,649 @@
|
||||
"""Verification, resolution, and validation tests for `specify self upgrade`."""
|
||||
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import specify_cli
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
SENTINEL_GH_TOKEN,
|
||||
SENTINEL_GITHUB_TOKEN,
|
||||
_InstallMethod,
|
||||
_UpgradePlan,
|
||||
_completed_process,
|
||||
_verify_upgrade,
|
||||
mock_urlopen_response,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 6 — User Story 4: failure recovery (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestVerificationMismatch:
|
||||
"""Installer says 0 but the binary is still the old version → exit 2."""
|
||||
|
||||
def test_installer_ok_but_verify_returns_old_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0), # installer OK
|
||||
_completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD!
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "resolves to 0.7.5 (expected v0.7.6)" in out
|
||||
assert "The new version may take effect on your next invocation." in out
|
||||
|
||||
def test_verify_nonzero_exit_is_not_treated_as_success(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(1, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "(unknown) (expected v0.7.6)" in out
|
||||
|
||||
def test_verify_accepts_pep440_equivalent_rc_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.9.0"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 1.0.0rc1\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_accepts_specify_cli_binary_name_in_version_output(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify-cli version 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_accepts_capitalized_binary_name_in_version_output(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="Specify, version 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_rejects_output_without_parseable_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify version unknown\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "(unknown) (expected v0.7.6)" in out
|
||||
|
||||
def test_verify_uses_current_entrypoint_when_not_on_path(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
assert uv_tool_argv0.exists()
|
||||
assert uv_tool_argv0.is_file()
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == str(uv_tool_argv0)
|
||||
assert mock_run.call_args.kwargs["timeout"] == specify_cli._version._VERIFY_TIMEOUT_SECS
|
||||
|
||||
def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None,
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version.os.access", return_value=False
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
|
||||
|
||||
def test_verify_ignores_python_entrypoint_and_falls_back_to_specify(
|
||||
self,
|
||||
clean_environ,
|
||||
tmp_path,
|
||||
):
|
||||
fake_python = tmp_path / "python3"
|
||||
fake_python.write_text("#!/bin/sh\n")
|
||||
fake_python.chmod(0o755)
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version.sys.argv", [str(fake_python)]
|
||||
), patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
|
||||
|
||||
def test_verify_accepts_specify_cli_named_current_entrypoint(
|
||||
self,
|
||||
clean_environ,
|
||||
tmp_path,
|
||||
):
|
||||
fake_specify_cli = tmp_path / "specify-cli"
|
||||
fake_specify_cli.write_text("#!/bin/sh\n")
|
||||
fake_specify_cli.chmod(0o755)
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == str(fake_specify_cli)
|
||||
|
||||
|
||||
class TestResolutionFailures:
|
||||
"""Pre-installer resolution failure → exit 1, reusing the resolver category strings."""
|
||||
|
||||
def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ):
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("nope"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output)
|
||||
|
||||
def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=403,
|
||||
msg="rate limited",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert (
|
||||
"Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
def test_http_500_exits_1(self, uv_tool_argv0, clean_environ):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=500,
|
||||
msg="srv err",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code, expected",
|
||||
[
|
||||
# 429 (Too Many Requests / secondary rate limit) gets the same
|
||||
# actionable token hint as 403; other statuses surface verbatim.
|
||||
(
|
||||
429,
|
||||
"Upgrade aborted: rate limited (configure ~/.specify/auth.json "
|
||||
"with a GitHub token)",
|
||||
),
|
||||
(404, "Upgrade aborted: HTTP 404"),
|
||||
(502, "Upgrade aborted: HTTP 502"),
|
||||
],
|
||||
)
|
||||
def test_http_error_categorization(
|
||||
self, code, expected, uv_tool_argv0, clean_environ
|
||||
):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=code,
|
||||
msg="err",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert expected in strip_ansi(result.output)
|
||||
|
||||
def test_unparseable_resolved_release_tag_exits_1_without_traceback(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
out = strip_ansi(result.output)
|
||||
assert "resolved release tag is not a comparable version" in out
|
||||
assert "release-main" not in out
|
||||
assert "Traceback" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
|
||||
class TestTagValidation:
|
||||
"""--tag regex enforcement."""
|
||||
|
||||
def test_valid_stable_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.7.6"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0.dev0" in strip_ansi(result.output)
|
||||
|
||||
def test_valid_rc_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0b1"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--tag", "v1.0.0-beta.1"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Already on requested release: v1.0.0-beta.1" in strip_ansi(
|
||||
result.output
|
||||
)
|
||||
|
||||
def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0+build.42" in strip_ansi(result.output)
|
||||
|
||||
def test_uppercase_v_prefix_is_folded_to_lowercase(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# A pasted uppercase `V` prefix is accepted and normalized to `v` so
|
||||
# the git ref matches the canonical lowercase release tag.
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "V0.7.6"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_valid_prerelease_with_build_metadata_tag(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# Prerelease and build-metadata suffixes compose (PEP 440 / semver).
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_tag",
|
||||
[
|
||||
"latest",
|
||||
"0.7.5",
|
||||
"main",
|
||||
"v7",
|
||||
"",
|
||||
"v1.2.3abc",
|
||||
"v1.2.3...",
|
||||
"v1.2.3++",
|
||||
"v\uff11.2.3",
|
||||
"v1.\u0662.3",
|
||||
],
|
||||
)
|
||||
def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ):
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag])
|
||||
assert result.exit_code == 1
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
|
||||
|
||||
|
||||
class TestUnknownCurrent:
|
||||
"""'unknown' current version renders literally in notice and success message."""
|
||||
|
||||
def test_unknown_current_renders_literal_in_notice(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="unknown"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out
|
||||
assert "Upgraded specify-cli: unknown → 0.7.6" in out
|
||||
|
||||
def test_unknown_current_rollback_hint_degrades(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="unknown"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)] # installer fails
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Could not determine the previous version" in out
|
||||
assert "https://github.com/github/spec-kit/releases" in out
|
||||
|
||||
|
||||
class TestTokenScrubbing:
|
||||
"""GH_TOKEN / GITHUB_TOKEN are stripped from every child env."""
|
||||
|
||||
def test_env_passed_to_subprocess_has_no_github_tokens(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
response = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener"
|
||||
) as mock_build_opener, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = response
|
||||
mock_build_opener.return_value.open.return_value = response
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert mock_run.call_count >= 1
|
||||
for call in mock_run.call_args_list:
|
||||
env_kwarg = call.kwargs.get("env") or {}
|
||||
assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}"
|
||||
assert "GITHUB_TOKEN" not in env_kwarg
|
||||
for v in env_kwarg.values():
|
||||
assert SENTINEL_GH_TOKEN not in v
|
||||
assert SENTINEL_GITHUB_TOKEN not in v
|
||||
|
||||
def test_env_scrubbing_is_case_insensitive(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN)
|
||||
response = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener"
|
||||
) as mock_build_opener, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = response
|
||||
mock_build_opener.return_value.open.return_value = response
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert mock_run.call_count >= 1
|
||||
for call in mock_run.call_args_list:
|
||||
env_kwarg = call.kwargs.get("env") or {}
|
||||
assert "gh_token" not in env_kwarg
|
||||
assert "GitHub_Token" not in env_kwarg
|
||||
for v in env_kwarg.values():
|
||||
assert SENTINEL_GH_TOKEN not in v
|
||||
assert SENTINEL_GITHUB_TOKEN not in v
|
||||
|
||||
def test_env_scrubbing_removes_github_token_variants(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_PAT", "gh-pat")
|
||||
monkeypatch.setenv("GH_TOKEN_FILE", "gh-token-file")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key")
|
||||
monkeypatch.setenv("GITHUB_PAT", "github-pat")
|
||||
monkeypatch.setenv("GITHUB_TOKEN_PATH", "github-token-path")
|
||||
monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github")
|
||||
monkeypatch.setenv("GITHUB_API_TOKEN", "api-token")
|
||||
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key")
|
||||
monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret")
|
||||
monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token")
|
||||
monkeypatch.setenv("NOTGITHUB_TOKEN", "not-github-kept")
|
||||
monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept")
|
||||
monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept")
|
||||
monkeypatch.setenv("UNRELATED_TOKEN", "kept")
|
||||
|
||||
env = specify_cli._version._scrubbed_env()
|
||||
|
||||
assert "GH_PAT" not in env
|
||||
assert "GH_TOKEN_FILE" not in env
|
||||
assert "GH_ENTERPRISE_TOKEN" not in env
|
||||
assert "GH_ENTERPRISE_SECRET" not in env
|
||||
assert "GH_ENTERPRISE_PRIVATE_KEY" not in env
|
||||
assert "GITHUB_PAT" not in env
|
||||
assert "GITHUB_TOKEN_PATH" not in env
|
||||
assert "GITHUB_ENTERPRISE_TOKEN" not in env
|
||||
assert "GITHUB_API_TOKEN" not in env
|
||||
assert "GITHUB_APP_PRIVATE_KEY" not in env
|
||||
assert "GITHUB_OAUTH_CLIENT_SECRET" not in env
|
||||
assert "HOMEBREW_GITHUB_API_TOKEN" not in env
|
||||
assert env["NOTGITHUB_TOKEN"] == "not-github-kept"
|
||||
assert env["GHOST_API_TOKEN"] == "ghost-kept"
|
||||
assert env["GHIDRA_API_KEY"] == "ghidra-kept"
|
||||
assert env["UNRELATED_TOKEN"] == "kept"
|
||||
|
||||
def test_env_scrubbing_strips_noncredential_github_vars_by_design(
|
||||
self, monkeypatch
|
||||
):
|
||||
# The scrub is intentionally broad: every GH_/GITHUB_-prefixed name is
|
||||
# removed from the installer subprocess env, including non-credential
|
||||
# context vars. This is a deliberate fail-safe so credential-adjacent
|
||||
# names that lack a recognized suffix (e.g. GH_TOKEN_FILE,
|
||||
# GITHUB_TOKEN_PATH, asserted above) can never leak. The installer
|
||||
# (`uv tool install` / `pipx install` of a public package) does not
|
||||
# consume routing/context vars like GITHUB_REPOSITORY, so nothing the
|
||||
# subprocess needs is lost by stripping them.
|
||||
monkeypatch.setenv("GH_HOST", "github.example.com")
|
||||
monkeypatch.setenv("GH_CONFIG_DIR", "/home/u/.config/gh")
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY", "github/spec-kit")
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", "/home/runner/work")
|
||||
monkeypatch.setenv("GITHUB_USER", "octocat")
|
||||
|
||||
env = specify_cli._version._scrubbed_env()
|
||||
|
||||
assert "GH_HOST" not in env
|
||||
assert "GH_CONFIG_DIR" not in env
|
||||
assert "GITHUB_REPOSITORY" not in env
|
||||
assert "GITHUB_WORKSPACE" not in env
|
||||
assert "GITHUB_USER" not in env
|
||||
@@ -13,8 +13,10 @@ from tests.conftest import requires_bash
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
|
||||
CHECK_PREREQ_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
@@ -30,6 +32,7 @@ def _install_bash_scripts(repo: Path) -> None:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, d / "common.sh")
|
||||
shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh")
|
||||
shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.sh")
|
||||
|
||||
|
||||
def _install_ps_scripts(repo: Path) -> None:
|
||||
@@ -37,6 +40,7 @@ def _install_ps_scripts(repo: Path) -> None:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, d / "common.ps1")
|
||||
shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1")
|
||||
shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.ps1")
|
||||
|
||||
|
||||
def _install_core_tasks_template(repo: Path) -> None:
|
||||
@@ -57,6 +61,25 @@ def _minimal_feature(repo: Path) -> Path:
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
return feat
|
||||
|
||||
|
||||
def _write_integration_state(repo: Path, integration: str = "claude", separator: str = "-") -> None:
|
||||
specify_dir = repo / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
state = {
|
||||
"integration": integration,
|
||||
"default_integration": integration,
|
||||
"installed_integrations": [integration],
|
||||
"integration_settings": {
|
||||
integration: {
|
||||
"invoke_separator": separator,
|
||||
},
|
||||
},
|
||||
}
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps(state),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
@@ -71,6 +94,38 @@ def _clean_env() -> dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
|
||||
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
return subprocess.run(
|
||||
["bash", "-c", 'source "$1"; format_speckit_command "$2" "$PWD"', "bash", str(script), command_name],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
|
||||
script = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
return subprocess.run(
|
||||
[
|
||||
exe,
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
'& { param($common, $commandName) . $common; Format-SpecKitCommand -CommandName $commandName -RepoRoot (Get-Location).Path }',
|
||||
str(script),
|
||||
command_name,
|
||||
],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
@@ -123,7 +178,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
|
||||
setup-tasks.sh --json should exit 0 and return an absolute, existing
|
||||
TASKS_TEMPLATE path pointing to the core template.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
@@ -150,7 +205,7 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
|
||||
When an override exists at .specify/templates/overrides/tasks-template.md,
|
||||
setup-tasks.sh --json must return the override path, not the core path.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
# Create the override
|
||||
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
|
||||
@@ -187,7 +242,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
|
||||
When an extension template exists, setup-tasks.sh --json must resolve
|
||||
tasks-template.md from the extension before falling back to the core path.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
|
||||
extension_dir = (
|
||||
@@ -225,7 +280,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
|
||||
When both preset and extension templates exist, setup-tasks.sh --json must
|
||||
resolve the preset path because presets outrank extensions.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
|
||||
extension_dir = (
|
||||
@@ -269,7 +324,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
When two presets both provide tasks-template.md, the one listed first in
|
||||
.specify/presets/.registry wins.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
# resolve_template reads .specify/presets/.registry as a JSON object with a
|
||||
# "presets" map where each entry has a numeric "priority" (lower = higher
|
||||
@@ -329,7 +384,7 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
|
||||
When tasks-template.md is absent from all locations, setup-tasks.sh must
|
||||
exit non-zero and print a helpful ERROR message to stderr.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
# Remove the core template so no template exists anywhere
|
||||
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
|
||||
@@ -345,12 +400,138 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "ERROR" in result.stderr
|
||||
assert "tasks-template" in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None:
|
||||
integration_json = tasks_repo / ".specify" / "integration.json"
|
||||
if integration_json.exists():
|
||||
integration_json.unlink()
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.plan"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_rejects_invalid_invoke_separator(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "/")
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.plan"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_normalizes_mixed_separators(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "copilot", ".")
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "/speckit-git.commit")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.git.commit"
|
||||
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "speckit.git-commit")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit-git-commit"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "copilot", ".")
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "speckit.jira.sync-status")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
dot_state = {
|
||||
"integration": "copilot",
|
||||
"default_integration": "copilot",
|
||||
"installed_integrations": ["copilot"],
|
||||
"integration_settings": {"copilot": {"invoke_separator": "."}},
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
'source "$1"; format_speckit_command plan "$PWD"; printf "%s" "$2" > .specify/integration.json; format_speckit_command tasks "$PWD"',
|
||||
"bash",
|
||||
str(script),
|
||||
json.dumps(dot_state),
|
||||
],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == ["/speckit-plan", "/speckit-tasks"]
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Run /speckit-plan first" in result.stderr
|
||||
assert "/speckit.plan" not in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_check_prerequisites_bash_uses_invoke_separator_in_tasks_hint(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--require-tasks"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Run /speckit-tasks first" in result.stderr
|
||||
assert "/speckit.tasks" not in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
tasks_repo: Path,
|
||||
@@ -413,11 +594,10 @@ def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# POWERSHELL TESTS
|
||||
# ===========================================================================
|
||||
@@ -429,7 +609,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
setup-tasks.ps1 -Json should exit 0 and return an absolute, existing
|
||||
TASKS_TEMPLATE path.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
@@ -457,7 +637,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
When an override exists at .specify/templates/overrides/tasks-template.md,
|
||||
setup-tasks.ps1 -Json must return the override path, not the core path.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -493,7 +673,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
When tasks-template.md is absent from all locations, setup-tasks.ps1 must
|
||||
exit non-zero and write a helpful error to stderr.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
|
||||
core.unlink()
|
||||
@@ -514,6 +694,87 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_normalizes_mixed_separators(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
_write_integration_state(tasks_repo, "copilot", ".")
|
||||
|
||||
result = _run_powershell_format_command(tasks_repo, "/speckit-git.commit")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.git.commit"
|
||||
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
|
||||
result = _run_powershell_format_command(tasks_repo, "speckit.git-commit")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit-git-commit"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_preserves_hyphens_inside_segments(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
_write_integration_state(tasks_repo, "copilot", ".")
|
||||
|
||||
result = _run_powershell_format_command(tasks_repo, "speckit.jira.sync-status")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
output = result.stderr + result.stdout
|
||||
assert result.returncode != 0
|
||||
assert "Run /speckit-plan first" in output
|
||||
assert "/speckit.plan" not in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-RequireTasks"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
output = result.stderr + result.stdout
|
||||
assert result.returncode != 0
|
||||
assert "Run /speckit-tasks first" in output
|
||||
assert "/speckit.tasks" not in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
tasks_repo: Path,
|
||||
@@ -581,4 +842,3 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
|
||||
@@ -923,7 +923,7 @@ class TestDryRun:
|
||||
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
|
||||
# Verify no side effects
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", f"*ts-feat*"],
|
||||
["git", "branch", "--list", "*ts-feat*"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Tests for the `specify self` sub-app (`self check` and `self upgrade`).
|
||||
|
||||
Network isolation contract (SC-004 / FR-014): every test that exercises
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock
|
||||
`urllib.request.urlopen` so no real outbound call ever reaches
|
||||
api.github.com. The `self upgrade` stub tests do not need that patch because
|
||||
the stub is contractually network-free. Run this module under `pytest-socket`
|
||||
(if installed) with `--disable-socket` as an extra safety net.
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound
|
||||
urllib path it expects (`urlopen` for unauthenticated requests, `build_opener`
|
||||
for authenticated requests) so no real outbound call ever reaches api.github.com.
|
||||
Tests for non-network `self upgrade` behavior should keep that contract explicit
|
||||
with local mocks. Run this module under `pytest-socket` (if installed) with
|
||||
`--disable-socket` as an extra safety net.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import importlib.metadata
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -24,6 +24,7 @@ from specify_cli._version import (
|
||||
_normalize_tag,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -35,16 +36,6 @@ _RATE_LIMITED_REASON = (
|
||||
)
|
||||
|
||||
|
||||
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
|
||||
|
||||
def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
||||
return urllib.error.HTTPError(
|
||||
url="https://api.github.com/repos/github/spec-kit/releases/latest",
|
||||
@@ -55,39 +46,6 @@ def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
||||
)
|
||||
|
||||
|
||||
class TestSelfUpgradeStub:
|
||||
"""Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016)."""
|
||||
|
||||
def test_prints_exactly_three_lines_and_exits_zero(self):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
lines = strip_ansi(result.output).strip().splitlines()
|
||||
assert lines == [
|
||||
"specify self upgrade is not implemented yet.",
|
||||
"Run 'specify self check' to see whether a newer release is available.",
|
||||
"Actual self-upgrade is planned as follow-up work.",
|
||||
]
|
||||
|
||||
def test_stub_makes_no_network_call(self):
|
||||
# The stub must not hit the network via either urllib path:
|
||||
# unauthenticated requests use urlopen() directly; authenticated ones
|
||||
# go through build_opener(...).open(). Both are patched so that any
|
||||
# accidental network call raises immediately.
|
||||
network_error = AssertionError("stub must not hit the network")
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=network_error,
|
||||
),
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
side_effect=network_error,
|
||||
),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestIsNewer:
|
||||
def test_latest_strictly_greater_returns_true(self):
|
||||
assert _is_newer("0.8.0", "0.7.4") is True
|
||||
@@ -151,7 +109,7 @@ class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -164,7 +122,7 @@ class TestUserStory1:
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -176,7 +134,7 @@ class TestUserStory1:
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -187,26 +145,46 @@ class TestUserStory1:
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Current version could not be determined" in output
|
||||
assert "Latest release: v0.7.4" in output
|
||||
assert "0.7.4" in output
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
assert "specify self upgrade" in output
|
||||
assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0;echo unsafe"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Latest release: vX.Y.Z" in output
|
||||
assert "Could not validate latest release tag from GitHub." in output
|
||||
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
|
||||
assert "v0.9.0;echo unsafe" not in output
|
||||
|
||||
def test_unparseable_tag_reports_validation_failure_without_raw_tag(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" not in output
|
||||
assert "Up to date" in output
|
||||
assert "Up to date" not in output
|
||||
assert "Could not validate latest release tag from GitHub." in output
|
||||
assert "Latest release: vX.Y.Z" in output
|
||||
assert "0.7.4" in output
|
||||
assert "not-a-version" not in output
|
||||
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
|
||||
|
||||
|
||||
class TestFailureCategorization:
|
||||
@@ -306,13 +284,25 @@ class TestUserStory2:
|
||||
def _capture_request_via_urlopen():
|
||||
captured = {}
|
||||
|
||||
def _side_effect(req, timeout=None):
|
||||
def _side_effect(req, *args, **kwargs):
|
||||
captured["request"] = req
|
||||
return _mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
return mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
|
||||
return captured, _side_effect
|
||||
|
||||
|
||||
def _capture_request_via_auth_opener():
|
||||
captured = {}
|
||||
|
||||
def _side_effect(req, *args, **kwargs):
|
||||
captured["request"] = req
|
||||
return mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = _side_effect
|
||||
return captured, opener
|
||||
|
||||
|
||||
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
@@ -323,10 +313,11 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||
@@ -335,10 +326,11 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -376,10 +368,11 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Regression guard: utility and asset symbols importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
run_command, check_tool, is_git_repo, init_git_repo,
|
||||
handle_vscode_settings, merge_json_files,
|
||||
check_tool, is_git_repo, merge_json_files,
|
||||
get_speckit_version,
|
||||
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ def test_version_symbols_available_from_star_import():
|
||||
|
||||
def test_version_module_symbols_directly_importable():
|
||||
from specify_cli._version import (
|
||||
GITHUB_API_LATEST,
|
||||
_fetch_latest_release_tag,
|
||||
_get_installed_version,
|
||||
_is_newer,
|
||||
|
||||
@@ -2379,6 +2379,306 @@ steps:
|
||||
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
|
||||
|
||||
|
||||
# ===== continue_on_error Tests =====
|
||||
#
|
||||
# Locks the contract documented in workflows/README.md "Error Handling"
|
||||
# section: when a step returns `StepResult(status=StepStatus.FAILED, ...)` and
|
||||
# `continue_on_error: true` is declared, the engine records the step's
|
||||
# `output` (with `exit_code` and `stderr` from the failure) and its
|
||||
# `status` (sibling key on `steps.<id>`, not nested under `output`)
|
||||
# and continues to the next sibling step instead of halting the run.
|
||||
# Gate aborts (`output.aborted`) still halt regardless of the flag.
|
||||
# Unhandled exceptions raised out of `step_impl.execute()` are out of
|
||||
# scope for this flag — they propagate to `WorkflowEngine.execute()`
|
||||
# and abort the run.
|
||||
|
||||
|
||||
class TestContinueOnError:
|
||||
"""Test the `continue_on_error` step-level field."""
|
||||
|
||||
def test_undeclared_failure_halts_run(self, project_dir):
|
||||
"""Default behaviour (no `continue_on_error`): a failing step
|
||||
halts the workflow run with `status == StepStatus.FAILED`.
|
||||
|
||||
Locks the byte-equivalent default — workflows that do not
|
||||
declare the flag must behave exactly as before this feature.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "halt-on-fail"
|
||||
name: "Halt On Fail"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: fail-step
|
||||
type: shell
|
||||
run: "exit 7"
|
||||
- id: after
|
||||
type: shell
|
||||
run: "echo should-not-run"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.FAILED
|
||||
assert "fail-step" in state.step_results
|
||||
assert state.step_results["fail-step"]["output"]["exit_code"] == 7
|
||||
# Subsequent step never executes when the flag is absent.
|
||||
assert "after" not in state.step_results
|
||||
|
||||
def test_declared_and_fired_continues_run(self, project_dir):
|
||||
"""`continue_on_error: true` + failing step: the run keeps
|
||||
going, the failed step's result is recorded, and the
|
||||
downstream step runs.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "continue-past-fail"
|
||||
name: "Continue Past Fail"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: flaky-step
|
||||
type: shell
|
||||
run: "exit 42"
|
||||
continue_on_error: true
|
||||
- id: after
|
||||
type: shell
|
||||
run: "echo did-run"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
# Failed step's exit_code is preserved so downstream branching
|
||||
# can inspect it.
|
||||
assert state.step_results["flaky-step"]["output"]["exit_code"] == 42
|
||||
assert state.step_results["flaky-step"]["status"] == "failed"
|
||||
# Downstream step ran successfully.
|
||||
assert state.step_results["after"]["output"]["exit_code"] == 0
|
||||
|
||||
def test_declared_but_step_succeeded_is_noop(self, project_dir):
|
||||
"""`continue_on_error: true` on a step that succeeds is a
|
||||
no-op — the flag only changes behaviour on StepStatus.FAILED status.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "flag-but-success"
|
||||
name: "Flag But Success"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: ok-step
|
||||
type: shell
|
||||
run: "echo ok"
|
||||
continue_on_error: true
|
||||
- id: after
|
||||
type: shell
|
||||
run: "echo done"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
assert state.step_results["ok-step"]["status"] == "completed"
|
||||
assert state.step_results["ok-step"]["output"]["exit_code"] == 0
|
||||
assert state.step_results["after"]["output"]["exit_code"] == 0
|
||||
|
||||
def test_if_branch_routes_around_failure(self, project_dir):
|
||||
"""End-to-end: `continue_on_error` + `if` cleanly routes around
|
||||
a failure. The recovery branch runs; the success branch does
|
||||
not.
|
||||
|
||||
Mirrors the canonical usage pattern from the original feature
|
||||
discussion in issue #2591.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "route-around"
|
||||
name: "Route Around Failure"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: heavy-thing
|
||||
type: shell
|
||||
run: "exit 1"
|
||||
continue_on_error: true
|
||||
- id: check-result
|
||||
type: if
|
||||
condition: "{{ steps.heavy-thing.output.exit_code != 0 }}"
|
||||
then:
|
||||
- id: recovery
|
||||
type: shell
|
||||
run: "echo recovery-ran"
|
||||
else:
|
||||
- id: happy-path
|
||||
type: shell
|
||||
run: "echo happy-path-ran"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
assert "recovery" in state.step_results
|
||||
assert "happy-path" not in state.step_results
|
||||
|
||||
def test_gate_abort_still_halts_with_continue_on_error(
|
||||
self, project_dir, monkeypatch
|
||||
):
|
||||
"""`continue_on_error` does NOT override a deliberate gate
|
||||
abort. `output.aborted` always halts the run with
|
||||
`status == ABORTED`.
|
||||
|
||||
Aborts are explicit operator decisions; continue_on_error
|
||||
is for transient/expected step failures only.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
from specify_cli.workflows.steps import gate as gate_module
|
||||
|
||||
# Force the gate step into interactive mode and feed a "reject"
|
||||
# choice so the abort path actually runs in the test env
|
||||
# (default behaviour returns StepStatus.PAUSED when stdin is not a TTY).
|
||||
# Swap sys.stdin itself for a stub: setattr on the real
|
||||
# TextIOWrapper's `isatty` method is not assignable under some
|
||||
# runners (e.g. pytest with capture disabled).
|
||||
class _TTYStdin:
|
||||
def isatty(self) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(gate_module.sys, "stdin", _TTYStdin())
|
||||
monkeypatch.setattr(
|
||||
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "reject")
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "gate-abort-halts"
|
||||
name: "Gate Abort Halts"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: gate-step
|
||||
type: gate
|
||||
message: "Approve?"
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
continue_on_error: true
|
||||
- id: should-not-run
|
||||
type: shell
|
||||
run: "echo nope"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.ABORTED
|
||||
assert "should-not-run" not in state.step_results
|
||||
|
||||
def test_validation_rejects_non_bool_continue_on_error(self):
|
||||
"""`continue_on_error` must be a literal boolean; coerced
|
||||
strings like `"true"` are rejected at validation time so
|
||||
authoring mistakes surface before execution.
|
||||
"""
|
||||
from specify_cli.workflows.engine import (
|
||||
WorkflowDefinition,
|
||||
validate_workflow,
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "bad-coe"
|
||||
name: "Bad COE"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: step-one
|
||||
type: shell
|
||||
run: "true"
|
||||
continue_on_error: "true"
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any(
|
||||
"continue_on_error" in e and "boolean" in e for e in errors
|
||||
), errors
|
||||
|
||||
def test_validation_accepts_bool_continue_on_error(self):
|
||||
"""Boolean values pass validation cleanly."""
|
||||
from specify_cli.workflows.engine import (
|
||||
WorkflowDefinition,
|
||||
validate_workflow,
|
||||
)
|
||||
|
||||
for value in (True, False):
|
||||
yaml_value = "true" if value else "false"
|
||||
definition = WorkflowDefinition.from_string(f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "good-coe"
|
||||
name: "Good COE"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: step-one
|
||||
type: shell
|
||||
run: "true"
|
||||
continue_on_error: {yaml_value}
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert errors == [], errors
|
||||
|
||||
def test_engine_ignores_truthy_non_bool_continue_on_error(self, project_dir):
|
||||
"""Defense-in-depth: even if a caller bypasses
|
||||
`validate_workflow()` and feeds the engine a definition with
|
||||
`continue_on_error: "true"` (a string), the engine must NOT
|
||||
honour the flag — only a literal boolean enables the
|
||||
behaviour. `WorkflowEngine.execute()` does not auto-validate
|
||||
(the `WorkflowEngine.load_workflow` docstring explicitly
|
||||
notes the definition is "not yet validated; call
|
||||
`validate_workflow()` or `engine.validate()` separately"),
|
||||
so the engine guards against truthy non-bool values itself
|
||||
via an identity check rather than truthiness.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
# Bypass `validate_workflow()` — execute() is what would
|
||||
# be called by a caller that skipped validation.
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "string-coe"
|
||||
name: "String COE"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: fail-step
|
||||
type: shell
|
||||
run: "exit 1"
|
||||
continue_on_error: "true"
|
||||
- id: should-not-run
|
||||
type: shell
|
||||
run: "echo should-not-run"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
# String "true" is truthy but not a literal boolean, so the
|
||||
# engine must treat the step as a halting failure.
|
||||
assert state.status == RunStatus.FAILED
|
||||
assert "should-not-run" not in state.step_results
|
||||
|
||||
|
||||
# ===== State Persistence Tests =====
|
||||
|
||||
class TestRunState:
|
||||
@@ -2416,6 +2716,112 @@ class TestRunState:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
RunState.load("nonexistent", project_dir)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malicious_run_id",
|
||||
[
|
||||
# Parent-directory traversal — the classic path-escape vector.
|
||||
"../escape",
|
||||
"..",
|
||||
"../../etc/passwd",
|
||||
# Embedded path separators — both POSIX and Windows.
|
||||
"foo/bar",
|
||||
"foo\\bar",
|
||||
# Leading non-alphanumeric characters that the existing
|
||||
# pattern's anchor blocks (would be mistaken for CLI flags
|
||||
# or hidden files in shell completions / error messages).
|
||||
".hidden",
|
||||
"-flag",
|
||||
# NUL byte — some filesystems treat the prefix as a valid
|
||||
# path and silently truncate at the NUL.
|
||||
"foo\x00bar",
|
||||
# Empty string — degenerate case, matches no file but the
|
||||
# validator should reject it before any I/O.
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_load_rejects_path_traversal(self, project_dir, malicious_run_id):
|
||||
"""``RunState.load`` validates ``run_id`` before touching the
|
||||
filesystem.
|
||||
|
||||
Without this guard, a value like ``../escape`` passed via
|
||||
``specify workflow resume`` would interpolate path-traversal
|
||||
segments into the lookup path. ``state_path.exists()`` would
|
||||
probe arbitrary paths the process can read (a file-existence
|
||||
oracle) and ``json.load`` would happily parse attacker-planted
|
||||
JSON from outside ``.specify/workflows/runs/``. The check must
|
||||
fire *before* the path is built — ``__init__``'s identical
|
||||
regex on ``state_data["run_id"]`` fires too late.
|
||||
"""
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
# Plant a state.json *outside* the legitimate ``runs/`` directory
|
||||
# at the location ``../escape`` would traverse to, so a missing
|
||||
# guard would surface as a successful load rather than a
|
||||
# ``FileNotFoundError`` (which would be ambiguous with the
|
||||
# not-found case).
|
||||
runs_dir = project_dir / ".specify" / "workflows" / "runs"
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
attacker_dir = project_dir / ".specify" / "workflows" / "escape"
|
||||
attacker_dir.mkdir(exist_ok=True)
|
||||
(attacker_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"run_id": "pwned",
|
||||
"workflow_id": "attacker-owned",
|
||||
"status": "created",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid run_id"):
|
||||
RunState.load(malicious_run_id, project_dir)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_run_id",
|
||||
[
|
||||
# One vector per category from ``test_load_rejects_path_traversal``
|
||||
# — enough to prove both entry points agree without re-running
|
||||
# the full attack matrix here.
|
||||
"../escape", # parent-directory traversal
|
||||
"foo/bar", # embedded path separator
|
||||
".hidden", # leading non-alphanumeric
|
||||
"", # empty / degenerate
|
||||
],
|
||||
)
|
||||
def test_init_and_load_share_validation(self, project_dir, bad_run_id):
|
||||
"""``__init__`` *and* ``load`` reject the same malformed IDs.
|
||||
|
||||
The two entry points must stay in sync — drift would let an ID
|
||||
slip in via one path that the other would reject, producing
|
||||
confusing crashes mid-workflow. The previous version of this
|
||||
test only exercised ``__init__`` and ``_validate_run_id`` (the
|
||||
shared helper), so a regression in ``load`` — e.g. someone
|
||||
deleting the ``cls._validate_run_id(run_id)`` call there — could
|
||||
slip through despite ``__init__`` and the helper staying
|
||||
aligned. We now hit ``load`` directly with the same vector so
|
||||
any drift between the two call sites is caught by this test.
|
||||
"""
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
# ``__init__`` rejects up front.
|
||||
with pytest.raises(ValueError, match="Invalid run_id"):
|
||||
RunState(run_id=bad_run_id)
|
||||
|
||||
# The shared helper rejects the value too (sanity check that the
|
||||
# ``__init__`` rejection came from the validator, not some
|
||||
# unrelated constructor failure).
|
||||
with pytest.raises(ValueError, match="Invalid run_id"):
|
||||
RunState._validate_run_id(bad_run_id)
|
||||
|
||||
# And ``load`` rejects it *before* touching the filesystem. This
|
||||
# is the assertion the previous version was missing: without it,
|
||||
# a regression in ``load`` (e.g. forgetting to call the
|
||||
# validator before building the path) would not be caught even
|
||||
# though ``__init__`` and the helper still agreed.
|
||||
with pytest.raises(ValueError, match="Invalid run_id"):
|
||||
RunState.load(bad_run_id, project_dir)
|
||||
|
||||
def test_append_log(self, project_dir):
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
@@ -2726,3 +3132,118 @@ steps:
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
assert "do-plan" in state.step_results
|
||||
assert "do-specify" not in state.step_results
|
||||
|
||||
|
||||
class TestResumeWithInputs:
|
||||
"""Test that `workflow resume` can accept updated workflow inputs."""
|
||||
|
||||
_WF_CMD = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "resume-cmd-wf"
|
||||
name: "Resume Cmd WF"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
cmd:
|
||||
type: string
|
||||
default: "exit 1"
|
||||
steps:
|
||||
- id: s
|
||||
type: shell
|
||||
run: "{{ inputs.cmd }}"
|
||||
"""
|
||||
|
||||
_WF_NUM = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "resume-num-wf"
|
||||
name: "Resume Num WF"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
count:
|
||||
type: number
|
||||
default: 1
|
||||
steps:
|
||||
- id: gate
|
||||
type: gate
|
||||
message: "Review"
|
||||
options: [approve, reject]
|
||||
"""
|
||||
|
||||
def _engine(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
return WorkflowEngine(project_dir)
|
||||
|
||||
def test_resume_with_input_reruns_step_with_new_value(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_CMD)
|
||||
engine = self._engine(project_dir)
|
||||
|
||||
state = engine.execute(definition)
|
||||
assert state.status == RunStatus.FAILED # "exit 1" fails
|
||||
|
||||
resumed = engine.resume(state.run_id, {"cmd": "exit 0"})
|
||||
assert resumed.status == RunStatus.COMPLETED
|
||||
assert resumed.inputs["cmd"] == "exit 0"
|
||||
|
||||
def test_resume_without_input_preserves_inputs(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_CMD)
|
||||
engine = self._engine(project_dir)
|
||||
|
||||
state = engine.execute(definition)
|
||||
assert state.status == RunStatus.FAILED
|
||||
|
||||
resumed = engine.resume(state.run_id)
|
||||
assert resumed.status == RunStatus.FAILED # still "exit 1"
|
||||
assert resumed.inputs["cmd"] == "exit 1"
|
||||
|
||||
def test_resume_merges_and_coerces_typed_input(self, project_dir):
|
||||
import json as _json
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_NUM)
|
||||
engine = self._engine(project_dir)
|
||||
|
||||
state = engine.execute(definition)
|
||||
assert state.status == RunStatus.PAUSED
|
||||
|
||||
resumed = engine.resume(state.run_id, {"count": "5"})
|
||||
assert resumed.inputs["count"] == 5 # coerced string -> number
|
||||
|
||||
inputs_file = (
|
||||
project_dir / ".specify" / "workflows" / "runs" / state.run_id / "inputs.json"
|
||||
)
|
||||
assert _json.loads(inputs_file.read_text())["inputs"]["count"] == 5
|
||||
|
||||
def test_resume_invalid_typed_input_raises(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_NUM)
|
||||
engine = self._engine(project_dir)
|
||||
|
||||
state = engine.execute(definition)
|
||||
with pytest.raises(ValueError):
|
||||
engine.resume(state.run_id, {"count": "not-a-number"})
|
||||
|
||||
def test_cli_resume_input_invalid_format_errors(self, project_dir):
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_NUM)
|
||||
state = self._engine(project_dir).execute(definition)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "resume", state.run_id, "--input", "bogus"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid input format" in result.stdout
|
||||
|
||||
@@ -219,6 +219,83 @@ Aggregate results from fan-out steps:
|
||||
output: {}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
By default, any step that returns `StepResult(status=StepStatus.FAILED, ...)`
|
||||
at runtime halts the entire run — most commonly a `shell` or
|
||||
`command` step exiting non-zero. Set `continue_on_error: true` on
|
||||
a step to record its result and continue to the next sibling step
|
||||
instead. When the failure was a non-zero exit, the exit code
|
||||
remains available on `steps.<id>.output.exit_code` so a downstream
|
||||
`if` or `switch` can branch on it (or a `gate` can surface it to
|
||||
the operator via `{{ }}` interpolation in `message`):
|
||||
|
||||
```yaml
|
||||
- id: heavy-thing
|
||||
type: command
|
||||
integration: claude
|
||||
command: speckit.heavy-thing
|
||||
continue_on_error: true
|
||||
|
||||
- id: check-result
|
||||
type: if
|
||||
condition: "{{ steps.heavy-thing.output.exit_code != 0 }}"
|
||||
then:
|
||||
- id: review
|
||||
type: gate
|
||||
message: "Step failed (exit {{ steps.heavy-thing.output.exit_code }}). Approve to run the recovery path, or reject to leave the failure recorded and move on."
|
||||
on_reject: skip
|
||||
- id: recover
|
||||
type: if
|
||||
condition: "{{ steps.review.output.choice == 'approve' }}"
|
||||
then:
|
||||
- id: rerun
|
||||
command: speckit.recovery
|
||||
else:
|
||||
- id: next-thing
|
||||
command: speckit.next-thing
|
||||
```
|
||||
|
||||
A few things worth knowing about that example:
|
||||
|
||||
- Both gate options (`approve`, `reject`) return `StepStatus.COMPLETED`;
|
||||
`on_reject: skip` controls only whether the engine aborts on reject
|
||||
(it doesn't, with `skip`) — it does **not** auto-skip subsequent
|
||||
sibling steps in the `then:` list. Downstream branching is the
|
||||
workflow author's responsibility: read
|
||||
`{{ steps.<gate-id>.output.choice }}` in a follow-up `if`, `switch`,
|
||||
or expression, as the `recover` step above does.
|
||||
- `on_reject` has three values: `abort` (default — reject → `StepStatus.FAILED`
|
||||
with `output.aborted = True`, halts the run), `skip` (reject →
|
||||
`StepStatus.COMPLETED`, author handles branching as shown), and `retry`
|
||||
(reject → `StepStatus.PAUSED` so the next `specify workflow resume` re-runs
|
||||
the gate).
|
||||
- Gates do not automatically re-run the failed step. To express a
|
||||
retry path, either define custom gate options and branch on the
|
||||
choice downstream, or wrap the failing step in your own loop.
|
||||
|
||||
**Notes:**
|
||||
|
||||
- The field must be a literal boolean (`true` / `false`); coerced
|
||||
strings like `"true"` are rejected at validation time.
|
||||
- **Scope: returned failures only.** The flag applies to step results
|
||||
with `status=StepStatus.FAILED`. Unhandled exceptions raised out of a step's
|
||||
`execute()` method are caught one level up by `WorkflowEngine.execute()`,
|
||||
logged as `workflow_failed`, and abort the run regardless of
|
||||
`continue_on_error`. If a step author wants the flag to cover an
|
||||
exceptional path, the step must catch the exception internally and
|
||||
return `StepResult(status=StepStatus.FAILED, ...)` with the failure encoded in
|
||||
`output` (e.g. `exit_code`, `stderr`, or a custom field).
|
||||
- Gate aborts (`on_reject: abort` chosen by the operator) always halt
|
||||
the run — `continue_on_error` does not override them. The flag is
|
||||
for transient/expected step failures, not for overriding deliberate
|
||||
operator decisions.
|
||||
- Structural validation runs up-front: `specify workflow run` rejects
|
||||
invalid workflow definitions before the run is created, so
|
||||
validation failures never reach this code path.
|
||||
- When the flag is omitted, behaviour is byte-equivalent to before
|
||||
this feature.
|
||||
|
||||
## Expressions
|
||||
|
||||
Workflow definitions use `{{ expression }}` syntax for dynamic values:
|
||||
|
||||
Reference in New Issue
Block a user