mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09b99f351a |
@@ -29,20 +29,12 @@ def _dependency_diff_refs() -> tuple[str, str]:
|
||||
def _dependency_inputs_changed() -> bool:
|
||||
base_ref, head_ref = _dependency_diff_refs()
|
||||
try:
|
||||
merge_base = subprocess.run(
|
||||
["git", "merge-base", base_ref, head_ref],
|
||||
check=True,
|
||||
cwd=REPO_ROOT,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
merge_base,
|
||||
base_ref,
|
||||
head_ref,
|
||||
"--",
|
||||
*DEPENDENCY_INPUTS,
|
||||
@@ -85,7 +77,6 @@ def main() -> int:
|
||||
|
||||
generated_requirements = Path(generated_requirements_env)
|
||||
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
|
||||
generated_requirements.write_bytes(COMMITTED_REQUIREMENTS.read_bytes())
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
@@ -96,6 +87,7 @@ def main() -> int:
|
||||
"--extra",
|
||||
"test",
|
||||
"--universal",
|
||||
"--upgrade",
|
||||
"--generate-hashes",
|
||||
"--quiet",
|
||||
"--no-header",
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
||||
@@ -27,14 +27,14 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Check committed audit requirements are current
|
||||
env:
|
||||
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
|
||||
DEPENDENCY_DIFF_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
|
||||
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
|
||||
run: python .github/scripts/check_security_requirements.py
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
|
||||
with:
|
||||
# Days of inactivity before an issue or PR becomes stale
|
||||
days-before-stale: 150
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
||||
@@ -2,59 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.15.1] - 2026-07-31
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: escape Rich markup in `workflow resolve` output (#3879)
|
||||
- chore(deps): bump actions/stale from 10.4.0 to 11.0.0 (#3877)
|
||||
- chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#3876)
|
||||
- feat: support tar archives for installs (#3874)
|
||||
- fix: eliminate TOCTOU race in file unlink calls (#3819)
|
||||
- fix(scripts): tolerate an unusable integration.json in the Python helper (#3785)
|
||||
- fix(catalogs): validate the port in the shared catalog-URL validator, like its mirrors do (#3804)
|
||||
- feat(presets): add opt-in constitution-sync preset (#3873)
|
||||
- fix: reject non-object workflow caches (#3860)
|
||||
- Harden extension URL download cache against symlink and junction races (#3869)
|
||||
- fix: escape workflow step metadata (#3863)
|
||||
- [bug-fix] Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller (#3452)
|
||||
- fix: use chunked read for extension manifest hash (#3841)
|
||||
- fix: preserve unreadable event config files (#3861)
|
||||
- fix(scripts): use a .NET Framework-safe trim in the PowerShell init-dir resolver (#3872)
|
||||
- Add ContextForge MCP extension to community catalog (#3487)
|
||||
- fix: normalize non-UTF-8 integration manifests (#3862)
|
||||
- feat: bind gate verdict to workflow input via verdict_input (#3725)
|
||||
- docs: use absolute image URLs in README for PyPI rendering (#3867)
|
||||
- chore: release 0.15.0, begin 0.15.1.dev0 development (#3871)
|
||||
|
||||
## [0.15.0] - 2026-07-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Add yolo to community workflow catalog (#3864)
|
||||
- fix(workflows): guard the shell step's timeout check against OverflowError (#3865)
|
||||
- Add Intent Reconciliation extension to community catalog (#3858)
|
||||
- fix(workflows): validate prompt step 'timeout' like the shell step (#3847)
|
||||
- fix: add utf-8 encoding to registry file open calls (#3816)
|
||||
- fix: eliminate TOCTOU race in file unlink calls (#3815)
|
||||
- test(workflows): name the condition-rejection tests for the real boundary (#3808)
|
||||
- fix: eliminate TOCTOU race in file unlink calls (#3811)
|
||||
- fix(presets): escape user-supplied catalog name/URL in add/remove output (#3806)
|
||||
- fix: add missing utf-8 encoding to registry file open calls (#3810)
|
||||
- [bug-fix] Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade (#3853)
|
||||
- fix(integrations): don't abort uninstall when the manifest can't be deleted (#3805)
|
||||
- test(extensions): update stale manifest validation message assertion (#3859)
|
||||
- fix(agents): coerce a non-string description in TOML command rendering (#3799)
|
||||
- fix(workflows): make security requirements sync deterministic (#3832)
|
||||
- fix(cli): render the literal [suffix] in --tag help and rejection message (#3800)
|
||||
- fix(integrations): preserve non-UTF-8 VS Code settings (#3833)
|
||||
- fix(bundler): treat an explicit-null manifest field as missing, not the text "None" (#3798)
|
||||
- feat: first-class agent-native runtime hooks for integrations (#3704)
|
||||
- fix(extensions): guard the required manifest sections so one bad extension cannot break `extension list` (#3797)
|
||||
- fix(presets): escape installed preset metadata in Rich output (#3826)
|
||||
- fix(workflows): dispatch prompt steps via the resolved executable (#3793)
|
||||
- chore: release 0.14.4, begin 0.14.5.dev0 development (#3850)
|
||||
|
||||
## [0.14.4] - 2026-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/github/spec-kit/main/media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
|
||||
<img src="./media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
|
||||
<h1>🌱 Spec Kit</h1>
|
||||
<h3><em>Define what to build before building it — with any AI coding agent.</em></h3>
|
||||
</div>
|
||||
@@ -136,7 +136,7 @@ For detailed step-by-step instructions, see our [comprehensive guide](./spec-dri
|
||||
|
||||
Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)!
|
||||
|
||||
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
|
||||
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
|
||||
|
||||
## 🌍 Community
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| ContextForge MCP | Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase | `code` | Read+Write | [contextforge-mcp](https://github.com/capatinore/contextforge-mcp) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
|
||||
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF/JUnit output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 27 validators, stable finding codes, adoption baseline for legacy repos, compliance-evidence reports, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
@@ -67,7 +66,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
|
||||
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
| Intent Reconciliation | Reconcile implementation-discovered decisions against approved feature intent | `process` | Read+Write | [spec-kit-reconcile](https://github.com/SuhaibAslam/spec-kit-reconcile) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
|
||||
|
||||
@@ -39,21 +39,6 @@ specify workflow run my-pipeline.yml --json
|
||||
|
||||
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
|
||||
|
||||
For `failed` and `aborted` runs, the payload includes an `error` field carrying the terminal step's error message:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "662bf791",
|
||||
"workflow_id": "build-and-review",
|
||||
"status": "failed",
|
||||
"current_step_id": "boom",
|
||||
"current_step_index": 0,
|
||||
"error": "Command exited with code 3"
|
||||
}
|
||||
```
|
||||
|
||||
`completed` and `paused` runs omit the `error` field. The error is persisted in the run's `state.json`, so `specify workflow status <run_id> --json` surfaces the same message after the fact.
|
||||
|
||||
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
|
||||
|
||||
## Resume a Workflow
|
||||
@@ -103,17 +88,10 @@ specify workflow add <source>
|
||||
|
||||
| Option | Description |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| `--dev` | Install from a local YAML file, package directory, or archive |
|
||||
| `--dev` | Install from a local workflow YAML file or directory |
|
||||
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
|
||||
|
||||
Installs a workflow from the catalog, an HTTPS URL, a local YAML file, a
|
||||
directory containing `workflow.yml`, or a `.zip`, `.tar.gz`, or `.tgz`
|
||||
archive. Archives may contain `workflow.yml` at the root or inside one
|
||||
top-level directory.
|
||||
|
||||
Directory and archive installs preserve the complete workflow package,
|
||||
including scripts and other companion files. ZIP, `.tar.gz`, and `.tgz`
|
||||
archives follow the same validation and installation behavior.
|
||||
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
|
||||
|
||||
## Workflow Overlays
|
||||
|
||||
@@ -288,9 +266,7 @@ Lower priority values have higher precedence. Change this overlay to `priority:
|
||||
|
||||
### Interaction with Bundles and Updates
|
||||
|
||||
`specify workflow add <local-directory>` installs the complete local workflow
|
||||
package into `.specify/workflows/<id>/`. Archive installs preserve the same
|
||||
package contents.
|
||||
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
|
||||
|
||||
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
|
||||
|
||||
@@ -578,51 +554,6 @@ Each workflow run persists its state at `.specify/workflows/runs/<run_id>/`:
|
||||
|
||||
This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed.
|
||||
|
||||
### Gate Verdict Inputs
|
||||
|
||||
`verdict_input` binds a gate's verdict to a named workflow input. The input must be declared in the workflow's `inputs` block; `specify workflow validate` reports an undeclared reference.
|
||||
|
||||
`verdict_input` is not supported inside a `fan-out` template. Fan-out items
|
||||
share workflow inputs, while workflow state can represent only one paused
|
||||
gate. Place a gate before the fan-out to approve the whole batch, or after a
|
||||
fan-in to review the aggregated results.
|
||||
|
||||
**Input value semantics:**
|
||||
|
||||
| Value | Behavior |
|
||||
|---|---|
|
||||
| Non-empty string, matches an option (case-insensitive) | Gate auto-decides; `output.choice` is set to the configured option spelling |
|
||||
| Non-empty string, no match | Gate fails immediately |
|
||||
| Non-string | Gate fails immediately |
|
||||
| Missing or empty | Gate prompts on a TTY; pauses otherwise |
|
||||
|
||||
**Default value semantics:** A non-empty `default` is consumed as a verdict on the first run — matching an option auto-decides the gate, not matching fails it immediately.
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
spec_verdict:
|
||||
type: string
|
||||
default: ""
|
||||
steps:
|
||||
- id: review-spec
|
||||
type: gate
|
||||
message: "Approve the specification?"
|
||||
options: [approve, reject]
|
||||
on_reject: retry
|
||||
verdict_input: spec_verdict
|
||||
```
|
||||
|
||||
Supply a verdict when resuming:
|
||||
|
||||
```bash
|
||||
specify workflow resume <run_id> --input spec_verdict=approve
|
||||
```
|
||||
|
||||
For `on_reject: retry`, a bound reject verdict is consumed before the gate
|
||||
pauses: the named stored input is reset to `""`. A later resume therefore
|
||||
prompts or pauses again until another verdict is supplied. Approve, abort, and
|
||||
skip outcomes leave the input unchanged.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What happens when a workflow hits a gate step?
|
||||
|
||||
@@ -208,74 +208,6 @@ Restart your IDE to refresh the command list.
|
||||
|
||||
---
|
||||
|
||||
## Behavior change: `/constitution` no longer propagates into templates
|
||||
|
||||
The `/constitution` command ([#3790](https://github.com/github/spec-kit/pull/3790)) is scoped to
|
||||
its own artifact. It updates
|
||||
`.specify/memory/constitution.md` and writes a Sync Impact Report, and **no longer edits**
|
||||
`plan-template.md`, `spec-template.md`, `tasks-template.md`, installed command files, or
|
||||
guidance docs.
|
||||
|
||||
### Why
|
||||
|
||||
Spec Kit uses **runtime resolution**: `plan`, `tasks`, and `analyze` read
|
||||
`.specify/memory/constitution.md` live on every run, and `analyze` is the dedicated drift
|
||||
checker. The governed templates carry a pointer, not a copy — `plan-template.md` ships
|
||||
`[Gates determined based on constitution file]`, and `/plan` fills that section from the live
|
||||
constitution each run. Propagation duplicated the single source of truth and fought the
|
||||
preset/override composition system (a `replace` preset shadows an edited core template).
|
||||
|
||||
More broadly, presets and extensions — not in-place file edits — are how Spec Kit now governs
|
||||
shared assets. Composing policy through the resolution stack keeps it centrally owned, versioned,
|
||||
and auditable across repositories, instead of frozen into per-repo copies no core team can see.
|
||||
|
||||
### Is this a breaking change for existing projects?
|
||||
|
||||
**No — your workflow keeps working.** You would only notice a difference if you relied on
|
||||
`/constitution` editing those files in place. The templates are scaffolds, not authorities. When you
|
||||
run `/plan`, it copies the template into a per-feature `plan.md` and re-derives the Constitution
|
||||
Check from the live constitution; `/analyze` validates against it. Even if a previous
|
||||
`/constitution` run materialized concrete gate text into `.specify/templates/plan-template.md`,
|
||||
the live constitution remains the source of truth at runtime.
|
||||
|
||||
On a **non-forced upgrade**, a materialized template is *preserved* (its hash diverges from the
|
||||
recorded managed copy, so the refresh treats it as a customization and does not overwrite it).
|
||||
Nothing regresses.
|
||||
|
||||
### Optional cleanup — return to the runtime pointer
|
||||
|
||||
A frozen, pre-filled Constitution Check is a slightly misleading scaffold and can bias the first
|
||||
`/plan` pass. To move fully back to runtime resolution, reset the section body in
|
||||
`.specify/templates/plan-template.md` to the pointer:
|
||||
|
||||
```text
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
```
|
||||
|
||||
Leave the rest of the file untouched. This is cleanup, not a required migration.
|
||||
|
||||
### Keeping the old behavior (opt-in)
|
||||
|
||||
If your team treats the materialized templates as **reviewed, committed artifacts** and wants
|
||||
`/constitution` to keep propagating, install the bundled **`constitution-sync`** preset:
|
||||
|
||||
```bash
|
||||
specify preset add constitution-sync
|
||||
```
|
||||
|
||||
It wraps the core `/constitution` command and re-adds the propagation pass. It does **not** edit
|
||||
versioned preset- or extension-provided templates or command files (those are owned by their
|
||||
packages and are recomposed on reconciliation). Note that this edit-in-place propagation model
|
||||
conflicts with the composition model used by the rest of the SDD commands when they are
|
||||
preset/extension-managed — see the "Interaction with the resolution stack" section in
|
||||
`presets/constitution-sync/README.md` for the tradeoffs and when to prefer the default instead.
|
||||
|
||||
---
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario 1: "I just want new slash commands"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-29T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1070,41 +1070,6 @@
|
||||
"created_at": "2026-03-29T00:00:00Z",
|
||||
"updated_at": "2026-03-29T00:00:00Z"
|
||||
},
|
||||
"contextforge-mcp": {
|
||||
"name": "ContextForge MCP",
|
||||
"id": "contextforge-mcp",
|
||||
"description": "Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase.",
|
||||
"author": "capatinore",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/capatinore/contextforge-mcp/releases/download/ext-v0.1.0/contextforge-mcp-speckit-extension.zip",
|
||||
"repository": "https://github.com/capatinore/contextforge-mcp",
|
||||
"homepage": "https://github.com/capatinore/contextforge-mcp",
|
||||
"documentation": "https://github.com/capatinore/contextforge-mcp/blob/main/README.md",
|
||||
"changelog": "",
|
||||
"license": "MIT",
|
||||
"category": "code",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.10.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"mcp",
|
||||
"code-intelligence",
|
||||
"context-compression",
|
||||
"tokens",
|
||||
"claude",
|
||||
"spec-driven-development"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"cost": {
|
||||
"name": "Cost Tracker",
|
||||
"id": "cost",
|
||||
@@ -1896,40 +1861,6 @@
|
||||
"created_at": "2026-06-23T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z"
|
||||
},
|
||||
"intent": {
|
||||
"name": "Intent Reconciliation",
|
||||
"id": "intent",
|
||||
"description": "Reconcile implementation-discovered decisions against approved feature intent",
|
||||
"author": "SuhaibAslam",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/SuhaibAslam/spec-kit-reconcile/archive/refs/tags/v1.0.2.zip",
|
||||
"repository": "https://github.com/SuhaibAslam/spec-kit-reconcile",
|
||||
"homepage": "https://github.com/SuhaibAslam/spec-kit-reconcile",
|
||||
"documentation": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/README.md",
|
||||
"changelog": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"intent",
|
||||
"decisions",
|
||||
"reconciliation",
|
||||
"drift",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-29T00:00:00Z",
|
||||
"updated_at": "2026-07-29T00:00:00Z"
|
||||
},
|
||||
"issue": {
|
||||
"name": "GitHub Issues Integration 2",
|
||||
"id": "issue",
|
||||
|
||||
@@ -25,29 +25,6 @@
|
||||
"workflow",
|
||||
"core"
|
||||
]
|
||||
},
|
||||
"constitution-sync": {
|
||||
"name": "Constitution Template Sync",
|
||||
"id": "constitution-sync",
|
||||
"version": "1.0.0",
|
||||
"description": "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.",
|
||||
"author": "github",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"license": "MIT",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"speckit_version": ">=0.14.4"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"templates": 0
|
||||
},
|
||||
"tags": [
|
||||
"constitution",
|
||||
"governance",
|
||||
"templates",
|
||||
"compatibility"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# Constitution Template Sync
|
||||
|
||||
An **opt-in** preset that restores `/constitution`'s ability to propagate amended guidance into your
|
||||
project's own templates and command files. After you update the constitution, it aligns
|
||||
`plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
|
||||
guidance docs so they reflect the current principles.
|
||||
|
||||
This propagation used to be built into `/constitution`; it was dropped when the command moved to the
|
||||
preset model. Installing this preset opts you back into it: you get the guidance materialized into
|
||||
reviewed, committed artifacts instead of relying on runtime resolution alone.
|
||||
|
||||
> **What you're opting into.** Propagation was removed deliberately — it duplicates the constitution
|
||||
> as the source of truth and can fight the composition stack (materialized edits get shadowed or
|
||||
> clobbered on the next recompose). This preset knowingly **reintroduces** that behavior, and those
|
||||
> tradeoffs, for teams that want it. Read the [caveats](#caveats-you-take-on) before installing.
|
||||
|
||||
For most projects the default composable stack is the **recommended** approach, and at organization
|
||||
scale it is usually the stronger governance model. Runtime resolution keeps the live constitution as
|
||||
the single source of truth (nothing to re-sync, so nothing drifts), and the stack composes the
|
||||
**entire** Spec Kit ecosystem — not just the SDD commands, but every command, template, script and
|
||||
extension — with explicit priority levels, strategies, and independent versioning. It is a
|
||||
capability, not automatic governance: a core team authors its own organizational presets and
|
||||
extensions, then owns, versions, and audits that policy in one place and rolls it across many
|
||||
repositories, instead of scattering frozen, per-repo copies no central team can see. This preset is
|
||||
a supported escape hatch for teams whose workflow depends on reviewing materialized artifacts
|
||||
directly — useful as a bridge, though for org-wide policy the better long-term path is usually a
|
||||
versioned preset a core team maintains.
|
||||
|
||||
## What it does
|
||||
|
||||
Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
|
||||
current core command (via `{CORE_TEMPLATE}`), so it stays forward-compatible with core changes, and
|
||||
appends a propagation pass that, after the constitution is written:
|
||||
|
||||
- Aligns `plan/spec/tasks-template.md` in `.specify/templates/` with the updated principles.
|
||||
- Updates **project-local** command files and guidance docs to correct stale references.
|
||||
- Extends the Sync Impact Report in `.specify/memory/constitution.md` with the files it touched.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- It does **not** change behavior for anyone who does not install it — the default runtime
|
||||
resolution model is untouched.
|
||||
- It does **not** disable runtime resolution. `plan`, `tasks`, and `analyze` still read the live
|
||||
constitution every run; this preset adds materialized copies on top — it does not replace the
|
||||
source of truth.
|
||||
- It does **not** edit versioned, package-owned files — templates or command files provided or
|
||||
wrapped by another preset or extension. Those are recomposed from the resolution stack, so it
|
||||
only ever writes into your project's own `.specify/templates/` scaffolds and command files that
|
||||
are not managed by a preset/extension.
|
||||
|
||||
## When to use it
|
||||
|
||||
Install it **only** if your team treats the materialized templates and commands as
|
||||
**reviewed, committed artifacts** — for example, if `plan-template.md`'s Constitution Check is
|
||||
read in PRs as "here are our current gates" and is expected to track the constitution.
|
||||
|
||||
If you rely on the default runtime-resolution model, you do **not** need this preset: the live
|
||||
constitution is already the single source of truth and there is nothing to sync.
|
||||
|
||||
## Caveats you take on
|
||||
|
||||
The preset resolution stack is how Spec Kit composes templates and commands going forward: they are
|
||||
**layered, package-owned artifacts recomposed on demand**, not frozen files you edit in place.
|
||||
Propagation is the opposite idea — it **materializes** guidance into files and freezes it. That
|
||||
tension is the main thing to understand before installing:
|
||||
|
||||
- **Materialized copies can drift.** Anything propagated is a snapshot; if you amend the
|
||||
constitution and do not re-run `/constitution`, the copies fall out of sync. The default runtime
|
||||
model has no drift because it reads the live constitution every run.
|
||||
|
||||
- **Edits to composed files do not survive reconciliation.** If the rest of your SDD flow is
|
||||
preset/extension-managed, the commands it materializes (`speckit.plan`, `speckit.specify`,
|
||||
`speckit.tasks`, `speckit.analyze`, `speckit.implement`, …) are recomputed from the stack. Any
|
||||
guidance propagated into them is clobbered the next time the stack reconciles — on
|
||||
`specify integration use <key>` / `switch`, `specify integration upgrade`, or any preset/extension
|
||||
install or remove. The same applies to templates owned by another preset/extension. This is why
|
||||
the preset restricts itself to project-local files; propagation is reliable **only** for
|
||||
artifacts you own outright.
|
||||
|
||||
- **A pre-filled Constitution Check can bias `/plan`.** Materializing concrete gates into
|
||||
`plan-template.md` replaces the runtime pointer, so the first `/plan` pass may anchor on the
|
||||
frozen text. Keep the pointer unless you specifically want committed gates.
|
||||
|
||||
**Bottom line:** this preset fits projects whose governed templates and commands are project-local
|
||||
artifacts they review, with the rest of the SDD flow on the plain bundled core. If your
|
||||
`plan`/`specify`/`tasks`/`analyze` commands or templates come from other presets or extensions,
|
||||
prefer the default runtime-resolution model.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# constitution-sync is a bundled preset — no download needed
|
||||
specify preset add constitution-sync
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Test from local directory
|
||||
specify preset add --dev ./presets/constitution-sync
|
||||
|
||||
# Verify the wrapped command resolves
|
||||
specify preset resolve speckit.constitution
|
||||
|
||||
# Remove when done
|
||||
specify preset remove constitution-sync
|
||||
```
|
||||
|
||||
## Migrating back to the default
|
||||
|
||||
To move back to runtime resolution, reset each materialized `## Constitution Check` section in
|
||||
`.specify/templates/plan-template.md` to the pointer:
|
||||
|
||||
```text
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
```
|
||||
|
||||
Then remove this preset. See `docs/upgrade.md` for details.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
description: Create or update the project constitution, then propagate the amended guidance into dependent templates and installed command files (opt-in template sync).
|
||||
strategy: wrap
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
---
|
||||
|
||||
{CORE_TEMPLATE}
|
||||
|
||||
## Constitution Template Sync
|
||||
|
||||
> **This section supersedes the "Scope Guard" above for template and command propagation.**
|
||||
> The core Scope Guard states that dependent templates and commands "are not modified here" —
|
||||
> that restriction is intentionally lifted by this preset. Propagating amended guidance into the
|
||||
> project's own dependent scaffolds and installed command files (as described below) **is in
|
||||
> scope** whenever this preset is installed. The Scope Guard's other constraints still apply:
|
||||
> do not implement features, generate application code, or touch artifacts unrelated to the
|
||||
> constitution/template workflow.
|
||||
|
||||
After you have written the updated constitution above, perform a consistency propagation pass
|
||||
so the dependent artifacts reflect the amended principles:
|
||||
|
||||
1. Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align
|
||||
with the updated principles. Only materialize concrete gate text here if your team intends to
|
||||
review it as committed content; otherwise leave the runtime pointer
|
||||
`[Gates determined based on constitution file]` in place so `/plan` fills it from the live
|
||||
constitution.
|
||||
2. Read `.specify/templates/spec-template.md` for scope/requirements alignment — update if the
|
||||
constitution adds/removes mandatory sections or constraints.
|
||||
3. Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or
|
||||
removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
4. Read each installed Spec Kit command file for your agent (including this one) — named
|
||||
`speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as
|
||||
`speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`,
|
||||
`.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify
|
||||
no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance
|
||||
is required. **Only hand-edit a command file if it is a project-local file not managed by a
|
||||
preset or extension.** Command files that are composed from the resolution stack (anything
|
||||
provided or wrapped by a preset/extension) must be regenerated through the stack — do **not**
|
||||
edit them in place, because reconciliation (`specify integration use`, `specify integration
|
||||
upgrade`, or any preset/extension install/remove) will clobber the edits.
|
||||
5. Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific
|
||||
guidance files if present) and update references to principles that changed.
|
||||
|
||||
Then extend the Sync Impact Report at the top of `.specify/memory/constitution.md` with:
|
||||
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths.
|
||||
|
||||
**Do not edit versioned preset- or extension-provided template or command files directly.** Those
|
||||
artifacts are owned by their packages and are recomposed on the package's next update or on stack
|
||||
reconciliation — hand edits are clobbered. Limit propagation to the project's own
|
||||
`.specify/templates/` scaffolds and to command files that are not managed by a preset or extension.
|
||||
@@ -1,30 +0,0 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
preset:
|
||||
id: "constitution-sync"
|
||||
name: "Constitution Template Sync"
|
||||
version: "1.0.0"
|
||||
description: "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts."
|
||||
author: "github"
|
||||
repository: "https://github.com/github/spec-kit"
|
||||
license: "MIT"
|
||||
|
||||
requires:
|
||||
# Requires the runtime-resolution baseline (#3790, shipped in 0.14.4) where the
|
||||
# core /constitution command no longer propagates. Installing this preset on an
|
||||
# older core would double-apply propagation.
|
||||
speckit_version: ">=0.14.4"
|
||||
|
||||
provides:
|
||||
templates:
|
||||
- type: "command"
|
||||
name: "speckit.constitution"
|
||||
file: "commands/speckit.constitution.md"
|
||||
description: "Wrap /constitution to also propagate guidance into dependent templates and command files"
|
||||
strategy: "wrap"
|
||||
|
||||
tags:
|
||||
- "constitution"
|
||||
- "governance"
|
||||
- "templates"
|
||||
- "compatibility"
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.14.4"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -49,7 +49,6 @@ packages = ["src/specify_cli"]
|
||||
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
|
||||
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
|
||||
"presets/lean" = "specify_cli/core_pack/presets/lean"
|
||||
"presets/constitution-sync" = "specify_cli/core_pack/presets/constitution-sync"
|
||||
# Community bundle catalog snapshot (used for offline discovery)
|
||||
"bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json"
|
||||
|
||||
|
||||
@@ -55,17 +55,9 @@ function Resolve-SpecifyInitDir {
|
||||
}
|
||||
# Resolve-Path echoes back any trailing separator from the input; trim it so
|
||||
# the returned root matches the bash resolver, whose `cd && pwd` never yields
|
||||
# one. TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
|
||||
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework, as
|
||||
# Get-FeaturePathsEnv already does below. Unlike a bare TrimEnd, the
|
||||
# GetPathRoot check preserves a path that *is* its own root ('C:\' must not
|
||||
# become 'C:', which every later API re-resolves against the current
|
||||
# directory instead of the drive root). No-op on a path with no trailing
|
||||
# separator.
|
||||
$initRoot = $resolved.Path.TrimEnd('/', '\')
|
||||
if ($initRoot.Length -lt [System.IO.Path]::GetPathRoot($resolved.Path).Length) {
|
||||
$initRoot = $resolved.Path
|
||||
}
|
||||
# one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
|
||||
# that already has no trailing separator.
|
||||
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
|
||||
@@ -258,27 +258,16 @@ def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
return "."
|
||||
# Split the parse out of the lookup and guard the top-level shape, matching
|
||||
# read_feature_json_feature_directory above and the bash/PowerShell twins,
|
||||
# which both fall back to "." for any unusable integration.json:
|
||||
# * a non-mapping top level ([], "forge", 42, null) is valid JSON, so
|
||||
# json.JSONDecodeError never fires and state.get(...) raised
|
||||
# AttributeError;
|
||||
# * a non-UTF-8 file raises UnicodeDecodeError, which is a ValueError --
|
||||
# not an OSError -- so it escaped the except tuple. Realistic on
|
||||
# Windows, where PowerShell 5.1's Out-File/`>` default to UTF-16.
|
||||
try:
|
||||
state = json.loads(integration_json.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return "."
|
||||
if not isinstance(state, dict):
|
||||
return "."
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
return entry["invoke_separator"]
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
return entry["invoke_separator"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return "."
|
||||
|
||||
|
||||
|
||||
@@ -514,11 +514,6 @@ _register_extension_cmds(app)
|
||||
from .integrations._commands import register as _register_integration_cmds # noqa: E402
|
||||
_register_integration_cmds(app)
|
||||
|
||||
|
||||
# ===== Event Commands =====
|
||||
from .commands.event import register as _register_event_cmds # noqa: E402
|
||||
_register_event_cmds(app)
|
||||
|
||||
# Re-export selected helpers to preserve the public import surface.
|
||||
from .integrations._helpers import ( # noqa: E402
|
||||
_clear_init_options_for_integration as _clear_init_options_for_integration,
|
||||
|
||||
@@ -7,7 +7,6 @@ import re
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import tarfile
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
@@ -15,12 +14,11 @@ from contextlib import ExitStack, contextmanager
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
from itertools import pairwise
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import BinaryIO, Literal, NoReturn, TypeVar
|
||||
from typing import NoReturn, TypeVar
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
|
||||
ErrorT = TypeVar("ErrorT", bound=Exception)
|
||||
ArchiveFormat = Literal["zip", "tar.gz"]
|
||||
|
||||
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
MAX_ZIP_ENTRIES = 512
|
||||
@@ -69,130 +67,6 @@ _ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
|
||||
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
|
||||
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
|
||||
)
|
||||
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
|
||||
"application/gzip": "tar.gz",
|
||||
"application/x-gzip": "tar.gz",
|
||||
"application/x-tar+gzip": "tar.gz",
|
||||
"application/zip": "zip",
|
||||
"application/x-zip-compressed": "zip",
|
||||
}
|
||||
|
||||
|
||||
def archive_format_from_name(name: str) -> ArchiveFormat | None:
|
||||
"""Return the supported archive format declared by a path or URL."""
|
||||
try:
|
||||
path = urlparse(name).path.lower()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if path.endswith(".tar.gz") or path.endswith(".tgz"):
|
||||
return "tar.gz"
|
||||
if path.endswith(".zip"):
|
||||
return "zip"
|
||||
return None
|
||||
|
||||
|
||||
def archive_format_from_content_type(content_type: str | None) -> ArchiveFormat | None:
|
||||
"""Return the supported archive format declared by an HTTP Content-Type."""
|
||||
if not isinstance(content_type, str):
|
||||
return None
|
||||
media_type = content_type.partition(";")[0].strip().lower()
|
||||
return _ARCHIVE_CONTENT_TYPES.get(media_type)
|
||||
|
||||
|
||||
def archive_suffix(archive_format: ArchiveFormat) -> str:
|
||||
"""Return the canonical filename suffix for *archive_format*."""
|
||||
if archive_format == "zip":
|
||||
return ".zip"
|
||||
if archive_format == "tar.gz":
|
||||
return ".tar.gz"
|
||||
raise ValueError(f"Unsupported archive format: {archive_format!r}")
|
||||
|
||||
|
||||
def detect_archive_format(
|
||||
archive_path: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
source_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
) -> ArchiveFormat:
|
||||
"""Validate the declared archive format against the file contents.
|
||||
|
||||
A recognized path/URL suffix is authoritative. For remote responses whose
|
||||
final URL has no archive suffix, a recognized Content-Type may declare the
|
||||
format instead. When both declarations are recognized they must agree, and
|
||||
the resulting declaration must match the archive bytes.
|
||||
"""
|
||||
archive_path = Path(archive_path)
|
||||
name_format = archive_format_from_name(
|
||||
source_name if source_name is not None else str(archive_path)
|
||||
)
|
||||
content_format = archive_format_from_content_type(content_type)
|
||||
if (
|
||||
name_format is not None
|
||||
and content_format is not None
|
||||
and name_format != content_format
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Archive format mismatch: filename declares {name_format} but "
|
||||
f"Content-Type declares {content_format}",
|
||||
)
|
||||
declared_format = name_format or content_format
|
||||
|
||||
with ExitStack() as stack:
|
||||
if archive_file is None:
|
||||
try:
|
||||
archive_file = stack.enter_context(archive_path.open("rb"))
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
|
||||
try:
|
||||
archive_file.seek(0)
|
||||
is_zip = zipfile.is_zipfile(archive_file)
|
||||
archive_file.seek(0)
|
||||
signature = archive_file.read(4)
|
||||
# Let the bounded ZIP preflight report structural errors such as
|
||||
# impossible entry counts. ``is_zipfile`` rejects those before the
|
||||
# extractor can produce the established security diagnostic.
|
||||
is_zip = is_zip or signature in {
|
||||
b"PK\x03\x04",
|
||||
b"PK\x05\x06",
|
||||
b"PK\x07\x08",
|
||||
}
|
||||
is_gzip = signature[:2] == b"\x1f\x8b"
|
||||
archive_file.seek(0)
|
||||
is_tar_gz = False
|
||||
if is_gzip:
|
||||
try:
|
||||
with tarfile.open(fileobj=archive_file, mode="r:gz"):
|
||||
is_tar_gz = True
|
||||
except tarfile.TarError:
|
||||
pass
|
||||
archive_file.seek(0)
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
|
||||
|
||||
actual_format: ArchiveFormat | None
|
||||
if is_zip and not is_tar_gz:
|
||||
actual_format = "zip"
|
||||
elif is_tar_gz and not is_zip:
|
||||
actual_format = "tar.gz"
|
||||
else:
|
||||
actual_format = None
|
||||
if declared_format is None:
|
||||
if actual_format is None:
|
||||
_raise(
|
||||
error_type,
|
||||
"Unsupported archive format; expected .zip, .tar.gz, or .tgz",
|
||||
)
|
||||
declared_format = actual_format
|
||||
if actual_format != declared_format:
|
||||
actual_label = actual_format or "invalid/unsupported data"
|
||||
_raise(
|
||||
error_type,
|
||||
f"Archive format mismatch: expected {declared_format}, got {actual_label}",
|
||||
)
|
||||
return declared_format
|
||||
|
||||
|
||||
def _ip_address_without_scope(
|
||||
@@ -418,7 +292,6 @@ def build_safe_download_path(
|
||||
*,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
label: str = "archive",
|
||||
suffix: str = ".zip",
|
||||
) -> Path:
|
||||
"""Build a portable single-component archive path inside *target_dir*."""
|
||||
if not isinstance(identifier, str) or not isinstance(version, str):
|
||||
@@ -428,9 +301,7 @@ def build_safe_download_path(
|
||||
f"{identifier!r} and {version!r}",
|
||||
)
|
||||
|
||||
if suffix not in {".zip", ".tar.gz", ".tgz"}:
|
||||
_raise(error_type, f"Unsupported archive download suffix: {suffix!r}")
|
||||
filename = f"{identifier}-{version}{suffix}"
|
||||
filename = f"{identifier}-{version}.zip"
|
||||
try:
|
||||
filename_too_long = (
|
||||
len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
|
||||
@@ -507,25 +378,24 @@ def read_zip_member_limited(
|
||||
)
|
||||
|
||||
|
||||
def normalize_archive_member_name(
|
||||
def normalize_zip_member_name(
|
||||
name: str,
|
||||
*,
|
||||
archive_label: str = "archive",
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
) -> str:
|
||||
"""Return a normalized, portable archive member name or raise if unsafe."""
|
||||
"""Return a normalized, portable ZIP member name or raise if unsafe."""
|
||||
if "\x00" in name:
|
||||
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
|
||||
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
|
||||
|
||||
normalized = name.replace("\\", "/")
|
||||
try:
|
||||
encoded_name = normalized.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
|
||||
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
|
||||
if len(encoded_name) > MAX_ZIP_PATH_BYTES:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in {archive_label} archive: {name!r} "
|
||||
f"Unsafe path in ZIP archive: {name!r} "
|
||||
"(not portable across supported filesystems)",
|
||||
)
|
||||
path = PurePosixPath(normalized)
|
||||
@@ -545,8 +415,7 @@ def normalize_archive_member_name(
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in {archive_label} archive: {name!r} "
|
||||
"(potential path traversal)",
|
||||
f"Unsafe path in ZIP archive: {name!r} (potential path traversal)",
|
||||
)
|
||||
for part in raw_parts:
|
||||
reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
|
||||
@@ -563,26 +432,13 @@ def normalize_archive_member_name(
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in {archive_label} archive: {name!r} "
|
||||
f"Unsafe path in ZIP archive: {name!r} "
|
||||
"(not portable across supported filesystems)",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_zip_member_name(
|
||||
name: str,
|
||||
*,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
) -> str:
|
||||
"""Return a normalized, portable ZIP member name or raise if unsafe."""
|
||||
return normalize_archive_member_name(
|
||||
name,
|
||||
archive_label="ZIP",
|
||||
error_type=error_type,
|
||||
)
|
||||
|
||||
|
||||
def portable_archive_path_key(name: str) -> tuple[str, ...]:
|
||||
def portable_zip_path_key(name: str) -> tuple[str, ...]:
|
||||
"""Return a comparison key for filesystems with case/Unicode folding."""
|
||||
normalized_name = name.replace("\\", "/")
|
||||
return tuple(
|
||||
@@ -591,11 +447,6 @@ def portable_archive_path_key(name: str) -> tuple[str, ...]:
|
||||
)
|
||||
|
||||
|
||||
def portable_zip_path_key(name: str) -> tuple[str, ...]:
|
||||
"""Backward-compatible ZIP-specific alias for portable archive keys."""
|
||||
return portable_archive_path_key(name)
|
||||
|
||||
|
||||
def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
|
||||
_raise(
|
||||
error_type,
|
||||
@@ -854,7 +705,6 @@ def _preflight_zip_central_directory(
|
||||
def open_zip_bounded(
|
||||
zip_path: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
max_entries: int = MAX_ZIP_ENTRIES,
|
||||
) -> Iterator[zipfile.ZipFile]:
|
||||
@@ -862,11 +712,10 @@ def open_zip_bounded(
|
||||
_validate_non_negative_int(max_entries, "max_entries")
|
||||
zip_path = Path(zip_path)
|
||||
with ExitStack() as stack:
|
||||
if archive_file is None:
|
||||
try:
|
||||
archive_file = stack.enter_context(zip_path.open("rb"))
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
||||
try:
|
||||
archive_file = stack.enter_context(zip_path.open("rb"))
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
||||
try:
|
||||
_preflight_zip_central_directory(
|
||||
archive_file,
|
||||
@@ -888,7 +737,6 @@ def safe_extract_zip(
|
||||
zip_path: Path,
|
||||
target_dir: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
max_entries: int = MAX_ZIP_ENTRIES,
|
||||
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
||||
@@ -904,7 +752,6 @@ def safe_extract_zip(
|
||||
|
||||
with open_zip_bounded(
|
||||
zip_path,
|
||||
archive_file=archive_file,
|
||||
error_type=error_type,
|
||||
max_entries=max_entries,
|
||||
) as zf:
|
||||
@@ -927,7 +774,7 @@ def safe_extract_zip(
|
||||
error_type=error_type,
|
||||
)
|
||||
is_dir = member.is_dir() or normalized_name.endswith("/")
|
||||
path_key = portable_archive_path_key(normalized_name)
|
||||
path_key = portable_zip_path_key(normalized_name)
|
||||
|
||||
existing = validated_paths.get(path_key)
|
||||
if existing is not None:
|
||||
@@ -1047,211 +894,3 @@ def safe_extract_zip(
|
||||
)
|
||||
if limit_error is not None:
|
||||
_raise(error_type, limit_error)
|
||||
|
||||
|
||||
def safe_extract_tar(
|
||||
archive_path: Path,
|
||||
target_dir: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
max_entries: int = MAX_ZIP_ENTRIES,
|
||||
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
||||
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
|
||||
) -> None:
|
||||
"""Extract a gzip-compressed tar after ZIP-equivalent safety validation."""
|
||||
_validate_non_negative_int(max_entries, "max_entries")
|
||||
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
|
||||
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
|
||||
archive_path = Path(archive_path)
|
||||
try:
|
||||
target_root = target_dir.resolve()
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid tar extraction target: {target_dir}", exc)
|
||||
|
||||
try:
|
||||
if archive_file is not None:
|
||||
archive_file.seek(0)
|
||||
archive = tarfile.open(
|
||||
archive_path if archive_file is None else None,
|
||||
mode="r:gz",
|
||||
fileobj=archive_file,
|
||||
)
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
|
||||
|
||||
with archive:
|
||||
validated: list[tuple[tarfile.TarInfo, str, bool]] = []
|
||||
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
|
||||
total_size = 0
|
||||
try:
|
||||
for index, member in enumerate(archive, start=1):
|
||||
if index > max_entries:
|
||||
_raise(
|
||||
error_type,
|
||||
f"tar.gz archive contains too many entries "
|
||||
f"({index} > {max_entries})",
|
||||
)
|
||||
normalized_name = normalize_archive_member_name(
|
||||
member.name,
|
||||
archive_label="tar.gz",
|
||||
error_type=error_type,
|
||||
)
|
||||
is_dir = member.isdir()
|
||||
if member.issym():
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe symlink in tar.gz archive: {member.name}",
|
||||
)
|
||||
if member.islnk():
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe hard link in tar.gz archive: {member.name}",
|
||||
)
|
||||
if not is_dir and not member.isreg():
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe member type in tar.gz archive: {member.name}",
|
||||
)
|
||||
|
||||
path_key = portable_archive_path_key(normalized_name)
|
||||
existing = validated_paths.get(path_key)
|
||||
if existing is not None:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Conflicting path in tar.gz archive: {member.name} "
|
||||
f"conflicts with {existing[0]}",
|
||||
)
|
||||
validated_paths[path_key] = (member.name, is_dir)
|
||||
|
||||
member_path = (target_dir / normalized_name).resolve()
|
||||
try:
|
||||
member_path.relative_to(target_root)
|
||||
except ValueError:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in tar.gz archive: {member.name} "
|
||||
"(potential path traversal)",
|
||||
)
|
||||
|
||||
if not is_dir:
|
||||
if member.size > max_member_bytes:
|
||||
_raise(
|
||||
error_type,
|
||||
f"tar.gz member {member.name} exceeds maximum size "
|
||||
f"of {max_member_bytes} bytes",
|
||||
)
|
||||
total_size += member.size
|
||||
if total_size > max_total_bytes:
|
||||
_raise(
|
||||
error_type,
|
||||
f"tar.gz archive exceeds maximum uncompressed size "
|
||||
f"of {max_total_bytes} bytes",
|
||||
)
|
||||
validated.append((member, normalized_name, is_dir))
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Invalid tar.gz archive: {archive_path}",
|
||||
exc,
|
||||
)
|
||||
|
||||
for (
|
||||
(path_key, (original, is_dir)),
|
||||
(next_key, (next_original, _next_is_dir)),
|
||||
) in pairwise(sorted(validated_paths.items())):
|
||||
if (
|
||||
not is_dir
|
||||
and len(next_key) > len(path_key)
|
||||
and next_key[: len(path_key)] == path_key
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Conflicting path in tar.gz archive: {original} conflicts "
|
||||
f"with {next_original}",
|
||||
)
|
||||
|
||||
total_written = 0
|
||||
for member, normalized_name, is_dir in validated:
|
||||
member_path = target_dir / normalized_name
|
||||
if is_dir:
|
||||
try:
|
||||
member_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Failed to create tar.gz directory {member.name}: {exc}",
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
member_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Failed to read tar.gz member {member.name}",
|
||||
)
|
||||
written = 0
|
||||
limit_error: str | None = None
|
||||
with source, member_path.open("wb") as dest:
|
||||
while True:
|
||||
chunk = source.read(READ_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > max_member_bytes:
|
||||
limit_error = (
|
||||
f"tar.gz member {member.name} exceeds maximum size "
|
||||
f"of {max_member_bytes} bytes"
|
||||
)
|
||||
break
|
||||
total_written += len(chunk)
|
||||
if total_written > max_total_bytes:
|
||||
limit_error = (
|
||||
f"tar.gz archive exceeds maximum uncompressed size "
|
||||
f"of {max_total_bytes} bytes"
|
||||
)
|
||||
break
|
||||
dest.write(chunk)
|
||||
except Exception as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Failed to extract tar.gz member {member.name}: {exc}",
|
||||
exc,
|
||||
)
|
||||
if limit_error is not None:
|
||||
_raise(error_type, limit_error)
|
||||
|
||||
|
||||
def safe_extract_archive(
|
||||
archive_path: Path,
|
||||
target_dir: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
source_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
max_entries: int = MAX_ZIP_ENTRIES,
|
||||
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
||||
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
|
||||
) -> ArchiveFormat:
|
||||
"""Detect and securely extract a supported archive."""
|
||||
archive_format = detect_archive_format(
|
||||
archive_path,
|
||||
archive_file=archive_file,
|
||||
source_name=source_name,
|
||||
content_type=content_type,
|
||||
error_type=error_type,
|
||||
)
|
||||
extractor = safe_extract_zip if archive_format == "zip" else safe_extract_tar
|
||||
extractor(
|
||||
archive_path,
|
||||
target_dir,
|
||||
archive_file=archive_file,
|
||||
error_type=error_type,
|
||||
max_entries=max_entries,
|
||||
max_member_bytes=max_member_bytes,
|
||||
max_total_bytes=max_total_bytes,
|
||||
)
|
||||
return archive_format
|
||||
|
||||
@@ -27,7 +27,6 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
|
||||
from ._console import console
|
||||
@@ -1231,10 +1230,7 @@ def self_upgrade(
|
||||
tag: str | None = typer.Option(
|
||||
None,
|
||||
"--tag",
|
||||
# Typer renders help through Rich, so escape the literal bracket (\[)
|
||||
# or `[suffix]` is parsed as a style tag and dropped -- `--help` then
|
||||
# advertises only `(vX.Y.Z)`, contradicting docs/upgrade.md and README.
|
||||
help="Pin the target version (vX.Y.Z\\[suffix]). Without --tag, the "
|
||||
help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the "
|
||||
"latest stable release is resolved via GitHub Releases.",
|
||||
),
|
||||
) -> None:
|
||||
@@ -1274,14 +1270,7 @@ def self_upgrade(
|
||||
try:
|
||||
tag = _validate_tag(tag)
|
||||
except typer.BadParameter as exc:
|
||||
# Escape at the print site rather than baking `\[` into
|
||||
# _INVALID_TAG_MESSAGE: the message is also raised through
|
||||
# typer.BadParameter, which Click renders without Rich, so the
|
||||
# constant must stay plain text. Unescaped, Rich parses the literal
|
||||
# `[suffix]` as a style tag and drops it, leaving the user with
|
||||
# "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
|
||||
# accepted form when -rc1 / .dev0 / +build.42 are all valid.
|
||||
console.print(_escape_markup(str(exc)), soft_wrap=True)
|
||||
console.print(str(exc), soft_wrap=True)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
plan, failure_reason = _build_upgrade_plan(target_tag_override=tag)
|
||||
|
||||
@@ -302,20 +302,8 @@ class CommandRegistrar:
|
||||
toml_lines = []
|
||||
|
||||
if "description" in frontmatter:
|
||||
# Frontmatter comes from ``yaml.safe_load``, so ``description`` can
|
||||
# be any YAML type: ``description:`` with no value yields None,
|
||||
# ``description: 2`` an int, an unquoted ``true`` a bool.
|
||||
# ``_render_basic_toml_string`` iterates the value and calls ord()
|
||||
# on each character, so a non-string raises a raw TypeError -- and a
|
||||
# list of single-character items is silently concatenated into a
|
||||
# wrong value (``["a", "b"]`` -> ``"ab"``). Coerce first, matching
|
||||
# ``render_yaml_command`` below and ``TomlIntegration
|
||||
# ._extract_description``, which both normalise it already.
|
||||
description = frontmatter["description"]
|
||||
if not isinstance(description, str):
|
||||
description = str(description) if description is not None else ""
|
||||
toml_lines.append(
|
||||
f"description = {self._render_basic_toml_string(description)}"
|
||||
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
|
||||
)
|
||||
toml_lines.append("")
|
||||
|
||||
|
||||
@@ -96,19 +96,19 @@ class BundleManifest:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Manifest must be a YAML mapping at the top level.")
|
||||
|
||||
schema_version = _text(data.get("schema_version"))
|
||||
schema_version = str(data.get("schema_version", "")).strip()
|
||||
|
||||
bundle_raw = data.get("bundle")
|
||||
if not isinstance(bundle_raw, dict):
|
||||
raise BundlerError("Manifest is missing the required 'bundle' mapping.")
|
||||
meta = BundleMeta(
|
||||
id=_text(bundle_raw.get("id")),
|
||||
name=_text(bundle_raw.get("name")),
|
||||
version=_text(bundle_raw.get("version")),
|
||||
role=_text(bundle_raw.get("role")),
|
||||
description=_text(bundle_raw.get("description")),
|
||||
author=_text(bundle_raw.get("author")),
|
||||
license=_text(bundle_raw.get("license")),
|
||||
id=str(bundle_raw.get("id", "")).strip(),
|
||||
name=str(bundle_raw.get("name", "")).strip(),
|
||||
version=str(bundle_raw.get("version", "")).strip(),
|
||||
role=str(bundle_raw.get("role", "")).strip(),
|
||||
description=str(bundle_raw.get("description", "")).strip(),
|
||||
author=str(bundle_raw.get("author", "")).strip(),
|
||||
license=str(bundle_raw.get("license", "")).strip(),
|
||||
)
|
||||
|
||||
requires_raw = data.get("requires")
|
||||
@@ -117,7 +117,7 @@ class BundleManifest:
|
||||
elif not isinstance(requires_raw, dict):
|
||||
raise BundlerError("'requires' must be a mapping when present.")
|
||||
requires = Requires(
|
||||
speckit_version=_text(requires_raw.get("speckit_version")),
|
||||
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
|
||||
tools=_parse_str_list(requires_raw.get("tools"), "requires.tools"),
|
||||
mcp=_parse_str_list(requires_raw.get("mcp"), "requires.mcp"),
|
||||
)
|
||||
@@ -220,22 +220,6 @@ class BundleManifest:
|
||||
return self.integration is None
|
||||
|
||||
|
||||
def _text(raw: Any) -> str:
|
||||
"""Coerce a manifest scalar into stripped text, mapping an explicit null to ``""``.
|
||||
|
||||
A ``.get(key, "")`` default only covers a *missing* key. A key that is
|
||||
present but null -- how YAML spells an empty field (``author:`` with nothing
|
||||
after it) -- yields ``None``, and ``str(None)`` is the literal ``"None"``.
|
||||
That text is non-empty, so it sailed past the ``if not value`` required-field
|
||||
checks in :meth:`BundleManifest.structural_errors`: an empty required field
|
||||
was silently accepted and the bundle shipped ``"None"`` as its
|
||||
author/license/description.
|
||||
"""
|
||||
if raw is None:
|
||||
return ""
|
||||
return str(raw).strip()
|
||||
|
||||
|
||||
def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]:
|
||||
"""Coerce a manifest list-of-strings field into a tuple of strings.
|
||||
|
||||
@@ -263,7 +247,7 @@ def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
|
||||
refs.append(
|
||||
ComponentRef(
|
||||
kind=kind,
|
||||
id=_text(item.get("id")),
|
||||
id=str(item.get("id", "")).strip(),
|
||||
version=(str(item["version"]).strip() if item.get("version") else None),
|
||||
source=(str(item["source"]).strip() if item.get("source") else None),
|
||||
priority=priority,
|
||||
|
||||
@@ -227,10 +227,6 @@ class DefaultPrimitiveInstaller:
|
||||
manager = self._manager_for(component, project_root)
|
||||
manager.install(component)
|
||||
|
||||
def refresh(self, project_root: Path, component: ComponentRef) -> None:
|
||||
manager = self._manager_for(component, project_root)
|
||||
manager.refresh(component)
|
||||
|
||||
def remove(self, project_root: Path, component: ComponentRef) -> None:
|
||||
manager = self._manager_for(component, project_root)
|
||||
manager.remove(component)
|
||||
|
||||
@@ -85,17 +85,11 @@ def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None:
|
||||
|
||||
|
||||
class _KindManager(Protocol):
|
||||
def is_installed(self, component: ComponentRef) -> bool:
|
||||
pass
|
||||
def is_installed(self, component: ComponentRef) -> bool: ...
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
pass
|
||||
def install(self, component: ComponentRef) -> None: ...
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
pass
|
||||
|
||||
def remove(self, component: ComponentRef) -> None:
|
||||
pass
|
||||
def remove(self, component: ComponentRef) -> None: ...
|
||||
|
||||
|
||||
def primitive_manager(
|
||||
@@ -157,12 +151,6 @@ class _PresetKindManager:
|
||||
return False
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
self._do_install(component, force=False)
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
self._do_install(component, force=True)
|
||||
|
||||
def _do_install(self, component: ComponentRef, *, force: bool) -> None:
|
||||
from ... import get_speckit_version
|
||||
from ..._assets import _locate_bundled_preset
|
||||
|
||||
@@ -180,9 +168,7 @@ class _PresetKindManager:
|
||||
component.version,
|
||||
_bundled_manifest_version(bundled / "preset.yml", "preset"),
|
||||
)
|
||||
self._manager.install_from_directory(
|
||||
bundled, speckit_version, priority, **({"force": True} if force else {})
|
||||
)
|
||||
self._manager.install_from_directory(bundled, speckit_version, priority)
|
||||
return
|
||||
|
||||
if not self._allow_network:
|
||||
@@ -208,9 +194,7 @@ class _PresetKindManager:
|
||||
)
|
||||
zip_path = catalog.download_pack(component.id)
|
||||
try:
|
||||
self._manager.install_from_zip(
|
||||
zip_path, speckit_version, priority, **({"force": True} if force else {})
|
||||
)
|
||||
self._manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
if zip_path.exists():
|
||||
@@ -240,12 +224,6 @@ class _ExtensionKindManager:
|
||||
return False
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
self._do_install(component, force=False)
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
self._do_install(component, force=True)
|
||||
|
||||
def _do_install(self, component: ComponentRef, *, force: bool) -> None:
|
||||
from ... import get_speckit_version
|
||||
from ..._assets import _locate_bundled_extension
|
||||
|
||||
@@ -264,7 +242,7 @@ class _ExtensionKindManager:
|
||||
_bundled_manifest_version(bundled / "extension.yml", "extension"),
|
||||
)
|
||||
self._manager.install_from_directory(
|
||||
bundled, speckit_version, priority=priority, force=force
|
||||
bundled, speckit_version, priority=priority
|
||||
)
|
||||
return
|
||||
|
||||
@@ -294,7 +272,7 @@ class _ExtensionKindManager:
|
||||
zip_path = catalog.download_extension(component.id)
|
||||
try:
|
||||
self._manager.install_from_zip(
|
||||
zip_path, speckit_version, priority=priority, force=force
|
||||
zip_path, speckit_version, priority=priority
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -340,11 +318,6 @@ class _WorkflowKindManager:
|
||||
lambda: workflow_add(component.id),
|
||||
)
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
# workflow_add is idempotent for already-installed workflows; delegate
|
||||
# to the standard install path which handles version refresh correctly.
|
||||
self.install(component)
|
||||
|
||||
def _assert_pinned_version(self, component: ComponentRef) -> None:
|
||||
if not component.version:
|
||||
return
|
||||
@@ -405,35 +378,6 @@ class _StepKindManager:
|
||||
lambda: workflow_step_add(component.id),
|
||||
)
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
# Preserve an existing step until we've validated we can perform refresh.
|
||||
# For already-installed steps, keep a backup and restore it if the
|
||||
# remove+reinstall path fails.
|
||||
if not (self._allow_network and self.is_installed(component)):
|
||||
self.install(component)
|
||||
return
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
step_dir = self._registry.steps_dir / component.id
|
||||
metadata = self._registry.get(component.id)
|
||||
backup_dir = Path(tempfile.mkdtemp(prefix="speckit-step-refresh-")) / component.id
|
||||
try:
|
||||
if step_dir.exists():
|
||||
shutil.copytree(step_dir, backup_dir)
|
||||
self.remove(component)
|
||||
try:
|
||||
self.install(component)
|
||||
except BundlerError:
|
||||
if backup_dir.exists():
|
||||
shutil.copytree(backup_dir, step_dir, dirs_exist_ok=True)
|
||||
if metadata is not None and not self._registry.is_installed(component.id):
|
||||
self._registry.add(component.id, metadata)
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(backup_dir.parent, ignore_errors=True)
|
||||
|
||||
def remove(self, component: ComponentRef) -> None:
|
||||
from ... import workflow_step_remove
|
||||
|
||||
|
||||
@@ -74,13 +74,6 @@ class CatalogStackBase:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
# Accessing ``port`` performs urllib's syntax/range validation;
|
||||
# ``hostname`` alone does not, so a non-numeric or out-of-range
|
||||
# port would otherwise pass validation here and only fail later,
|
||||
# at fetch time, as an error this module does not translate --
|
||||
# a raw http.client.InvalidURL for a non-numeric port, and a
|
||||
# socket-layer failure for one that is merely out of range.
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
raise cls._error(f"Catalog URL is malformed: {url}") from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""specify event * command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import typer
|
||||
|
||||
event_app = typer.Typer(
|
||||
name="event",
|
||||
help="Manage and execute event-driven commands",
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
|
||||
@event_app.command("run")
|
||||
def event_run(
|
||||
command_name: str = typer.Argument(..., help="Name of the command to execute"),
|
||||
event_name: str = typer.Argument(..., help="Canonical event name (e.g., session_start)"),
|
||||
timeout: int = typer.Argument(
|
||||
120, help="Per-handler timeout in seconds (passed through from the native hook config)"
|
||||
),
|
||||
):
|
||||
"""Resolve and run an event-driven command script with stdin payload."""
|
||||
from ..events import resolve_and_run_event_command
|
||||
|
||||
# Read payload from stdin if available
|
||||
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
|
||||
|
||||
# Run the event command
|
||||
project_root = Path.cwd() # The agent runs events from project root
|
||||
exit_code = resolve_and_run_event_command(
|
||||
command_name, event_name, payload, project_root, timeout=timeout
|
||||
)
|
||||
raise typer.Exit(code=exit_code)
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
app.add_typer(event_app, name="event")
|
||||
@@ -443,20 +443,12 @@ def register(app: typer.Typer) -> None:
|
||||
if extra:
|
||||
integration_parsed_options.update(extra)
|
||||
|
||||
from ..events import resolve_events
|
||||
events_map = resolve_events(
|
||||
resolved_integration.key,
|
||||
resolved_integration.config,
|
||||
project_path,
|
||||
integration_parsed_options or None,
|
||||
)
|
||||
resolved_integration.setup(
|
||||
project_path,
|
||||
manifest,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
events=events_map,
|
||||
)
|
||||
manifest.save()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Set
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
import pathspec
|
||||
import yaml
|
||||
@@ -29,14 +29,11 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
from .._assets import _locate_core_pack, _repo_root
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
MAX_JSON_CATALOG_BYTES,
|
||||
build_safe_download_path,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
read_response_limited,
|
||||
safe_extract_archive,
|
||||
safe_extract_zip,
|
||||
)
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
|
||||
@@ -266,25 +263,8 @@ class ExtensionManifest:
|
||||
f"(expected {self.SCHEMA_VERSION})"
|
||||
)
|
||||
|
||||
# The REQUIRED_FIELDS loop above only checks key PRESENCE, so a section
|
||||
# that is written but left empty (``provides:`` -> None) or given the
|
||||
# wrong shape (``provides: []``) passes it and then fails on first use:
|
||||
# ``field not in None`` raises TypeError and ``None.get(...)`` raises
|
||||
# AttributeError. Neither is a ValidationError, so both escape the
|
||||
# callers that already handle malformed manifests -- list_installed()'s
|
||||
# "Corrupted extension" fallback catches ValidationError only, so one bad
|
||||
# extension made ``specify extension list`` exit 1 with a raw
|
||||
# AttributeError instead of listing the rest. Guard each required
|
||||
# section's shape, mirroring the nested guards below ("Invalid
|
||||
# provides.commands: expected a list", "Invalid hooks: expected a
|
||||
# mapping") and _load_yaml's document-root check.
|
||||
|
||||
# Validate extension metadata
|
||||
ext = self.data["extension"]
|
||||
if not isinstance(ext, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
|
||||
)
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
if field not in ext:
|
||||
raise ValidationError(f"Missing extension.{field}")
|
||||
@@ -319,37 +299,24 @@ class ExtensionManifest:
|
||||
|
||||
# Validate requires section
|
||||
requires = self.data["requires"]
|
||||
if not isinstance(requires, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid requires: expected a mapping, got {type(requires).__name__}"
|
||||
)
|
||||
if "speckit_version" not in requires:
|
||||
raise ValidationError("Missing requires.speckit_version")
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
if not isinstance(provides, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid provides: expected a mapping, got {type(provides).__name__}"
|
||||
)
|
||||
commands = provides.get("commands", [])
|
||||
hooks = self.data.get("hooks")
|
||||
events = self.data.get("events")
|
||||
|
||||
if "commands" in provides and not isinstance(commands, list):
|
||||
raise ValidationError("Invalid provides.commands: expected a list")
|
||||
if "hooks" in self.data and not isinstance(hooks, dict):
|
||||
raise ValidationError("Invalid hooks: expected a mapping")
|
||||
if "events" in self.data:
|
||||
from ..events import validate_events
|
||||
validate_events(self.data)
|
||||
|
||||
has_commands = bool(commands)
|
||||
has_hooks = bool(hooks)
|
||||
has_events = bool(events)
|
||||
|
||||
if not has_commands and not has_hooks and not has_events:
|
||||
raise ValidationError("Extension must provide at least one command, hook, or event")
|
||||
if not has_commands and not has_hooks:
|
||||
raise ValidationError("Extension must provide at least one command or hook")
|
||||
|
||||
# Validate hook values (if present).
|
||||
# Each event is a single mapping or a list of mappings.
|
||||
@@ -473,33 +440,6 @@ class ExtensionManifest:
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
|
||||
# C11: apply the same rename + alias-lift canonicalization to event
|
||||
# command references. Without this, an event referencing a command
|
||||
# that was auto-corrected (e.g. speckit.boot -> speckit.<id>.boot)
|
||||
# keeps the obsolete name, dispatch reports no command, and the event
|
||||
# silently no-ops.
|
||||
events_data = self.data.get("events", {})
|
||||
if isinstance(events_data, dict):
|
||||
for event_name, event_config in events_data.items():
|
||||
if not isinstance(event_config, dict):
|
||||
continue
|
||||
command_ref = event_config.get("command")
|
||||
if not isinstance(command_ref, str):
|
||||
continue
|
||||
after_rename = rename_map.get(command_ref, command_ref)
|
||||
parts = after_rename.split(".")
|
||||
if len(parts) == 2 and parts[0] == ext["id"]:
|
||||
final_ref = f"speckit.{ext['id']}.{parts[1]}"
|
||||
else:
|
||||
final_ref = after_rename
|
||||
if final_ref != command_ref:
|
||||
event_config["command"] = final_ref
|
||||
self.warnings.append(
|
||||
f"Event '{event_name}' referenced command '{command_ref}'; "
|
||||
f"updated to canonical form '{final_ref}'. "
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
|
||||
"""Try to auto-correct a non-conforming command name to the required pattern.
|
||||
@@ -569,11 +509,8 @@ class ExtensionManifest:
|
||||
|
||||
def get_hash(self) -> str:
|
||||
"""Calculate SHA256 hash of manifest file."""
|
||||
h = hashlib.sha256()
|
||||
with open(self.path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return f"sha256:{h.hexdigest()}"
|
||||
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
|
||||
|
||||
|
||||
class ExtensionRegistry:
|
||||
@@ -598,7 +535,7 @@ class ExtensionRegistry:
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, "r", encoding="utf-8") as f:
|
||||
with open(self.registry_path, "r") as f:
|
||||
data = json.load(f)
|
||||
# Validate loaded data is a dict (handles corrupted registry files)
|
||||
if not isinstance(data, dict):
|
||||
@@ -614,7 +551,7 @@ class ExtensionRegistry:
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, "w", encoding="utf-8") as f:
|
||||
with open(self.registry_path, "w") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, extension_id: str, metadata: dict):
|
||||
@@ -1330,7 +1267,6 @@ class ExtensionManager:
|
||||
manifest: ExtensionManifest,
|
||||
extension_dir: Path,
|
||||
link_outputs: bool = False,
|
||||
force: bool = False,
|
||||
) -> List[str]:
|
||||
"""Generate SKILL.md files for extension commands as agent skills.
|
||||
|
||||
@@ -1344,11 +1280,6 @@ class ExtensionManager:
|
||||
extension_dir: Installed extension directory.
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
skill files when supported by the OS.
|
||||
force: If True, overwrite existing SKILL.md files even when they
|
||||
are not dev-mode symlinks. Use in the upgrade path, where
|
||||
``setup()`` has just freshly regenerated core-template skill
|
||||
files and the skip guard would otherwise prevent extension
|
||||
content from being layered on top.
|
||||
|
||||
Returns:
|
||||
List of skill names that were created (for registry storage).
|
||||
@@ -1436,16 +1367,13 @@ class ExtensionManager:
|
||||
)
|
||||
# Do not overwrite user-customized skills, but allow dev-mode
|
||||
# symlinks that point back to this extension's generated cache
|
||||
# to be refreshed on a subsequent dev install. In the upgrade
|
||||
# path (force=True) the file was just written by setup(), so
|
||||
# overwriting it with the composed extension content is correct.
|
||||
if not is_expected_dev_symlink and not force:
|
||||
# to be refreshed on a subsequent dev install.
|
||||
if not is_expected_dev_symlink:
|
||||
continue
|
||||
elif skill_dir_preexists and not force:
|
||||
elif skill_dir_preexists:
|
||||
# Never add files to a pre-existing user directory. Without a
|
||||
# verifiable SKILL.md ownership marker, rollback/removal cannot
|
||||
# distinguish our output from unrelated user artifacts.
|
||||
# Skipped when force=True (upgrade path).
|
||||
continue
|
||||
|
||||
# Create skill directory; track whether we created it so we can clean
|
||||
@@ -2406,7 +2334,7 @@ class ExtensionManager:
|
||||
pass # Best-effort; install already committed to the registry.
|
||||
|
||||
# Restore execute bits on shipped POSIX scripts. copytree here (and the
|
||||
# archive extraction in install_from_archive, which delegates here, does
|
||||
# zipfile.extractall in install_from_zip, which delegates to this method) does
|
||||
# not restore a stripped Unix mode, so a bundled *.sh would land non-executable
|
||||
# and a documented `.specify/extensions/<id>/scripts/...` invocation would fail
|
||||
# with "Permission denied". This is the single sink every install route funnels
|
||||
@@ -2425,27 +2353,21 @@ class ExtensionManager:
|
||||
|
||||
return manifest
|
||||
|
||||
def install_from_archive(
|
||||
def install_from_zip(
|
||||
self,
|
||||
archive_path: Path,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
source_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> ExtensionManifest:
|
||||
"""Install an extension from a supported archive.
|
||||
"""Install extension from ZIP file.
|
||||
|
||||
Args:
|
||||
archive_path: Path to a .zip, .tar.gz, or .tgz archive
|
||||
zip_path: Path to extension ZIP file
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
force: If True and extension is already installed, remove it first
|
||||
before proceeding with installation
|
||||
archive_file: Already-open archive stream to consume instead of
|
||||
reopening ``zip_path``
|
||||
|
||||
Returns:
|
||||
Installed extension manifest
|
||||
@@ -2461,14 +2383,7 @@ class ExtensionManager:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
safe_extract_archive(
|
||||
archive_path,
|
||||
temp_path,
|
||||
archive_file=archive_file,
|
||||
source_name=source_name,
|
||||
content_type=content_type,
|
||||
error_type=ValidationError,
|
||||
)
|
||||
safe_extract_zip(zip_path, temp_path, error_type=ValidationError)
|
||||
|
||||
# Find extension directory (may be nested)
|
||||
extension_dir = temp_path
|
||||
@@ -2482,35 +2397,13 @@ class ExtensionManager:
|
||||
manifest_path = extension_dir / "extension.yml"
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise ValidationError("No extension.yml found in archive")
|
||||
raise ValidationError("No extension.yml found in ZIP file")
|
||||
|
||||
# Install from extracted directory
|
||||
return self.install_from_directory(
|
||||
extension_dir, speckit_version, priority=priority, force=force
|
||||
)
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
source_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> ExtensionManifest:
|
||||
"""Backward-compatible wrapper for archive installation."""
|
||||
return self.install_from_archive(
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
archive_file=archive_file,
|
||||
source_name=source_name,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
||||
"""Remove an installed extension.
|
||||
|
||||
@@ -2719,7 +2612,7 @@ class ExtensionManager:
|
||||
if updates:
|
||||
self.registry.update(ext_id, updates)
|
||||
|
||||
def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
|
||||
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
|
||||
"""Register installed, enabled extensions for ``agent_name``.
|
||||
|
||||
Command-file registration is scoped to the explicit ``agent_name``
|
||||
@@ -2837,7 +2730,7 @@ class ExtensionManager:
|
||||
if agent_name == active_agent:
|
||||
try:
|
||||
registered_skills = self._register_extension_skills(
|
||||
manifest, ext_dir, force=force
|
||||
manifest, ext_dir
|
||||
)
|
||||
except Exception as skills_err:
|
||||
# Skills are a companion artifact. If command registration
|
||||
@@ -3828,14 +3721,14 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
def download_extension(
|
||||
self, extension_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download an extension archive from a catalog.
|
||||
"""Download extension ZIP from catalog.
|
||||
|
||||
Args:
|
||||
extension_id: ID of the extension to download
|
||||
target_dir: Directory to save the archive
|
||||
target_dir: Directory to save ZIP file (defaults to temp directory)
|
||||
|
||||
Returns:
|
||||
Path to the downloaded archive
|
||||
Path to downloaded ZIP file
|
||||
|
||||
Raises:
|
||||
ExtensionError: If extension not found or download fails
|
||||
@@ -3894,93 +3787,52 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
target_dir = self.cache_dir / "downloads"
|
||||
target_dir = Path(target_dir)
|
||||
version = ext_info.get("version", "unknown")
|
||||
declared_format = archive_format_from_name(download_url)
|
||||
build_safe_download_path(
|
||||
zip_path = build_safe_download_path(
|
||||
target_dir,
|
||||
extension_id,
|
||||
version,
|
||||
error_type=ExtensionError,
|
||||
label="extension",
|
||||
suffix=archive_suffix(declared_format or "tar.gz"),
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_download_url = download_url
|
||||
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"}
|
||||
|
||||
staging_path: Path | None = None
|
||||
# Download the ZIP file
|
||||
try:
|
||||
with self._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
archive_data = read_response_limited(
|
||||
zip_data = read_response_limited(
|
||||
response,
|
||||
error_type=ExtensionError,
|
||||
label=f"extension '{extension_id}' download",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl()
|
||||
if hasattr(response, "geturl")
|
||||
else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
|
||||
verify_archive_sha256(
|
||||
archive_data, ext_info.get("sha256"), extension_id, ExtensionError
|
||||
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="extension-download-",
|
||||
suffix=".archive",
|
||||
dir=target_dir,
|
||||
delete=False,
|
||||
) as staging_file:
|
||||
staging_path = Path(staging_file.name)
|
||||
staging_file.write(archive_data)
|
||||
archive_format = detect_archive_format(
|
||||
staging_path,
|
||||
source_name=(
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else original_download_url
|
||||
),
|
||||
content_type=content_type,
|
||||
error_type=ExtensionError,
|
||||
)
|
||||
archive_path = build_safe_download_path(
|
||||
target_dir,
|
||||
extension_id,
|
||||
version,
|
||||
error_type=ExtensionError,
|
||||
label="extension",
|
||||
suffix=archive_suffix(archive_format),
|
||||
)
|
||||
os.replace(staging_path, archive_path)
|
||||
staging_path = None
|
||||
return archive_path
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise ExtensionError(
|
||||
f"Failed to download extension from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise ExtensionError(f"Failed to save extension archive: {e}")
|
||||
finally:
|
||||
if staging_path is not None:
|
||||
staging_path.unlink(missing_ok=True)
|
||||
raise ExtensionError(f"Failed to save extension ZIP: {e}")
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
|
||||
self.cache_file.unlink(missing_ok=True)
|
||||
self.cache_metadata_file.unlink(missing_ok=True)
|
||||
if self.cache_file.exists():
|
||||
self.cache_file.unlink()
|
||||
if self.cache_metadata_file.exists():
|
||||
self.cache_metadata_file.unlink()
|
||||
# Also clear any per-URL hash-based cache files
|
||||
if self.cache_dir.exists():
|
||||
for extra_cache in self.cache_dir.glob("catalog-*.json"):
|
||||
|
||||
@@ -8,12 +8,11 @@ which re-fetch from the parent package at call time so test monkeypatching of
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
@@ -27,11 +26,12 @@ from rich.table import Table
|
||||
from .._console import console
|
||||
from .._assets import get_speckit_version
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
normalize_zip_member_name,
|
||||
open_zip_bounded,
|
||||
portable_zip_path_key,
|
||||
read_response_limited,
|
||||
safe_extract_archive,
|
||||
read_zip_member_limited,
|
||||
)
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
|
||||
@@ -71,30 +71,6 @@ def _display_project_path(*args, **kwargs):
|
||||
return _f(*args, **kwargs)
|
||||
|
||||
|
||||
def _refresh_events_and_warn(project_root: Path) -> None:
|
||||
"""Refresh native event config and surface failures (R3).
|
||||
|
||||
The extension has already been added/removed/enabled/disabled by the time
|
||||
this runs, so a refresh failure must not abort the command — but it must
|
||||
be surfaced, because a stale native hook may still be active (e.g. a
|
||||
disabled extension's hook still resolves and runs). Prints a warning with
|
||||
the per-integration failures so the user knows deactivation was incomplete.
|
||||
"""
|
||||
from ..events import EventRefreshError, refresh_integration_events
|
||||
|
||||
try:
|
||||
refresh_integration_events(project_root)
|
||||
except EventRefreshError as exc:
|
||||
console.print(
|
||||
f"\n[yellow]⚠[/yellow] Extension updated, but event refresh failed "
|
||||
f"for {len(exc.failures)} integration(s); a stale native hook may "
|
||||
f"still be active. Re-run [cyan]specify integration upgrade "
|
||||
f"<key>[cyan][/cyan][/cyan] to retry."
|
||||
)
|
||||
for key, detail in exc.failures:
|
||||
console.print(f" {key}: {_escape_markup(detail)}")
|
||||
|
||||
|
||||
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
|
||||
"""Load extension catalog CLI config with user-facing shape errors."""
|
||||
try:
|
||||
@@ -437,278 +413,6 @@ def catalog_remove(
|
||||
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
||||
|
||||
|
||||
# Relative path, below the project root, of the extension URL download cache.
|
||||
_CACHE_REL_PARTS = (".specify", "extensions", ".cache", "downloads")
|
||||
|
||||
|
||||
def _has_secure_dir_fd() -> bool:
|
||||
"""Whether this platform supports the strongest (POSIX) hardening path.
|
||||
|
||||
The descriptor-anchored walk needs ``O_NOFOLLOW`` plus ``dir_fd`` support
|
||||
for ``os.open``/``os.mkdir``/``os.unlink``. When any of those is missing
|
||||
(notably on Windows) the caller falls back to the portable path-wise walk,
|
||||
which reproduces the same guarantees using symlink/reparse-point rejection,
|
||||
resolve-under-root containment checks, and post-open inode-identity
|
||||
verification instead of file descriptors.
|
||||
"""
|
||||
return bool(
|
||||
getattr(os, "O_NOFOLLOW", 0)
|
||||
and os.open in os.supports_dir_fd
|
||||
and os.mkdir in os.supports_dir_fd
|
||||
and os.unlink in os.supports_dir_fd
|
||||
)
|
||||
|
||||
|
||||
def _is_symlink_refusal_errno(exc: OSError) -> bool:
|
||||
"""Whether an ``os.open``/``os.mkdir`` error means a component is a symlink.
|
||||
|
||||
Opening an ``O_NOFOLLOW`` path whose final component is a symlink raises
|
||||
``ELOOP`` on Linux and ``EMLINK`` on some BSDs, while a symlinked component
|
||||
that no longer resolves to a directory surfaces as ``ENOTDIR``.
|
||||
"""
|
||||
return exc.errno in (errno.ELOOP, errno.ENOTDIR, getattr(errno, "EMLINK", -1))
|
||||
|
||||
|
||||
def _verify_leaf_identity(fd: int, path: Path) -> None:
|
||||
"""Confirm ``fd`` still refers to the regular file at ``path``.
|
||||
|
||||
Mirrors the workflow installer's staged-file check: comparing the open
|
||||
descriptor's ``fstat`` against a ``lstat`` of the pathname detects a leaf
|
||||
that was swapped for a symlink/reparse point between creation and use, so
|
||||
the portable (dir_fd-less) path is not vulnerable to an ancestor swap race.
|
||||
"""
|
||||
path_stat = path.stat(follow_symlinks=False)
|
||||
open_stat = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(path_stat.st_mode)
|
||||
or path_stat.st_dev != open_stat.st_dev
|
||||
or path_stat.st_ino != open_stat.st_ino
|
||||
):
|
||||
raise OSError(
|
||||
errno.ENOTDIR, "Download file changed between creation and open"
|
||||
)
|
||||
|
||||
|
||||
def _validate_safe_cache_dir(project_root: Path) -> Path:
|
||||
"""Create and validate the extension URL download cache one component at a
|
||||
time, refusing symlinked/junctioned components on every supported platform."""
|
||||
download_dir = project_root.joinpath(*_CACHE_REL_PARTS)
|
||||
try:
|
||||
if _has_secure_dir_fd():
|
||||
_validate_cache_dir_via_dir_fd(project_root, download_dir)
|
||||
else:
|
||||
_validate_cache_dir_via_paths(project_root, download_dir)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except FileExistsError:
|
||||
console.print(
|
||||
"[red]Error:[/red] Refusing to use symlinked download cache directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
except OSError as exc:
|
||||
if _is_symlink_refusal_errno(exc):
|
||||
console.print(
|
||||
"[red]Error:[/red] Refusing to use symlinked download cache directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(
|
||||
"[red]Error:[/red] Could not prepare download cache directory: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
return download_dir
|
||||
|
||||
|
||||
def _validate_cache_dir_via_dir_fd(project_root: Path, download_dir: Path) -> None:
|
||||
"""POSIX cache-dir walk anchored on ``dir_fd`` + ``O_NOFOLLOW`` descriptors."""
|
||||
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
o_directory = getattr(os, "O_DIRECTORY", 0)
|
||||
o_cloexec = getattr(os, "O_CLOEXEC", 0)
|
||||
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
|
||||
|
||||
project_root_resolved = project_root.resolve()
|
||||
parent_fd = os.open(project_root, walk_flags)
|
||||
current_path = project_root
|
||||
try:
|
||||
for part in _CACHE_REL_PARTS:
|
||||
current_path = current_path / part
|
||||
|
||||
try:
|
||||
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
os.mkdir(part, dir_fd=parent_fd)
|
||||
except FileExistsError:
|
||||
pass
|
||||
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
|
||||
|
||||
try:
|
||||
current_path.resolve().relative_to(project_root_resolved)
|
||||
except (OSError, ValueError):
|
||||
try:
|
||||
os.close(child_fd)
|
||||
except OSError:
|
||||
pass
|
||||
console.print(
|
||||
"[red]Error:[/red] Download cache directory escapes project root"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
os.close(parent_fd)
|
||||
parent_fd = child_fd
|
||||
finally:
|
||||
if parent_fd >= 0:
|
||||
try:
|
||||
os.close(parent_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _validate_cache_dir_via_paths(project_root: Path, download_dir: Path) -> None:
|
||||
"""Portable cache-dir walk for platforms without ``dir_fd`` (e.g. Windows).
|
||||
|
||||
Each component is created individually while a symlink/junction is rejected
|
||||
both before and after creation, and every component is required to resolve
|
||||
back under the project root so a mount-point alias or reparse point cannot
|
||||
redirect the cache outside the project.
|
||||
"""
|
||||
project_root_resolved = project_root.resolve()
|
||||
current_path = project_root
|
||||
for part in _CACHE_REL_PARTS:
|
||||
current_path = current_path / part
|
||||
|
||||
if current_path.is_symlink():
|
||||
console.print(
|
||||
"[red]Error:[/red] Refusing to use symlinked download cache directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
current_path.mkdir()
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
# Re-check after creation: a component swapped for a symlink/junction
|
||||
# (or an existing non-directory) between the check and mkdir is caught
|
||||
# here before the walk descends into it.
|
||||
if current_path.is_symlink() or not current_path.is_dir():
|
||||
console.print(
|
||||
"[red]Error:[/red] Refusing to use symlinked download cache directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
current_path.resolve().relative_to(project_root_resolved)
|
||||
except (OSError, ValueError):
|
||||
console.print(
|
||||
"[red]Error:[/red] Download cache directory escapes project root"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _safe_open_download_zip(
|
||||
project_root: Path, download_dir: Path, zip_filename: str
|
||||
) -> int:
|
||||
"""Exclusively create a download ZIP and return an owned descriptor.
|
||||
|
||||
The archive never persists as a nameable on-disk file: the POSIX path
|
||||
unlinks the leaf immediately after exclusive creation (anonymous inode),
|
||||
while the portable path opens it with ``O_TEMPORARY`` so the OS deletes it
|
||||
when the last handle closes. Installation proceeds entirely through the
|
||||
returned descriptor, removing the pathname-reopen and cleanup-walk TOCTOU
|
||||
classes on every supported platform.
|
||||
"""
|
||||
if _has_secure_dir_fd():
|
||||
return _open_download_zip_via_dir_fd(
|
||||
project_root, download_dir, zip_filename
|
||||
)
|
||||
return _open_download_zip_via_paths(project_root, download_dir, zip_filename)
|
||||
|
||||
|
||||
def _open_download_zip_via_dir_fd(
|
||||
project_root: Path, download_dir: Path, zip_filename: str
|
||||
) -> int:
|
||||
"""POSIX leaf create: descriptor walk, ``O_EXCL`` create, immediate unlink."""
|
||||
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
o_directory = getattr(os, "O_DIRECTORY", 0)
|
||||
o_cloexec = getattr(os, "O_CLOEXEC", 0)
|
||||
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
|
||||
|
||||
rel_parts = download_dir.relative_to(project_root).parts
|
||||
parent_fd = os.open(project_root, walk_flags)
|
||||
try:
|
||||
for part in rel_parts:
|
||||
new_fd = os.open(part, walk_flags, dir_fd=parent_fd)
|
||||
os.close(parent_fd)
|
||||
parent_fd = new_fd
|
||||
|
||||
download_fd = os.open(
|
||||
zip_filename,
|
||||
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_nofollow | o_cloexec,
|
||||
0o600,
|
||||
dir_fd=parent_fd,
|
||||
)
|
||||
try:
|
||||
os.unlink(zip_filename, dir_fd=parent_fd)
|
||||
except OSError:
|
||||
os.close(download_fd)
|
||||
raise
|
||||
return download_fd
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
|
||||
|
||||
def _open_download_zip_via_paths(
|
||||
project_root: Path, download_dir: Path, zip_filename: str
|
||||
) -> int:
|
||||
"""Portable leaf create for platforms without ``dir_fd`` (e.g. Windows).
|
||||
|
||||
The cache directory is re-validated (real directory, under the project
|
||||
root) immediately before an exclusive create. ``O_EXCL`` guarantees an
|
||||
attacker cannot pre-stage the leaf as a symlink/junction, ``O_TEMPORARY``
|
||||
makes the OS delete it on close, and a post-open inode-identity check
|
||||
detects a leaf swapped underneath us. The returned descriptor is the only
|
||||
handle installation ever uses, so the cache pathname is never reopened.
|
||||
"""
|
||||
zip_path = download_dir / zip_filename
|
||||
project_root_resolved = project_root.resolve()
|
||||
|
||||
if download_dir.is_symlink() or not download_dir.is_dir():
|
||||
raise OSError(
|
||||
errno.ENOTDIR, "Download cache directory is not a real directory"
|
||||
)
|
||||
try:
|
||||
download_dir.resolve().relative_to(project_root_resolved)
|
||||
except (OSError, ValueError):
|
||||
raise OSError(errno.ENOTDIR, "Download cache directory escapes project root")
|
||||
if zip_path.is_symlink():
|
||||
raise OSError(errno.ELOOP, "Refusing to write through a symlinked download file")
|
||||
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_BINARY", 0)
|
||||
o_temporary = getattr(os, "O_TEMPORARY", 0)
|
||||
flags |= o_temporary
|
||||
|
||||
download_fd = os.open(zip_path, flags, 0o600)
|
||||
try:
|
||||
_verify_leaf_identity(download_fd, zip_path)
|
||||
except OSError:
|
||||
os.close(download_fd)
|
||||
# Without O_TEMPORARY the leaf is not auto-deleted, so remove the file
|
||||
# we just exclusively created (best effort, never through a symlink).
|
||||
if not o_temporary:
|
||||
try:
|
||||
if not zip_path.is_symlink():
|
||||
zip_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return download_fd
|
||||
|
||||
|
||||
@extension_app.command("add")
|
||||
def extension_add(
|
||||
extension: str = typer.Argument(help="Extension name or path"),
|
||||
@@ -810,18 +514,22 @@ def extension_add(
|
||||
)
|
||||
|
||||
elif from_url:
|
||||
# Install from an archive URL.
|
||||
# Install from URL (ZIP file)
|
||||
import io
|
||||
import urllib.error
|
||||
|
||||
console.print(f"Downloading from {safe_url}...")
|
||||
|
||||
download_dir = _validate_safe_cache_dir(project_root)
|
||||
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
|
||||
# Only used for diagnostic messages: the real archive is a
|
||||
# transient inode (unlinked on POSIX, O_TEMPORARY on Windows)
|
||||
# consumed via ``archive_file`` below, so this path is never
|
||||
# opened again.
|
||||
archive_path = download_dir / archive_filename
|
||||
# Download ZIP to temp location
|
||||
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="extension-url-download-",
|
||||
suffix=".zip",
|
||||
dir=download_dir,
|
||||
delete=False,
|
||||
) as download_file:
|
||||
zip_path = Path(download_file.name)
|
||||
|
||||
try:
|
||||
# Use the catalog's authenticated fetch so configured
|
||||
@@ -839,105 +547,37 @@ def extension_add(
|
||||
with dl_catalog._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
archive_data = read_response_limited(
|
||||
zip_data = read_response_limited(
|
||||
response,
|
||||
error_type=ExtensionError,
|
||||
label=f"extension {from_url}",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl()
|
||||
if hasattr(response, "geturl")
|
||||
else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
|
||||
if not zipfile.is_zipfile(io.BytesIO(zip_data)):
|
||||
console.print(
|
||||
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
|
||||
f"(got {len(zip_data)} bytes). This usually means the request "
|
||||
f"was not authenticated and a login/HTML page was returned. "
|
||||
f"Verify the URL is correct and that credentials for its host "
|
||||
f"are configured in ~/.specify/auth.json."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
download_fd = -1
|
||||
download_file = None
|
||||
try:
|
||||
try:
|
||||
download_fd = _safe_open_download_zip(
|
||||
project_root, download_dir, archive_filename
|
||||
)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[red]Error:[/red] Could not safely create download file: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
zip_path.write_bytes(zip_data)
|
||||
|
||||
try:
|
||||
download_file = os.fdopen(download_fd, "w+b")
|
||||
download_fd = -1
|
||||
download_file.write(archive_data)
|
||||
download_file.flush()
|
||||
download_file.seek(0)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[red]Error:[/red] Could not safely write download file: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
format_source = (
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else from_url
|
||||
)
|
||||
try:
|
||||
detect_archive_format(
|
||||
archive_path,
|
||||
archive_file=download_file,
|
||||
source_name=format_source,
|
||||
content_type=content_type,
|
||||
error_type=ExtensionError,
|
||||
)
|
||||
except ExtensionError:
|
||||
console.print(
|
||||
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
|
||||
"or tar.gz/tgz archive "
|
||||
f"(got {len(archive_data)} bytes). This usually means "
|
||||
"the request was not authenticated and a login/HTML page was "
|
||||
"returned. Verify the URL and configured credentials."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Consume the transient inode reserved above rather
|
||||
# than reopening the cache pathname during extraction.
|
||||
try:
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
archive_file=download_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[red]Error:[/red] Could not install extension from downloaded archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
if download_file is not None:
|
||||
try:
|
||||
download_file.close()
|
||||
except OSError:
|
||||
pass
|
||||
elif download_fd >= 0:
|
||||
try:
|
||||
os.close(download_fd)
|
||||
except OSError:
|
||||
pass
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
||||
except urllib.error.URLError as e:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to download from {safe_url}: "
|
||||
f"{_escape_markup(str(e))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
else:
|
||||
# Try bundled extensions first (shipped with spec-kit)
|
||||
bundled_path = _locate_bundled_extension(extension)
|
||||
@@ -996,30 +636,23 @@ def extension_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Download extension archive (use the resolved catalog ID).
|
||||
# Download extension ZIP (use resolved ID, not original argument which may be display name)
|
||||
extension_id = ext_info['id']
|
||||
console.print(f"Downloading {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(ext_info.get('version', 'unknown')))}...")
|
||||
archive_path = catalog.download_extension(extension_id)
|
||||
zip_path = catalog.download_extension(extension_id)
|
||||
|
||||
try:
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
)
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
||||
finally:
|
||||
if archive_path.exists():
|
||||
archive_path.unlink()
|
||||
# Clean up downloaded ZIP
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
console.print("\n[green]✓[/green] Extension installed successfully!")
|
||||
console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})")
|
||||
console.print(f" {_escape_markup(str(manifest.description))}")
|
||||
|
||||
# #1: regenerate native event config for installed event-capable
|
||||
# integrations so the new extension's events take effect immediately.
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
for warning in manifest.warnings:
|
||||
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
|
||||
|
||||
@@ -1126,10 +759,6 @@ def extension_remove(
|
||||
console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/")
|
||||
else:
|
||||
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/")
|
||||
|
||||
# #1: regenerate native event config so the removed extension's events
|
||||
# are stripped from installed integrations.
|
||||
_refresh_events_and_warn(project_root)
|
||||
console.print(f"\nTo reinstall: specify extension add {safe_extension_id}")
|
||||
else:
|
||||
console.print("[red]Error:[/red] Failed to remove extension")
|
||||
@@ -1221,7 +850,7 @@ def extension_search(
|
||||
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
|
||||
console.print(
|
||||
f" Add to an approved catalog with install_allowed: true, "
|
||||
f"or install from an archive URL: specify extension add {safe_id} --from <archive-url>"
|
||||
f"or install from a ZIP URL: specify extension add {safe_id} --from <zip-url>"
|
||||
)
|
||||
console.print()
|
||||
|
||||
@@ -1849,105 +1478,131 @@ def extension_update(
|
||||
backup_hooks[hook_name] = ext_hooks
|
||||
|
||||
# 5. Download new version
|
||||
archive_path = catalog.download_extension(extension_id)
|
||||
zip_path = catalog.download_extension(extension_id)
|
||||
try:
|
||||
# 6. Validate the archive and extension ID before modifying
|
||||
# the existing installation. The shared extractor applies
|
||||
# the same bounded security checks to ZIP and tar archives.
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-update-archive-"
|
||||
) as archive_tmpdir:
|
||||
extracted_root = Path(archive_tmpdir)
|
||||
try:
|
||||
safe_extract_archive(archive_path, extracted_root)
|
||||
except ValueError as exc:
|
||||
# 6. Validate extension ID from ZIP BEFORE modifying installation
|
||||
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
|
||||
with open_zip_bounded(zip_path) as zf:
|
||||
import yaml
|
||||
manifest_data = None
|
||||
manifest_bytes = None
|
||||
namelist = zf.namelist()
|
||||
|
||||
# Read the manifest under a hard size cap: this happens
|
||||
# before install_from_zip()'s safe_extract_zip(), so a
|
||||
# raw zf.open().read() here would bypass that bound and
|
||||
# let a zip-bomb extension.yml exhaust memory.
|
||||
# Normalize separators before choosing the manifest so
|
||||
# this pre-scan cannot approve one entry while extraction
|
||||
# later overwrites it with a backslash alias.
|
||||
manifest_candidates = []
|
||||
archive_entries = []
|
||||
for name in namelist:
|
||||
normalized_name = normalize_zip_member_name(name)
|
||||
parts = normalized_name.removesuffix("/").split(
|
||||
"/"
|
||||
)
|
||||
path_key = portable_zip_path_key(normalized_name)
|
||||
archive_entries.append(
|
||||
(normalized_name, parts)
|
||||
)
|
||||
if (
|
||||
"Conflicting path" in str(exc)
|
||||
and "extension.yml" in str(exc).casefold()
|
||||
len(parts) in {1, 2}
|
||||
and path_key[-1] == "extension.yml"
|
||||
):
|
||||
manifest_candidates.append(
|
||||
(name, normalized_name, path_key)
|
||||
)
|
||||
|
||||
seen_manifest_keys = {}
|
||||
for name, _normalized_name, path_key in manifest_candidates:
|
||||
previous = seen_manifest_keys.get(path_key)
|
||||
if previous is not None:
|
||||
raise ValueError(
|
||||
"Downloaded extension archive contains multiple "
|
||||
"extension.yml manifests"
|
||||
) from exc
|
||||
raise
|
||||
manifest_root = extracted_root
|
||||
top_level = list(extracted_root.iterdir())
|
||||
root_manifest_entries = [
|
||||
entry
|
||||
for entry in top_level
|
||||
if entry.name.casefold() == "extension.yml"
|
||||
]
|
||||
if any(
|
||||
entry.name != "extension.yml"
|
||||
for entry in root_manifest_entries
|
||||
):
|
||||
raise ValueError(
|
||||
"Archive must use canonical 'extension.yml' casing"
|
||||
)
|
||||
canonical_root_manifest = next(
|
||||
)
|
||||
seen_manifest_keys[path_key] = name
|
||||
|
||||
for _name, normalized_name, _path_key in manifest_candidates:
|
||||
if normalized_name.split("/")[-1] != "extension.yml":
|
||||
raise ValueError(
|
||||
"Downloaded extension archive manifest "
|
||||
"filenames must use canonical "
|
||||
"'extension.yml' casing"
|
||||
)
|
||||
|
||||
root_manifest = next(
|
||||
(
|
||||
entry
|
||||
for entry in root_manifest_entries
|
||||
if entry.name == "extension.yml"
|
||||
name
|
||||
for name, _normalized_name, path_key
|
||||
in manifest_candidates
|
||||
if path_key == ("extension.yml",)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if canonical_root_manifest is not None:
|
||||
manifest_path = canonical_root_manifest
|
||||
else:
|
||||
top_level_dirs = [
|
||||
entry for entry in top_level if entry.is_dir()
|
||||
]
|
||||
if len(top_level_dirs) != 1:
|
||||
nested_manifests = [
|
||||
(name, normalized_name)
|
||||
for name, normalized_name, path_key
|
||||
in manifest_candidates
|
||||
if len(path_key) == 2
|
||||
and path_key[-1] == "extension.yml"
|
||||
]
|
||||
manifest_path = root_manifest
|
||||
if manifest_path is None and len(nested_manifests) == 1:
|
||||
manifest_path, normalized_manifest_path = (
|
||||
nested_manifests[0]
|
||||
)
|
||||
manifest_root = normalized_manifest_path.split(
|
||||
"/", 1
|
||||
)[0]
|
||||
top_level_dirs = {
|
||||
parts[0]
|
||||
for normalized_name, parts in archive_entries
|
||||
if (
|
||||
len(parts) > 1
|
||||
or normalized_name.endswith("/")
|
||||
)
|
||||
}
|
||||
if top_level_dirs != {manifest_root}:
|
||||
raise ValueError(
|
||||
"Downloaded extension archive must contain exactly "
|
||||
"Downloaded extension archive with a "
|
||||
"nested extension.yml must contain exactly "
|
||||
"one top-level directory"
|
||||
)
|
||||
manifest_root = top_level_dirs[0]
|
||||
nested_manifest_entries = [
|
||||
entry
|
||||
for entry in manifest_root.iterdir()
|
||||
if entry.name.casefold() == "extension.yml"
|
||||
]
|
||||
if any(
|
||||
entry.name != "extension.yml"
|
||||
for entry in nested_manifest_entries
|
||||
):
|
||||
raise ValueError(
|
||||
"Archive must use canonical 'extension.yml' casing"
|
||||
)
|
||||
manifest_path = next(
|
||||
(
|
||||
entry
|
||||
for entry in nested_manifest_entries
|
||||
if entry.name == "extension.yml"
|
||||
),
|
||||
manifest_root / "extension.yml",
|
||||
|
||||
if manifest_path is not None:
|
||||
manifest_bytes = read_zip_member_limited(
|
||||
zf, manifest_path
|
||||
)
|
||||
if not manifest_path.is_file():
|
||||
raise ValueError(
|
||||
"Downloaded extension archive is missing 'extension.yml'"
|
||||
parsed_manifest = yaml.safe_load(
|
||||
manifest_bytes
|
||||
)
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
parsed_manifest = yaml.safe_load(manifest_bytes)
|
||||
manifest_data = (
|
||||
parsed_manifest if parsed_manifest is not None else {}
|
||||
)
|
||||
manifest_data = (
|
||||
parsed_manifest
|
||||
if parsed_manifest is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
if manifest_data is None:
|
||||
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
|
||||
if not isinstance(manifest_data, dict):
|
||||
raise ValueError(
|
||||
"Invalid extension manifest in downloaded archive: "
|
||||
"expected YAML mapping"
|
||||
"Invalid extension manifest in downloaded archive: expected YAML mapping"
|
||||
)
|
||||
extension_data = manifest_data.get("extension", {})
|
||||
if not isinstance(extension_data, dict):
|
||||
raise ValueError(
|
||||
"Invalid extension manifest in downloaded archive: "
|
||||
"expected 'extension' mapping"
|
||||
"Invalid extension manifest in downloaded archive: expected 'extension' mapping"
|
||||
)
|
||||
|
||||
# Run the same manifest and compatibility validation as a
|
||||
# normal install while the existing extension is still
|
||||
# untouched. Reuse the exact bounded bytes selected above.
|
||||
if manifest_bytes is None:
|
||||
raise ValueError(
|
||||
"Downloaded extension archive is missing 'extension.yml'"
|
||||
)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-update-manifest-"
|
||||
) as manifest_tmpdir:
|
||||
@@ -2143,7 +1798,7 @@ def extension_update(
|
||||
manager.remove(extension_id, keep_config=True)
|
||||
|
||||
# 8. Install new version
|
||||
_ = manager.install_from_zip(archive_path, speckit_version)
|
||||
_ = manager.install_from_zip(zip_path, speckit_version)
|
||||
|
||||
# Restore user config files from backup after successful install.
|
||||
new_extension_dir = manager.extensions_dir / extension_id
|
||||
@@ -2189,12 +1844,12 @@ def extension_update(
|
||||
hook["enabled"] = False
|
||||
hook_executor.save_project_config(config)
|
||||
finally:
|
||||
# Archive cleanup is housekeeping: never replace an install
|
||||
# ZIP cleanup is housekeeping: never replace an install
|
||||
# error or roll back an already committed update because a
|
||||
# scanner temporarily locks the download on Windows.
|
||||
if archive_path.exists():
|
||||
if zip_path.exists():
|
||||
try:
|
||||
archive_path.unlink()
|
||||
zip_path.unlink()
|
||||
except OSError as error:
|
||||
zip_cleanup_error = error
|
||||
|
||||
@@ -2471,13 +2126,6 @@ def extension_update(
|
||||
console.print(f" • {_escape_markup(str(ext_name))}: {_escape_markup(str(error))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# S4: regenerate native event config after a successful update. An
|
||||
# update replaces the installed extension.yml, so any added/removed/
|
||||
# changed event declarations would otherwise leave native configs
|
||||
# stale until a manual integration upgrade.
|
||||
if updated_extensions:
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
except ValidationError as e:
|
||||
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
@@ -2527,10 +2175,6 @@ def extension_enable(
|
||||
|
||||
console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled")
|
||||
|
||||
# #1: regenerate native event config so the enabled extension's events
|
||||
# are re-emitted in installed integrations.
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
|
||||
@extension_app.command("disable")
|
||||
def extension_disable(
|
||||
@@ -2575,10 +2219,6 @@ def extension_disable(
|
||||
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
||||
console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}")
|
||||
|
||||
# #1: regenerate native event config so the disabled extension's events
|
||||
# are stripped from installed integrations.
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
|
||||
@extension_app.command("set-priority")
|
||||
def extension_set_priority(
|
||||
|
||||
@@ -121,7 +121,8 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str
|
||||
def _remove_integration_json(project_root: Path) -> None:
|
||||
"""Remove ``.specify/integration.json`` if it exists."""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
path.unlink(missing_ok=True)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -394,7 +395,6 @@ def _register_extensions_for_agent(
|
||||
agent_key: str,
|
||||
*,
|
||||
continuing: str,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Register all enabled extensions' commands/skills for ``agent_key``.
|
||||
|
||||
@@ -408,11 +408,6 @@ def _register_extensions_for_agent(
|
||||
before registering), so extension *skill* rendering — which is scoped to
|
||||
the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``.
|
||||
|
||||
When ``force=True``, existing skill files are overwritten even when they
|
||||
are not dev-mode symlinks. Pass ``force=True`` in the upgrade path so that
|
||||
extension content is layered on top of the core-template files that
|
||||
``setup()`` just regenerated (fixes the skip-guard bug for skills mode).
|
||||
|
||||
Best-effort: never aborts the surrounding integration operation. Callers
|
||||
invoke it *after* the use/upgrade/switch transaction has committed so a
|
||||
failure here cannot trigger a rollback.
|
||||
@@ -420,7 +415,7 @@ def _register_extensions_for_agent(
|
||||
_best_effort_extension_op(
|
||||
project_root,
|
||||
agent_key,
|
||||
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key, force=force),
|
||||
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key),
|
||||
phase="register extension artifacts for",
|
||||
continuing=continuing,
|
||||
)
|
||||
|
||||
@@ -143,21 +143,12 @@ def integration_install(
|
||||
integration.key, project_root, version=_get_speckit_version()
|
||||
)
|
||||
|
||||
from ..events import resolve_events
|
||||
events_map = resolve_events(
|
||||
integration.key,
|
||||
integration.config,
|
||||
project_root,
|
||||
parsed_options,
|
||||
)
|
||||
|
||||
try:
|
||||
integration.setup(
|
||||
project_root, manifest,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
events=events_map,
|
||||
)
|
||||
manifest.save()
|
||||
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
|
||||
|
||||
@@ -466,20 +466,12 @@ def integration_switch(
|
||||
target_integration.key, project_root, version=_get_speckit_version()
|
||||
)
|
||||
|
||||
from ..events import resolve_events
|
||||
events_map = resolve_events(
|
||||
target_integration.key,
|
||||
target_integration.config,
|
||||
project_root,
|
||||
parsed_options,
|
||||
)
|
||||
try:
|
||||
target_integration.setup(
|
||||
project_root, manifest,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
events=events_map,
|
||||
)
|
||||
manifest.save()
|
||||
_set_default_integration(
|
||||
@@ -771,13 +763,6 @@ def integration_upgrade(
|
||||
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
||||
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
|
||||
|
||||
from ..events import resolve_events
|
||||
events_map = resolve_events(
|
||||
key,
|
||||
integration.config,
|
||||
project_root,
|
||||
parsed_options,
|
||||
)
|
||||
try:
|
||||
integration.setup(
|
||||
project_root,
|
||||
@@ -785,7 +770,6 @@ def integration_upgrade(
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
events=events_map,
|
||||
)
|
||||
settings = _with_integration_setting(
|
||||
current,
|
||||
@@ -886,7 +870,6 @@ def integration_upgrade(
|
||||
_register_extensions_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
force=True,
|
||||
continuing="The integration was upgraded, but installed extensions may need re-registration.",
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
|
||||
@@ -30,7 +30,6 @@ import yaml
|
||||
from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent
|
||||
from .._toml_string import escape_toml_basic as _escape_toml_basic
|
||||
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
|
||||
from ..events import install_integration_events, remove_integration_events
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import IntegrationManifest
|
||||
@@ -160,17 +159,7 @@ class IntegrationBase(ABC):
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
"""Return options this integration accepts. Default: none."""
|
||||
opts = []
|
||||
if bool(getattr(cls, "CANONICAL_TO_NATIVE", None) and getattr(cls, "events_config_file", None)):
|
||||
opts.append(
|
||||
IntegrationOption(
|
||||
"--events",
|
||||
is_flag=False,
|
||||
default="true",
|
||||
help="Enable/disable runtime events (true|false, default: true)",
|
||||
)
|
||||
)
|
||||
return opts
|
||||
return []
|
||||
|
||||
def effective_invoke_separator(
|
||||
self,
|
||||
@@ -491,11 +480,7 @@ class IntegrationBase(ABC):
|
||||
tracking) would otherwise be deleted even though they are still
|
||||
managed. Subclasses list such paths here to protect them.
|
||||
"""
|
||||
exclusions = set()
|
||||
if self.supports_events():
|
||||
from ..events import events_stale_exclusions
|
||||
exclusions.update(events_stale_exclusions(self.key))
|
||||
return exclusions
|
||||
return set()
|
||||
|
||||
def commands_dest(self, project_root: Path) -> Path:
|
||||
"""Return the absolute path to the commands output directory.
|
||||
@@ -931,32 +916,8 @@ class IntegrationBase(ABC):
|
||||
|
||||
Returns ``(removed, skipped)`` file lists.
|
||||
"""
|
||||
self.remove_events(project_root, manifest)
|
||||
return manifest.uninstall(project_root, force=force)
|
||||
|
||||
def emit_events(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
events: dict[str, dict[str, Any]] | None = None,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Emit native event configuration for this integration."""
|
||||
return install_integration_events(self, project_root, manifest, events or {})
|
||||
|
||||
def remove_events(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
) -> None:
|
||||
"""Remove Specify-authored event entries from native config."""
|
||||
remove_integration_events(self, project_root, manifest)
|
||||
|
||||
def supports_events(self) -> bool:
|
||||
"""Return True if this integration supports agent-native events."""
|
||||
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
|
||||
|
||||
# -- Convenience helpers for subclasses -------------------------------
|
||||
|
||||
def install(
|
||||
@@ -1061,12 +1022,6 @@ class MarkdownIntegration(IntegrationBase):
|
||||
created.append(dst_file)
|
||||
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
@@ -1274,12 +1229,6 @@ class TomlIntegration(IntegrationBase):
|
||||
created.append(dst_file)
|
||||
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
@@ -1516,12 +1465,6 @@ class YamlIntegration(IntegrationBase):
|
||||
created.append(dst_file)
|
||||
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
@@ -1798,10 +1741,4 @@ class SkillsIntegration(IntegrationBase):
|
||||
created.append(dst)
|
||||
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
|
||||
return created
|
||||
|
||||
@@ -54,17 +54,6 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "PreToolUse",
|
||||
"post_tool_use": "PostToolUse",
|
||||
"session_end": "SessionEnd",
|
||||
"user_prompt_submit": "UserPromptSubmit",
|
||||
"stop": "Stop",
|
||||
}
|
||||
events_config_file = ".claude/settings.json"
|
||||
events_format = "json-nested"
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
|
||||
@@ -29,17 +29,6 @@ class CodexIntegration(SkillsIntegration):
|
||||
dev_no_symlink = True
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "PreToolUse",
|
||||
"post_tool_use": "PostToolUse",
|
||||
"session_end": "SessionEnd",
|
||||
"user_prompt_submit": "UserPromptSubmit",
|
||||
"stop": "Stop",
|
||||
}
|
||||
events_config_file = ".codex/config.toml"
|
||||
events_format = "toml"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -60,13 +49,11 @@ class CodexIntegration(SkillsIntegration):
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Codex)",
|
||||
)
|
||||
)
|
||||
return opts
|
||||
),
|
||||
]
|
||||
|
||||
@@ -118,19 +118,6 @@ class CopilotIntegration(IntegrationBase):
|
||||
"extension": ".agent.md",
|
||||
}
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "sessionStart",
|
||||
"pre_tool_use": "preToolUse",
|
||||
"post_tool_use": "postToolUse",
|
||||
"session_end": "sessionEnd",
|
||||
"user_prompt_submit": "userPromptSubmitted",
|
||||
# Copilot CLI supports the canonical per-turn stop lifecycle as native
|
||||
# agentStop (U3); mapping it so an extension's stop handler fires.
|
||||
"stop": "agentStop",
|
||||
}
|
||||
events_config_file = ".github/hooks/speckit.json"
|
||||
events_format = "copilot-json"
|
||||
|
||||
# Mutable flag set by setup() — indicates the active scaffolding mode.
|
||||
_skills_mode: bool = False
|
||||
|
||||
@@ -175,19 +162,14 @@ class CopilotIntegration(IntegrationBase):
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
# Compose with super() so the base class declares --events for this
|
||||
# event-capable integration; otherwise --integration-options
|
||||
# "--events false" is rejected as unknown (#9).
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
|
||||
),
|
||||
)
|
||||
return opts
|
||||
]
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the Copilot CLI executable, respecting the env-var override.
|
||||
@@ -346,9 +328,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
be flagged stale and deleted, destroying user settings (and the file
|
||||
the integration still manages).
|
||||
"""
|
||||
exclusions = super().stale_cleanup_exclusions()
|
||||
exclusions.add(".vscode/settings.json")
|
||||
return exclusions
|
||||
return {".vscode/settings.json"}
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject shared hook guidance into Copilot skill content.
|
||||
@@ -375,18 +355,10 @@ class CopilotIntegration(IntegrationBase):
|
||||
parsed_options = parsed_options or {}
|
||||
self._skills_mode = bool(parsed_options.get("skills"))
|
||||
if self._skills_mode:
|
||||
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
|
||||
else:
|
||||
if "skills" not in parsed_options:
|
||||
_warn_legacy_markdown_default()
|
||||
created = self._setup_default(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
return created
|
||||
return self._setup_skills(project_root, manifest, parsed_options, **opts)
|
||||
if "skills" not in parsed_options:
|
||||
_warn_legacy_markdown_default()
|
||||
return self._setup_default(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
def _setup_default(
|
||||
self,
|
||||
@@ -525,7 +497,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
"""
|
||||
try:
|
||||
existing = json.loads(dst.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Cannot parse existing file (likely JSONC with comments).
|
||||
# Skip merge to preserve the user's settings, but show
|
||||
# what they should add manually.
|
||||
|
||||
@@ -38,17 +38,6 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "sessionStart",
|
||||
"pre_tool_use": "preToolUse",
|
||||
"post_tool_use": "postToolUse",
|
||||
"session_end": "sessionEnd",
|
||||
"user_prompt_submit": "beforeSubmitPrompt",
|
||||
"stop": "stop",
|
||||
}
|
||||
events_config_file = ".cursor/hooks.json"
|
||||
events_format = "json-flat"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -103,13 +92,11 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (recommended for Cursor)",
|
||||
)
|
||||
)
|
||||
return opts
|
||||
),
|
||||
]
|
||||
|
||||
@@ -31,20 +31,6 @@ class DevinIntegration(SkillsIntegration):
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "PreToolUse",
|
||||
"post_tool_use": "PostToolUse",
|
||||
"session_end": "SessionEnd",
|
||||
"user_prompt_submit": "UserPromptSubmit",
|
||||
"stop": "Stop",
|
||||
}
|
||||
events_config_file = ".devin/hooks.v1.json"
|
||||
# Devin's hooks.v1.json is a root event map ({"PreToolUse": [...]}) with no
|
||||
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
|
||||
# json-root-nested writer/remover operate directly on the root event keys.
|
||||
events_format = "json-root-nested"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -69,16 +55,11 @@ class DevinIntegration(SkillsIntegration):
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
# Compose with super() so the base class declares --events for this
|
||||
# event-capable integration; otherwise --integration-options
|
||||
# "--events false" is rejected as unknown (#8).
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Devin)",
|
||||
),
|
||||
)
|
||||
return opts
|
||||
]
|
||||
|
||||
@@ -19,22 +19,3 @@ class GeminiIntegration(TomlIntegration):
|
||||
"extension": ".toml",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "BeforeTool",
|
||||
"post_tool_use": "AfterTool",
|
||||
"session_end": "SessionEnd",
|
||||
# Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle
|
||||
# point (S6); its own Claude-hook migration maps UserPromptSubmit to
|
||||
# BeforeAgent. Mapping it so extension handlers fire.
|
||||
"user_prompt_submit": "BeforeAgent",
|
||||
"stop": "AfterAgent",
|
||||
}
|
||||
events_config_file = ".gemini/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
|
||||
# which use seconds. The shared formatter converts via _native_timeout (#7)
|
||||
# so the default 60s becomes 60000ms instead of terminating the dispatcher
|
||||
# after 60ms.
|
||||
events_timeout_unit = "ms"
|
||||
|
||||
@@ -400,19 +400,7 @@ class IntegrationManifest:
|
||||
# Remove the manifest file itself
|
||||
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
|
||||
if remove_manifest and manifest.exists():
|
||||
try:
|
||||
manifest.unlink()
|
||||
except OSError:
|
||||
# An undeletable manifest (read-only file, a directory left at
|
||||
# the path, a Windows lock) must not abort the uninstall after
|
||||
# the tracked files were already removed: the caller would lose
|
||||
# the (removed, skipped) result and never run its post-uninstall
|
||||
# bookkeeping. Report it like any other file we could not
|
||||
# remove, mirroring the path.unlink() guard above. The
|
||||
# empty-parent cleanup below is left unconditional: with the
|
||||
# manifest still on disk its parent is non-empty, so the first
|
||||
# rmdir() raises and breaks immediately.
|
||||
skipped.append(manifest)
|
||||
manifest.unlink()
|
||||
parent = manifest.parent
|
||||
while parent != root:
|
||||
try:
|
||||
@@ -471,10 +459,6 @@ class IntegrationManifest:
|
||||
path = inst.manifest_path
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"Integration manifest at {path} is not valid UTF-8"
|
||||
) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"Integration manifest at {path} contains invalid JSON"
|
||||
|
||||
@@ -20,15 +20,6 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"pre_tool_use": "tool.execute.before",
|
||||
"post_tool_use": "tool.execute.after",
|
||||
"session_start": "session.created",
|
||||
"session_end": "session.deleted",
|
||||
}
|
||||
events_config_file = "opencode.json"
|
||||
events_format = "ts-plugin"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
|
||||
@@ -19,20 +19,3 @@ class QwenIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "PreToolUse",
|
||||
"post_tool_use": "PostToolUse",
|
||||
"session_end": "SessionEnd",
|
||||
"user_prompt_submit": "UserPromptSubmit",
|
||||
"stop": "Stop",
|
||||
}
|
||||
events_config_file = ".qwen/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Qwen Code's command hooks measure timeout in milliseconds (default
|
||||
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
|
||||
# the shared formatter convert the 60s default to 60000ms instead of
|
||||
# emitting timeout: 60 (60 ms), which would terminate the dispatcher
|
||||
# before it starts (U1).
|
||||
events_timeout_unit = "ms"
|
||||
|
||||
@@ -19,23 +19,3 @@ class TabnineIntegration(TomlIntegration):
|
||||
"extension": ".toml",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "BeforeTool",
|
||||
"post_tool_use": "AfterTool",
|
||||
"session_end": "SessionEnd",
|
||||
# Tabnine's Gemini-compatible schema also provides BeforeAgent and
|
||||
# AfterAgent (S7); mapping them so user_prompt_submit and stop
|
||||
# extension handlers fire instead of being skipped.
|
||||
"user_prompt_submit": "BeforeAgent",
|
||||
"stop": "AfterAgent",
|
||||
}
|
||||
events_config_file = ".tabnine/agent/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
|
||||
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
|
||||
# the shared formatter convert the 60s default to 60000ms instead of
|
||||
# emitting timeout: 60 (60 ms), which would terminate the dispatcher
|
||||
# before it starts (R5).
|
||||
events_timeout_unit = "ms"
|
||||
|
||||
@@ -27,14 +27,11 @@ from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
MAX_JSON_CATALOG_BYTES,
|
||||
build_safe_download_path,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
read_response_limited,
|
||||
safe_extract_archive,
|
||||
safe_extract_zip,
|
||||
)
|
||||
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .._init_options import (
|
||||
@@ -484,7 +481,7 @@ class PresetRegistry:
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.registry_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
# Validate loaded data is a dict (handles corrupted registry files)
|
||||
if not isinstance(data, dict):
|
||||
@@ -505,7 +502,7 @@ class PresetRegistry:
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.packs_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, 'w', encoding='utf-8') as f:
|
||||
with open(self.registry_path, 'w') as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, pack_id: str, metadata: dict):
|
||||
@@ -3362,7 +3359,6 @@ class PresetManager:
|
||||
source_dir: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> PresetManifest:
|
||||
"""Install preset from a local directory.
|
||||
|
||||
@@ -3370,7 +3366,6 @@ class PresetManager:
|
||||
source_dir: Path to preset directory
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
force: If True and the preset is already installed, remove it first
|
||||
|
||||
Returns:
|
||||
Installed preset manifest
|
||||
@@ -3389,12 +3384,10 @@ class PresetManager:
|
||||
self.check_compatibility(manifest, speckit_version)
|
||||
|
||||
if self.registry.is_installed(manifest.id):
|
||||
if not force:
|
||||
raise PresetError(
|
||||
f"Preset '{manifest.id}' is already installed. "
|
||||
f"Use 'specify preset remove {manifest.id}' first."
|
||||
)
|
||||
self.remove(manifest.id)
|
||||
raise PresetError(
|
||||
f"Preset '{manifest.id}' is already installed. "
|
||||
f"Use 'specify preset remove {manifest.id}' first."
|
||||
)
|
||||
|
||||
dest_dir = self.presets_dir / manifest.id
|
||||
if dest_dir.exists():
|
||||
@@ -3537,20 +3530,18 @@ class PresetManager:
|
||||
return
|
||||
_materialize_constitution_template(self.project_root, memory_constitution)
|
||||
|
||||
def install_from_archive(
|
||||
def install_from_zip(
|
||||
self,
|
||||
archive_path: Path,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> PresetManifest:
|
||||
"""Install a preset from a supported archive.
|
||||
"""Install preset from ZIP file.
|
||||
|
||||
Args:
|
||||
archive_path: Path to a .zip, .tar.gz, or .tgz archive
|
||||
zip_path: Path to preset ZIP file
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
force: If True and the preset is already installed, remove it first
|
||||
|
||||
Returns:
|
||||
Installed preset manifest
|
||||
@@ -3566,11 +3557,7 @@ class PresetManager:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
safe_extract_archive(
|
||||
archive_path,
|
||||
temp_path,
|
||||
error_type=PresetValidationError,
|
||||
)
|
||||
safe_extract_zip(zip_path, temp_path, error_type=PresetValidationError)
|
||||
|
||||
pack_dir = temp_path
|
||||
manifest_path = pack_dir / "preset.yml"
|
||||
@@ -3583,25 +3570,10 @@ class PresetManager:
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise PresetValidationError(
|
||||
"No preset.yml found in archive"
|
||||
"No preset.yml found in ZIP file"
|
||||
)
|
||||
|
||||
return self.install_from_directory(pack_dir, speckit_version, priority, force=force)
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> PresetManifest:
|
||||
"""Backward-compatible wrapper for archive installation."""
|
||||
return self.install_from_archive(
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
force=force,
|
||||
)
|
||||
return self.install_from_directory(pack_dir, speckit_version, priority)
|
||||
|
||||
def remove(self, pack_id: str) -> bool:
|
||||
"""Remove an installed preset.
|
||||
@@ -4627,14 +4599,14 @@ class PresetCatalog:
|
||||
def download_pack(
|
||||
self, pack_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download a preset archive from a catalog.
|
||||
"""Download preset ZIP from catalog.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the preset to download
|
||||
target_dir: Directory to save the archive
|
||||
target_dir: Directory to save ZIP file (defaults to cache directory)
|
||||
|
||||
Returns:
|
||||
Path to the downloaded archive
|
||||
Path to downloaded ZIP file
|
||||
|
||||
Raises:
|
||||
PresetError: If pack not found or download fails
|
||||
@@ -4703,86 +4675,42 @@ class PresetCatalog:
|
||||
target_dir = self.cache_dir / "downloads"
|
||||
target_dir = Path(target_dir)
|
||||
version = pack_info.get("version", "unknown")
|
||||
declared_format = archive_format_from_name(download_url)
|
||||
build_safe_download_path(
|
||||
zip_path = build_safe_download_path(
|
||||
target_dir,
|
||||
pack_id,
|
||||
version,
|
||||
error_type=PresetError,
|
||||
label="preset",
|
||||
suffix=archive_suffix(declared_format or "tar.gz"),
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_download_url = download_url
|
||||
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"}
|
||||
|
||||
staging_path: Path | None = None
|
||||
try:
|
||||
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
|
||||
archive_data = read_response_limited(
|
||||
zip_data = read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset '{pack_id}' download",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl()
|
||||
if hasattr(response, "geturl")
|
||||
else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
|
||||
verify_archive_sha256(
|
||||
archive_data, pack_info.get("sha256"), pack_id, PresetError
|
||||
zip_data, pack_info.get("sha256"), pack_id, PresetError
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="preset-download-",
|
||||
suffix=".archive",
|
||||
dir=target_dir,
|
||||
delete=False,
|
||||
) as staging_file:
|
||||
staging_path = Path(staging_file.name)
|
||||
staging_file.write(archive_data)
|
||||
archive_format = detect_archive_format(
|
||||
staging_path,
|
||||
source_name=(
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else original_download_url
|
||||
),
|
||||
content_type=content_type,
|
||||
error_type=PresetError,
|
||||
)
|
||||
archive_path = build_safe_download_path(
|
||||
target_dir,
|
||||
pack_id,
|
||||
version,
|
||||
error_type=PresetError,
|
||||
label="preset",
|
||||
suffix=archive_suffix(archive_format),
|
||||
)
|
||||
os.replace(staging_path, archive_path)
|
||||
staging_path = None
|
||||
return archive_path
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise PresetError(
|
||||
f"Failed to download preset from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise PresetError(f"Failed to save preset archive: {e}")
|
||||
finally:
|
||||
if staging_path is not None:
|
||||
staging_path.unlink(missing_ok=True)
|
||||
raise PresetError(f"Failed to save preset ZIP: {e}")
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear all catalog cache files, including per-URL hashed caches."""
|
||||
|
||||
@@ -17,9 +17,6 @@ from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
is_safe_download_redirect,
|
||||
read_response_limited,
|
||||
@@ -62,11 +59,8 @@ def preset_list():
|
||||
for pack in installed:
|
||||
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||
pri = pack.get('priority', 10)
|
||||
name = _escape_markup(str(pack['name']))
|
||||
pack_id = _escape_markup(str(pack['id']))
|
||||
version = _escape_markup(str(pack['version']))
|
||||
console.print(f" [bold]{name}[/bold] ({pack_id}) v{version} — {status} — priority {pri}")
|
||||
console.print(f" {_escape_markup(str(pack['description']))}")
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
||||
console.print(f" {pack['description']}")
|
||||
tags = pack.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in tags))
|
||||
@@ -78,11 +72,7 @@ def preset_list():
|
||||
@preset_app.command("add")
|
||||
def preset_add(
|
||||
preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
|
||||
from_url: str = typer.Option(
|
||||
None,
|
||||
"--from",
|
||||
help="Install from a .zip, .tar.gz, or .tgz URL",
|
||||
),
|
||||
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
||||
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||
):
|
||||
@@ -149,7 +139,7 @@ def preset_add(
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
archive_path = Path(tmpdir) / "preset.archive"
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
try:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
@@ -177,33 +167,13 @@ def preset_add(
|
||||
"or HTTP for localhost (127.0.0.1, ::1)."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset {from_url}",
|
||||
zip_path.write_bytes(
|
||||
read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset {from_url}",
|
||||
)
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
archive_path.write_bytes(archive_data)
|
||||
format_source = (
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else from_url
|
||||
)
|
||||
archive_format = detect_archive_format(
|
||||
archive_path,
|
||||
source_name=format_source,
|
||||
content_type=content_type,
|
||||
error_type=PresetError,
|
||||
)
|
||||
detected_path = archive_path.with_suffix(
|
||||
archive_suffix(archive_format)
|
||||
)
|
||||
os.replace(archive_path, detected_path)
|
||||
archive_path = detected_path
|
||||
except (urllib.error.URLError, PresetError) as e:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to download: "
|
||||
@@ -211,11 +181,7 @@ def preset_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
|
||||
@@ -258,16 +224,12 @@ def preset_add(
|
||||
console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
|
||||
|
||||
try:
|
||||
archive_path = catalog.download_pack(preset_id)
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
)
|
||||
zip_path = catalog.download_pack(preset_id)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
finally:
|
||||
if 'archive_path' in locals() and archive_path.exists():
|
||||
archive_path.unlink(missing_ok=True)
|
||||
if 'zip_path' in locals() and zip_path.exists():
|
||||
zip_path.unlink(missing_ok=True)
|
||||
else:
|
||||
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
|
||||
raise typer.Exit(1)
|
||||
@@ -355,20 +317,13 @@ def preset_resolve(
|
||||
project_root = _require_specify_project()
|
||||
resolver = PresetResolver(project_root)
|
||||
layers = resolver.collect_all_layers(template_name)
|
||||
safe_template_name = _escape_markup(str(template_name))
|
||||
|
||||
if layers:
|
||||
# Use the highest-priority layer for display because the final output
|
||||
# may be composed and may not map to resolve_with_source()'s single path.
|
||||
display_layer = layers[0]
|
||||
console.print(
|
||||
f" [bold]{safe_template_name}[/bold]: "
|
||||
f"{_escape_markup(str(display_layer['path']))}"
|
||||
)
|
||||
console.print(
|
||||
f" [dim](top layer from: "
|
||||
f"{_escape_markup(str(display_layer['source']))})[/dim]"
|
||||
)
|
||||
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
|
||||
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
|
||||
|
||||
has_composition = (
|
||||
layers[0]["strategy"] != "replace"
|
||||
@@ -380,10 +335,7 @@ def preset_resolve(
|
||||
composed = resolver.resolve_content(template_name)
|
||||
except Exception as exc:
|
||||
composed = None
|
||||
console.print(
|
||||
f" [yellow]Warning: composition error: "
|
||||
f"{_escape_markup(str(exc))}[/yellow]"
|
||||
)
|
||||
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
|
||||
if composed is None:
|
||||
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
|
||||
else:
|
||||
@@ -406,27 +358,15 @@ def preset_resolve(
|
||||
strategy_label = layer["strategy"]
|
||||
if strategy_label == "replace" and i == 0:
|
||||
strategy_label = "base"
|
||||
# Escape the literal bracket (\[) so Rich renders `[<strategy>]`
|
||||
# instead of parsing it as a style tag and swallowing the label,
|
||||
# mirroring `workflow info`'s step-graph line.
|
||||
console.print(
|
||||
f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] "
|
||||
f"{_escape_markup(str(layer['source']))} → "
|
||||
f"{_escape_markup(str(layer['path']))}"
|
||||
)
|
||||
console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}")
|
||||
else:
|
||||
# No layers found — fall back to resolve_with_source for non-composition cases
|
||||
result = resolver.resolve_with_source(template_name)
|
||||
if result:
|
||||
console.print(
|
||||
f" [bold]{safe_template_name}[/bold]: "
|
||||
f"{_escape_markup(str(result['path']))}"
|
||||
)
|
||||
console.print(
|
||||
f" [dim](from: {_escape_markup(str(result['source']))})[/dim]"
|
||||
)
|
||||
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
||||
console.print(f" [dim](from: {result['source']})[/dim]")
|
||||
else:
|
||||
console.print(f" [yellow]{safe_template_name}[/yellow]: not found")
|
||||
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
||||
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
||||
|
||||
|
||||
@@ -446,32 +386,24 @@ def preset_info(
|
||||
local_pack = manager.get_pack(preset_id)
|
||||
|
||||
if local_pack:
|
||||
console.print(
|
||||
f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n"
|
||||
)
|
||||
console.print(f" ID: {_escape_markup(str(local_pack.id))}")
|
||||
console.print(f" Version: {_escape_markup(str(local_pack.version))}")
|
||||
console.print(
|
||||
f" Description: {_escape_markup(str(local_pack.description))}"
|
||||
)
|
||||
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
|
||||
console.print(f" ID: {local_pack.id}")
|
||||
console.print(f" Version: {local_pack.version}")
|
||||
console.print(f" Description: {local_pack.description}")
|
||||
if local_pack.author:
|
||||
console.print(f" Author: {_escape_markup(str(local_pack.author))}")
|
||||
console.print(f" Author: {local_pack.author}")
|
||||
local_tags = local_pack.tags
|
||||
if isinstance(local_tags, list) and local_tags:
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in local_tags))
|
||||
console.print(f" Tags: {tags_str}")
|
||||
console.print(f" Tags: {', '.join(str(t) for t in local_tags)}")
|
||||
console.print(f" Templates: {len(local_pack.templates)}")
|
||||
for tmpl in local_pack.templates:
|
||||
tmpl_name = _escape_markup(str(tmpl['name']))
|
||||
tmpl_type = _escape_markup(str(tmpl['type']))
|
||||
tmpl_desc = _escape_markup(str(tmpl.get('description', '')))
|
||||
console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}")
|
||||
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
||||
repo = local_pack.data.get("preset", {}).get("repository")
|
||||
if repo:
|
||||
console.print(f" Repository: {_escape_markup(str(repo))}")
|
||||
console.print(f" Repository: {repo}")
|
||||
license_val = local_pack.data.get("preset", {}).get("license")
|
||||
if license_val:
|
||||
console.print(f" License: {_escape_markup(str(license_val))}")
|
||||
console.print(f" License: {license_val}")
|
||||
console.print("\n [green]Status: installed[/green]")
|
||||
# Get priority from registry
|
||||
pack_metadata = manager.registry.get(preset_id)
|
||||
@@ -506,8 +438,7 @@ def preset_info(
|
||||
)
|
||||
catalog_tags = pack_info.get("tags", [])
|
||||
if isinstance(catalog_tags, list) and catalog_tags:
|
||||
catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags))
|
||||
console.print(f" Tags: {catalog_tags_str}")
|
||||
console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}")
|
||||
if pack_info.get("repository"):
|
||||
console.print(
|
||||
f" Repository: {_escape_markup(str(pack_info['repository']))}"
|
||||
@@ -752,15 +683,10 @@ def preset_catalog_add(
|
||||
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Only rendering is escaped — the raw values are what get persisted and
|
||||
# compared below, so a name containing markup still round-trips exactly.
|
||||
safe_name = _escape_markup(str(name))
|
||||
safe_url = _escape_markup(str(url))
|
||||
|
||||
# Check for duplicate name
|
||||
for existing in catalogs:
|
||||
if isinstance(existing, dict) and existing.get("name") == name:
|
||||
console.print(f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists.")
|
||||
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
||||
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -776,11 +702,10 @@ def preset_catalog_add(
|
||||
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
|
||||
install_label = "install allowed" if install_allowed else "discovery only"
|
||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})")
|
||||
console.print(f" URL: {safe_url}")
|
||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||
console.print(f" URL: {url}")
|
||||
console.print(f" Priority: {priority}")
|
||||
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
|
||||
console.print(f"\nConfig saved to {config_label}")
|
||||
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
|
||||
|
||||
|
||||
@preset_catalog_app.command("remove")
|
||||
@@ -808,20 +733,17 @@ def preset_catalog_remove(
|
||||
if not isinstance(catalogs, list):
|
||||
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||
raise typer.Exit(1)
|
||||
# Rendering only — the raw name drives the comparison below.
|
||||
safe_name = _escape_markup(str(name))
|
||||
|
||||
original_count = len(catalogs)
|
||||
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
||||
|
||||
if len(catalogs) == original_count:
|
||||
console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.")
|
||||
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
|
||||
console.print(f"[green]✓[/green] Removed catalog '{safe_name}'")
|
||||
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||
if not catalogs:
|
||||
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
||||
|
||||
|
||||
@@ -21,17 +21,10 @@ from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console, err_console
|
||||
from .._download_security import (
|
||||
archive_format_from_content_type,
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
is_safe_download_redirect,
|
||||
read_response_limited,
|
||||
safe_extract_archive,
|
||||
)
|
||||
from .._project import _resolve_init_dir_override
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
|
||||
workflow_app = typer.Typer(
|
||||
name="workflow",
|
||||
@@ -462,42 +455,6 @@ def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _workflow_yaml_is_declared(
|
||||
source_name: str, content_type: str | None
|
||||
) -> bool:
|
||||
"""Return whether response metadata explicitly identifies workflow YAML."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
path = urlparse(source_name).path.casefold()
|
||||
media_type = (content_type or "").split(";", 1)[0].strip().casefold()
|
||||
return path.endswith((".yml", ".yaml")) or media_type in {
|
||||
"application/yaml",
|
||||
"application/x-yaml",
|
||||
"text/yaml",
|
||||
"text/x-yaml",
|
||||
}
|
||||
|
||||
|
||||
def _sniff_workflow_archive_format(data: bytes):
|
||||
"""Return a supported archive format when suffixless response bytes match."""
|
||||
from io import BytesIO
|
||||
|
||||
try:
|
||||
return detect_archive_format(
|
||||
Path("workflow-download"),
|
||||
archive_file=BytesIO(data),
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _enforce_workflow_yaml_size(data: bytes) -> None:
|
||||
if len(data) > _MAX_WORKFLOW_YAML_BYTES:
|
||||
raise ValueError(
|
||||
f"response exceeds the {_MAX_WORKFLOW_YAML_BYTES}-byte workflow size limit"
|
||||
)
|
||||
|
||||
|
||||
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
|
||||
"""Validate that ``workflow_id`` is a safe installed-workflow directory name."""
|
||||
if (
|
||||
@@ -922,231 +879,6 @@ def _discard_committed_backup_file(backup_file: Path | None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _workflow_package_root(extracted_root: Path) -> Path:
|
||||
"""Resolve a root-level or single-nested workflow package."""
|
||||
if (extracted_root / "workflow.yml").is_file():
|
||||
return extracted_root
|
||||
entries = list(extracted_root.iterdir())
|
||||
if (
|
||||
len(entries) == 1
|
||||
and entries[0].is_dir()
|
||||
and not entries[0].is_symlink()
|
||||
and (entries[0] / "workflow.yml").is_file()
|
||||
):
|
||||
return entries[0]
|
||||
raise ValueError(
|
||||
"Archive must contain workflow.yml at its root or in exactly one "
|
||||
"top-level directory"
|
||||
)
|
||||
|
||||
|
||||
def _validate_local_workflow_package(package_dir: Path) -> None:
|
||||
"""Reject links and special files before copying a local package."""
|
||||
import stat
|
||||
|
||||
for root, dirnames, filenames in os.walk(package_dir, followlinks=False):
|
||||
root_path = Path(root)
|
||||
for name in [*dirnames, *filenames]:
|
||||
path = root_path / name
|
||||
mode = path.lstat().st_mode
|
||||
if stat.S_ISLNK(mode):
|
||||
raise ValueError(f"Workflow package contains symlink: {path}")
|
||||
if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode):
|
||||
raise ValueError(f"Workflow package contains unsupported file: {path}")
|
||||
|
||||
|
||||
def _workflow_package_has_companions(package_dir: Path) -> bool:
|
||||
"""Return whether a directory contains anything beyond workflow.yml."""
|
||||
return any(path.name != "workflow.yml" for path in package_dir.iterdir())
|
||||
|
||||
|
||||
def _install_workflow_package(
|
||||
project_root: Path,
|
||||
workflows_dir: Path,
|
||||
package_dir: Path,
|
||||
source_label: str,
|
||||
*,
|
||||
expected_id: str | None = None,
|
||||
expected_version: str | None = None,
|
||||
expected_installed_version: str | None = None,
|
||||
catalog_info: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Validate and atomically install a complete workflow package directory."""
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from .engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
workflow_file = package_dir / "workflow.yml"
|
||||
try:
|
||||
_validate_local_workflow_package(package_dir)
|
||||
workflow_bytes = workflow_file.read_bytes()
|
||||
definition = WorkflowDefinition.from_string(workflow_bytes.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, ValueError, yaml.YAMLError) as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow package: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
errors = validate_workflow(definition)
|
||||
if errors:
|
||||
console.print("[red]Error:[/red] Workflow validation failed:")
|
||||
for error in errors:
|
||||
console.print(f" • {_escape_markup(str(error))}")
|
||||
raise typer.Exit(1)
|
||||
if not isinstance(definition.id, str) or not definition.id.strip():
|
||||
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
|
||||
raise typer.Exit(1)
|
||||
if expected_id is not None and definition.id != expected_id:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Workflow ID in YAML "
|
||||
f"({_escape_markup(repr(definition.id))}) does not match the requested "
|
||||
f"workflow ID ({_escape_markup(repr(expected_id))})."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if expected_version is not None and str(definition.version) != expected_version:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Downloaded workflow version "
|
||||
f"({_escape_markup(str(definition.version))}) does not match the catalog "
|
||||
f"version ({_escape_markup(expected_version)})."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
dest_dir = _safe_workflow_id_dir(workflows_dir, definition.id)
|
||||
staged_dir = Path(
|
||||
tempfile.mkdtemp(prefix=f".{definition.id}.installing-", dir=workflows_dir)
|
||||
)
|
||||
try:
|
||||
package_root = package_dir.resolve()
|
||||
|
||||
def ignore_reserved_package_entries(
|
||||
source: str, names: list[str]
|
||||
) -> set[str]:
|
||||
if Path(source).resolve() == package_root and "overlays" in names:
|
||||
return {"overlays"}
|
||||
return set()
|
||||
|
||||
shutil.copytree(
|
||||
package_dir,
|
||||
staged_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=ignore_reserved_package_entries,
|
||||
)
|
||||
except OSError as exc:
|
||||
shutil.rmtree(staged_dir, ignore_errors=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to stage workflow package: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
backup_dir: Path | None = None
|
||||
try:
|
||||
with _workflow_install_transaction(project_root):
|
||||
registry = _open_workflow_registry(project_root)
|
||||
existing = registry.get(definition.id)
|
||||
if expected_installed_version is not None and (
|
||||
not isinstance(existing, dict)
|
||||
or existing.get("source") != "catalog"
|
||||
or str(existing.get("version")) != expected_installed_version
|
||||
):
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Workflow "
|
||||
f"'{_escape_markup(definition.id)}' changed during update; "
|
||||
"rerun the command."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if dest_dir.exists():
|
||||
backup_dir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f".{definition.id}.backup-",
|
||||
dir=workflows_dir,
|
||||
)
|
||||
)
|
||||
backup_dir.rmdir()
|
||||
os.replace(dest_dir, backup_dir)
|
||||
try:
|
||||
os.replace(staged_dir, dest_dir)
|
||||
except BaseException:
|
||||
if backup_dir is not None:
|
||||
os.replace(backup_dir, dest_dir)
|
||||
backup_dir = None
|
||||
raise
|
||||
|
||||
entry = {
|
||||
"name": definition.name,
|
||||
"version": definition.version,
|
||||
"description": definition.description,
|
||||
"source": source_label,
|
||||
}
|
||||
if catalog_info is not None:
|
||||
entry.update(
|
||||
{
|
||||
"source": "catalog",
|
||||
"catalog_name": catalog_info.get("_catalog_name", ""),
|
||||
"url": catalog_info.get("url", ""),
|
||||
}
|
||||
)
|
||||
if isinstance(existing, dict) and not existing.get("enabled", True):
|
||||
entry["enabled"] = False
|
||||
try:
|
||||
registry.add(definition.id, entry)
|
||||
except (OSError, TypeError, ValueError):
|
||||
failed_dir: Path | None = None
|
||||
try:
|
||||
failed_dir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f".{definition.id}.failed-",
|
||||
dir=workflows_dir,
|
||||
)
|
||||
)
|
||||
failed_dir.rmdir()
|
||||
os.replace(dest_dir, failed_dir)
|
||||
if backup_dir is not None:
|
||||
os.replace(backup_dir, dest_dir)
|
||||
backup_dir = None
|
||||
except OSError as rollback_exc:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Failed to fully restore the prior "
|
||||
f"workflow package: {_escape_markup(str(rollback_exc))}"
|
||||
)
|
||||
finally:
|
||||
if failed_dir is not None and failed_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(failed_dir)
|
||||
except OSError as cleanup_exc:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Could not remove failed "
|
||||
f"workflow package: {_escape_markup(str(cleanup_exc))}"
|
||||
)
|
||||
raise
|
||||
except typer.Exit:
|
||||
raise
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to install workflow package: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
if staged_dir.exists():
|
||||
shutil.rmtree(staged_dir, ignore_errors=True)
|
||||
|
||||
if backup_dir is not None:
|
||||
try:
|
||||
shutil.rmtree(backup_dir)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Workflow installed, but its backup "
|
||||
f"directory could not be removed: {_escape_markup(str(exc))}"
|
||||
)
|
||||
console.print(
|
||||
f"[green]✓[/green] Workflow '{_escape_markup(definition.name)}' "
|
||||
f"({_escape_markup(definition.id)}) installed"
|
||||
)
|
||||
|
||||
|
||||
# Root helper re-fetched at call time so test monkeypatching of
|
||||
# `specify_cli._require_specify_project` keeps working after the move.
|
||||
def _require_specify_project(*args, **kwargs):
|
||||
@@ -1157,18 +889,6 @@ def _require_specify_project(*args, **kwargs):
|
||||
return project_root
|
||||
|
||||
|
||||
def _failed_step_error(state: Any) -> str | None:
|
||||
"""Terminal error for a failed/aborted run, if any.
|
||||
|
||||
Returns the run-level error persisted by the engine at the moment
|
||||
the run terminated. Returns ``None`` for non-terminal statuses so
|
||||
the caller can print unconditionally.
|
||||
"""
|
||||
if getattr(state.status, "value", state.status) not in ("failed", "aborted"):
|
||||
return None
|
||||
return getattr(state, "error", None)
|
||||
|
||||
|
||||
def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
||||
"""Machine-readable summary of a run/resume outcome."""
|
||||
payload = {
|
||||
@@ -1181,9 +901,6 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
||||
gate = _gate_outcome(state)
|
||||
if gate is not None:
|
||||
payload["gate"] = gate
|
||||
error = _failed_step_error(state)
|
||||
if error is not None:
|
||||
payload["error"] = error
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1444,10 +1161,6 @@ def workflow_run(
|
||||
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
||||
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
|
||||
|
||||
if state.status.value == "paused":
|
||||
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
|
||||
|
||||
@@ -1558,10 +1271,6 @@ def workflow_resume(
|
||||
color = status_colors.get(state.status.value, "white")
|
||||
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
|
||||
|
||||
raise typer.Exit(_run_outcome_exit_code(state.status.value))
|
||||
|
||||
|
||||
@@ -1629,10 +1338,6 @@ def workflow_status(
|
||||
if state.current_step_id:
|
||||
console.print(f" Current: {state.current_step_id}")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f" [red]Error: {_escape_markup(err_msg)}[/red]")
|
||||
|
||||
if state.step_results:
|
||||
console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]")
|
||||
for step_id, step_data in state.step_results.items():
|
||||
@@ -1870,48 +1575,16 @@ def workflow_add(
|
||||
if dev_path.is_file() and dev_path.suffix.lower() in (".yml", ".yaml"):
|
||||
_validate_and_install_local(dev_path, str(dev_path))
|
||||
return
|
||||
if dev_path.is_file() and archive_format_from_name(str(dev_path)) is not None:
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-workflow-archive-"
|
||||
) as tmpdir:
|
||||
extracted_root = Path(tmpdir)
|
||||
try:
|
||||
safe_extract_archive(dev_path, extracted_root)
|
||||
package_root = _workflow_package_root(extracted_root)
|
||||
except ValueError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
package_root,
|
||||
str(dev_path),
|
||||
)
|
||||
return
|
||||
if dev_path.is_dir():
|
||||
dev_wf_file = dev_path / "workflow.yml"
|
||||
if not dev_wf_file.is_file():
|
||||
console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}")
|
||||
raise typer.Exit(1)
|
||||
if _workflow_package_has_companions(dev_path):
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
dev_path,
|
||||
str(dev_path),
|
||||
)
|
||||
else:
|
||||
_validate_and_install_local(dev_wf_file, str(dev_path))
|
||||
_validate_and_install_local(dev_wf_file, str(dev_path))
|
||||
return
|
||||
console.print(
|
||||
"[red]Error:[/red] --dev source must be a workflow YAML file, "
|
||||
"supported archive, or directory containing workflow.yml: "
|
||||
f"{_escape_markup(source)}"
|
||||
"[red]Error:[/red] --dev source must be a workflow YAML file or a "
|
||||
f"directory containing workflow.yml: {_escape_markup(source)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -1974,7 +1647,6 @@ def workflow_add(
|
||||
|
||||
import tempfile
|
||||
tmp_path: Path | None = None
|
||||
downloaded_archive_format = None
|
||||
try:
|
||||
with _open_url(
|
||||
download_url,
|
||||
@@ -1988,48 +1660,13 @@ def workflow_add(
|
||||
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
content_type = (
|
||||
resp.getheader("Content-Type")
|
||||
if hasattr(resp, "getheader")
|
||||
else None
|
||||
)
|
||||
downloaded_archive_format = (
|
||||
archive_format_from_name(final_url)
|
||||
or archive_format_from_name(download_url)
|
||||
or archive_format_from_content_type(content_type)
|
||||
)
|
||||
declared_yaml = _workflow_yaml_is_declared(final_url, content_type)
|
||||
suffix = (
|
||||
archive_suffix(downloaded_archive_format)
|
||||
if downloaded_archive_format is not None
|
||||
else ".yml" if declared_yaml else ".download"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
|
||||
# Assign tmp_path immediately: NamedTemporaryFile(delete=False)
|
||||
# creates the file on disk right away, before any bytes are
|
||||
# written, so a failure in the size-limited read below must
|
||||
# still be able to find and remove it.
|
||||
tmp_path = Path(tmp.name)
|
||||
if downloaded_archive_format is not None:
|
||||
downloaded_content = read_response_limited(
|
||||
resp,
|
||||
error_type=ValueError,
|
||||
label="workflow archive download",
|
||||
)
|
||||
elif declared_yaml:
|
||||
downloaded_content = _read_response_within_limit(resp)
|
||||
else:
|
||||
downloaded_content = read_response_limited(
|
||||
resp,
|
||||
error_type=ValueError,
|
||||
label="workflow download",
|
||||
)
|
||||
downloaded_archive_format = (
|
||||
_sniff_workflow_archive_format(downloaded_content)
|
||||
)
|
||||
if downloaded_archive_format is None:
|
||||
_enforce_workflow_yaml_size(downloaded_content)
|
||||
tmp.write(downloaded_content)
|
||||
tmp.write(_read_response_within_limit(resp))
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -2049,38 +1686,13 @@ def workflow_add(
|
||||
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
if downloaded_archive_format is None:
|
||||
_validate_and_install_local(
|
||||
tmp_path,
|
||||
download_url,
|
||||
expected_id=source if from_url else None,
|
||||
)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-workflow-archive-"
|
||||
) as extract_dir:
|
||||
extracted_root = Path(extract_dir)
|
||||
try:
|
||||
safe_extract_archive(
|
||||
tmp_path,
|
||||
extracted_root,
|
||||
source_name=final_url,
|
||||
content_type=content_type,
|
||||
)
|
||||
package_root = _workflow_package_root(extracted_root)
|
||||
except ValueError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
package_root,
|
||||
download_url,
|
||||
expected_id=source if from_url else None,
|
||||
)
|
||||
# When installed via --from, the positional argument names the
|
||||
# workflow the user expects — enforce it like the catalog branch.
|
||||
_validate_and_install_local(
|
||||
tmp_path,
|
||||
download_url,
|
||||
expected_id=source if from_url else None,
|
||||
)
|
||||
finally:
|
||||
# Best-effort: _validate_and_install_local may already have
|
||||
# committed the file + registry entry (success) or already
|
||||
@@ -2104,46 +1716,12 @@ def workflow_add(
|
||||
if source_path.is_file() and source_path.suffix.lower() in (".yml", ".yaml"):
|
||||
_validate_and_install_local(source_path, str(source_path))
|
||||
return
|
||||
elif (
|
||||
source_path.is_file()
|
||||
and archive_format_from_name(str(source_path)) is not None
|
||||
):
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-workflow-archive-"
|
||||
) as tmpdir:
|
||||
extracted_root = Path(tmpdir)
|
||||
try:
|
||||
safe_extract_archive(source_path, extracted_root)
|
||||
package_root = _workflow_package_root(extracted_root)
|
||||
except ValueError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
package_root,
|
||||
str(source_path),
|
||||
)
|
||||
return
|
||||
elif source_path.is_dir():
|
||||
wf_file = source_path / "workflow.yml"
|
||||
if not wf_file.is_file():
|
||||
console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}")
|
||||
raise typer.Exit(1)
|
||||
if _workflow_package_has_companions(source_path):
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
source_path,
|
||||
str(source_path),
|
||||
)
|
||||
else:
|
||||
_validate_and_install_local(wf_file, str(source_path))
|
||||
_validate_and_install_local(wf_file, str(source_path))
|
||||
return
|
||||
|
||||
# Try from catalog
|
||||
@@ -2248,9 +1826,6 @@ def _install_workflow_from_catalog(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
original_workflow_url = workflow_url
|
||||
downloaded_archive_format = None
|
||||
archive_content_type = None
|
||||
try:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
from specify_cli.authentication.http import github_provider_hosts as _github_provider_hosts
|
||||
@@ -2282,38 +1857,10 @@ def _install_workflow_from_catalog(
|
||||
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
archive_content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
downloaded_archive_format = (
|
||||
archive_format_from_name(final_url)
|
||||
or archive_format_from_name(original_workflow_url)
|
||||
or archive_format_from_content_type(archive_content_type)
|
||||
)
|
||||
# Written to the staging file, never workflow_file directly, so a
|
||||
# reinstall's prior working copy is never touched until the
|
||||
# atomic commit below runs.
|
||||
if downloaded_archive_format is not None:
|
||||
downloaded_content = read_response_limited(
|
||||
response,
|
||||
error_type=ValueError,
|
||||
label=f"workflow '{workflow_id}' archive download",
|
||||
)
|
||||
elif _workflow_yaml_is_declared(final_url, archive_content_type):
|
||||
downloaded_content = _read_response_within_limit(response)
|
||||
else:
|
||||
downloaded_content = read_response_limited(
|
||||
response,
|
||||
error_type=ValueError,
|
||||
label=f"workflow '{workflow_id}' download",
|
||||
)
|
||||
downloaded_archive_format = _sniff_workflow_archive_format(
|
||||
downloaded_content
|
||||
)
|
||||
if downloaded_archive_format is None:
|
||||
_enforce_workflow_yaml_size(downloaded_content)
|
||||
downloaded_content = _read_response_within_limit(response)
|
||||
staged_file.write_bytes(downloaded_content)
|
||||
except typer.Exit:
|
||||
raise
|
||||
@@ -2322,59 +1869,6 @@ def _install_workflow_from_catalog(
|
||||
console.print(f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if downloaded_archive_format is not None:
|
||||
try:
|
||||
verify_archive_sha256(
|
||||
downloaded_content,
|
||||
info.get("sha256"),
|
||||
workflow_id,
|
||||
ValueError,
|
||||
)
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-workflow-archive-"
|
||||
) as extract_dir:
|
||||
extracted_root = Path(extract_dir)
|
||||
safe_extract_archive(
|
||||
staged_file.path,
|
||||
extracted_root,
|
||||
archive_file=BytesIO(downloaded_content),
|
||||
source_name=original_workflow_url,
|
||||
content_type=archive_content_type,
|
||||
)
|
||||
package_root = _workflow_package_root(extracted_root)
|
||||
_safe_discard_staged_workflow_file(
|
||||
staged_file,
|
||||
workflow_dir,
|
||||
existed_before,
|
||||
)
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
package_root,
|
||||
workflow_url,
|
||||
expected_id=workflow_id,
|
||||
expected_version=expected_version,
|
||||
expected_installed_version=expected_installed_version,
|
||||
catalog_info={**info, "url": workflow_url},
|
||||
)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except (OSError, ValueError) as exc:
|
||||
_safe_discard_staged_workflow_file(
|
||||
staged_file,
|
||||
workflow_dir,
|
||||
existed_before,
|
||||
)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
return
|
||||
|
||||
# Validate the downloaded workflow (still staged, not yet committed)
|
||||
# before registering.
|
||||
try:
|
||||
@@ -3055,10 +2549,9 @@ def workflow_step_list():
|
||||
console.print(" [bold]Custom (installed):[/bold]")
|
||||
for key in sorted(installed):
|
||||
meta = installed[key] or {}
|
||||
name = _escape_markup(str(meta.get("name", key)))
|
||||
safe_key = _escape_markup(str(key))
|
||||
version = _escape_markup(str(meta.get("version", "?")))
|
||||
console.print(f" • [bold]{name}[/bold] ({safe_key}) v{version}")
|
||||
name = meta.get("name", key)
|
||||
version = meta.get("version", "?")
|
||||
console.print(f" • [bold]{name}[/bold] ({key}) v{version}")
|
||||
console.print()
|
||||
|
||||
if not built_in and not installed:
|
||||
@@ -3602,15 +3095,13 @@ def workflow_step_search(
|
||||
install_note = (
|
||||
"" if step.get("_install_allowed", True) else " [dim](discovery only)[/dim]"
|
||||
)
|
||||
name = _escape_markup(str(step.get("name", step.get("id", "?"))))
|
||||
step_id = _escape_markup(str(step.get("id", "?")))
|
||||
version = _escape_markup(str(step.get("version", "?")))
|
||||
console.print(
|
||||
f" [bold]{name}[/bold] ({step_id}) v{version}{install_note}"
|
||||
f" [bold]{step.get('name', step.get('id', '?'))}[/bold]"
|
||||
f" ({step.get('id', '?')}) v{step.get('version', '?')}{install_note}"
|
||||
)
|
||||
desc = step.get("description", "")
|
||||
if desc:
|
||||
console.print(f" {_escape_markup(str(desc))}")
|
||||
console.print(f" {desc}")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -3623,7 +3114,6 @@ def workflow_step_info(
|
||||
from .catalog import StepCatalog, StepCatalogError, StepRegistry
|
||||
|
||||
project_root = _require_specify_project()
|
||||
safe_step_id = _escape_markup(str(step_id))
|
||||
|
||||
registry = StepRegistry(project_root)
|
||||
installed_meta = registry.get(step_id)
|
||||
@@ -3633,27 +3123,20 @@ def workflow_step_info(
|
||||
is_builtin = builtin_step is not None and not installed_meta
|
||||
|
||||
if is_builtin:
|
||||
console.print(f"\n[bold cyan]{safe_step_id}[/bold cyan] [dim](built-in)[/dim]")
|
||||
console.print(f" Type key: {safe_step_id}")
|
||||
console.print(f"\n[bold cyan]{step_id}[/bold cyan] [dim](built-in)[/dim]")
|
||||
console.print(f" Type key: {step_id}")
|
||||
console.print(" [green]Built-in step type[/green]")
|
||||
return
|
||||
|
||||
if installed_meta:
|
||||
name = _escape_markup(str(installed_meta.get("name", step_id)))
|
||||
version = _escape_markup(str(installed_meta.get("version", "?")))
|
||||
console.print(
|
||||
f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})"
|
||||
f"\n[bold cyan]{installed_meta.get('name', step_id)}[/bold cyan] ({step_id})"
|
||||
)
|
||||
console.print(f" Version: {version}")
|
||||
console.print(f" Version: {installed_meta.get('version', '?')}")
|
||||
if installed_meta.get("author"):
|
||||
console.print(
|
||||
f" Author: {_escape_markup(str(installed_meta['author']))}"
|
||||
)
|
||||
console.print(f" Author: {installed_meta['author']}")
|
||||
if installed_meta.get("description"):
|
||||
console.print(
|
||||
f" Description: "
|
||||
f"{_escape_markup(str(installed_meta['description']))}"
|
||||
)
|
||||
console.print(f" Description: {installed_meta['description']}")
|
||||
console.print(" [green]Installed[/green]")
|
||||
return
|
||||
|
||||
@@ -3665,24 +3148,20 @@ def workflow_step_info(
|
||||
info = None
|
||||
|
||||
if info:
|
||||
name = _escape_markup(str(info.get("name", step_id)))
|
||||
version = _escape_markup(str(info.get("version", "?")))
|
||||
console.print(
|
||||
f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})"
|
||||
f"\n[bold cyan]{info.get('name', step_id)}[/bold cyan] ({step_id})"
|
||||
)
|
||||
console.print(f" Version: {version}")
|
||||
console.print(f" Version: {info.get('version', '?')}")
|
||||
if info.get("author"):
|
||||
console.print(f" Author: {_escape_markup(str(info['author']))}")
|
||||
console.print(f" Author: {info['author']}")
|
||||
if info.get("description"):
|
||||
console.print(
|
||||
f" Description: {_escape_markup(str(info['description']))}"
|
||||
)
|
||||
console.print(f" Description: {info['description']}")
|
||||
console.print(" [yellow]Not installed[/yellow]")
|
||||
console.print(
|
||||
f"\n Install with: [cyan]specify workflow step add {safe_step_id}[/cyan]"
|
||||
f"\n Install with: [cyan]specify workflow step add {step_id}[/cyan]"
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Step type '{safe_step_id}' not found")
|
||||
console.print(f"[red]Error:[/red] Step type '{step_id}' not found")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
|
||||
@@ -56,9 +56,6 @@ class StepContext:
|
||||
#: Current fan-out item (set only inside fan-out iterations).
|
||||
item: Any = None
|
||||
|
||||
#: Whether the current step is executing inside a fan-out template.
|
||||
inside_fan_out: bool = False
|
||||
|
||||
#: Fan-in aggregated results (set only for fan-in steps).
|
||||
fan_in: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -495,8 +495,6 @@ class WorkflowCatalog:
|
||||
try:
|
||||
with open(meta_file, encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
if not isinstance(meta, dict):
|
||||
return False
|
||||
fetched_at = float(meta.get("fetched_at", 0))
|
||||
return (time.time() - fetched_at) < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
@@ -511,9 +509,7 @@ class WorkflowCatalog:
|
||||
if not force_refresh and self._is_url_cache_valid(entry.url):
|
||||
try:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
cached = json.load(f)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Ignore invalid/unreadable cache and fall back to fetching from source.
|
||||
pass
|
||||
@@ -578,9 +574,7 @@ class WorkflowCatalog:
|
||||
if cache_file.exists():
|
||||
try:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
cached = json.load(f)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, ValueError, OSError):
|
||||
# Stale-cache read failed; let the original fetch error propagate.
|
||||
pass
|
||||
@@ -1190,8 +1184,6 @@ class StepCatalog:
|
||||
try:
|
||||
with open(meta_file, encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
if not isinstance(meta, dict):
|
||||
return False
|
||||
fetched_at = float(meta.get("fetched_at", 0))
|
||||
return (time.time() - fetched_at) < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
|
||||
@@ -308,15 +308,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
errors.append("Workflow has no steps defined.")
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
# ``input_names`` is the set of declared workflow input names — used by
|
||||
# ``_validate_steps`` to cross-reference gate ``verdict_input`` bindings.
|
||||
# ``None`` means the inputs block itself is malformed (already reported
|
||||
# above); the cross-check is then disabled so one authoring mistake does
|
||||
# not cascade into N spurious "undeclared" errors.
|
||||
input_names: set[str] | None = (
|
||||
set(definition.inputs) if isinstance(definition.inputs, dict) else None
|
||||
)
|
||||
_validate_steps(definition.steps, seen_ids, errors, input_names)
|
||||
_validate_steps(definition.steps, seen_ids, errors)
|
||||
|
||||
return errors
|
||||
|
||||
@@ -325,16 +317,8 @@ def _validate_steps(
|
||||
steps: list[dict[str, Any]],
|
||||
seen_ids: set[str],
|
||||
errors: list[str],
|
||||
input_names: set[str] | None = None,
|
||||
inside_fan_out: bool = False,
|
||||
) -> None:
|
||||
"""Recursively validate a list of steps.
|
||||
|
||||
``input_names`` is the set of declared workflow input names (or ``None``
|
||||
when the inputs block is malformed). ``inside_fan_out`` is threaded
|
||||
through nested control-flow steps so gate verdict bindings can be rejected
|
||||
anywhere inside a fan-out template.
|
||||
"""
|
||||
"""Recursively validate a list of steps."""
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
for step_config in steps:
|
||||
@@ -427,73 +411,30 @@ def _validate_steps(
|
||||
f"unknown or not-yet-declared step id {wid!r}."
|
||||
)
|
||||
|
||||
# Gate verdict_input: fan-out items cannot bind shared workflow inputs
|
||||
# as per-item verdicts. Outside fan-out, the binding must reference a
|
||||
# declared workflow input because ``_resolve_inputs`` drops undeclared
|
||||
# names at both initial run and resume. Only check a non-empty string;
|
||||
# malformed shapes are already reported by ``GateStep.validate()``.
|
||||
if step_type == "gate":
|
||||
verdict_input = step_config.get("verdict_input")
|
||||
if isinstance(verdict_input, str) and verdict_input:
|
||||
if inside_fan_out:
|
||||
errors.append(
|
||||
f"Gate step {step_id!r}: 'verdict_input' is not "
|
||||
"supported inside fan-out templates."
|
||||
)
|
||||
elif input_names is not None and verdict_input not in input_names:
|
||||
errors.append(
|
||||
f"Gate step {step_id!r}: 'verdict_input' references "
|
||||
f"undeclared input {verdict_input!r}."
|
||||
)
|
||||
|
||||
# Recursively validate nested steps
|
||||
for nested_key in ("then", "else", "steps"):
|
||||
nested = step_config.get(nested_key)
|
||||
if isinstance(nested, list):
|
||||
_validate_steps(
|
||||
nested,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_names,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
_validate_steps(nested, seen_ids, errors)
|
||||
|
||||
# Validate switch cases
|
||||
cases = step_config.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for _case_key, case_steps in cases.items():
|
||||
if isinstance(case_steps, list):
|
||||
_validate_steps(
|
||||
case_steps,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_names,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
_validate_steps(case_steps, seen_ids, errors)
|
||||
|
||||
# Validate switch default
|
||||
default = step_config.get("default")
|
||||
if isinstance(default, list):
|
||||
_validate_steps(
|
||||
default,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_names,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
_validate_steps(default, seen_ids, errors)
|
||||
|
||||
# Validate fan-out nested step (template — not added to seen_ids
|
||||
# since the engine generates parentId:templateId:index at runtime)
|
||||
fan_step = step_config.get("step")
|
||||
if isinstance(fan_step, dict):
|
||||
fan_errors: list[str] = []
|
||||
_validate_steps(
|
||||
[fan_step],
|
||||
set(),
|
||||
fan_errors,
|
||||
input_names,
|
||||
inside_fan_out=True,
|
||||
)
|
||||
_validate_steps([fan_step], set(), fan_errors)
|
||||
errors.extend(fan_errors)
|
||||
|
||||
|
||||
@@ -619,7 +560,6 @@ class RunState:
|
||||
self.created_at = datetime.now(timezone.utc).isoformat()
|
||||
self.updated_at = self.created_at
|
||||
self.log_entries: list[dict[str, Any]] = []
|
||||
self.error: str | None = None
|
||||
|
||||
@property
|
||||
def runs_dir(self) -> Path:
|
||||
@@ -674,7 +614,6 @@ class RunState:
|
||||
"workflow_dir": self.workflow_dir,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"error": self.error,
|
||||
}
|
||||
self._atomic_write_json(runs_dir / "state.json", state_data)
|
||||
self._atomic_write_json(runs_dir / "inputs.json", {"inputs": self.inputs})
|
||||
@@ -768,7 +707,6 @@ class RunState:
|
||||
state.workflow_dir = state_data.get("workflow_dir")
|
||||
state.created_at = state_data.get("created_at", "")
|
||||
state.updated_at = state_data.get("updated_at", "")
|
||||
state.error = state_data.get("error")
|
||||
|
||||
inputs_path = runs_dir / "inputs.json"
|
||||
if inputs_path.exists():
|
||||
@@ -963,7 +901,6 @@ class WorkflowEngine:
|
||||
return state
|
||||
except Exception as exc:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = str(exc)
|
||||
state.append_log({"event": "workflow_failed", "error": str(exc)})
|
||||
state.save()
|
||||
raise
|
||||
@@ -1022,7 +959,6 @@ class WorkflowEngine:
|
||||
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
state.error = None
|
||||
state.status = RunStatus.RUNNING
|
||||
state.save()
|
||||
|
||||
@@ -1043,7 +979,6 @@ class WorkflowEngine:
|
||||
return state
|
||||
except Exception as exc:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = str(exc)
|
||||
state.append_log({"event": "resume_failed", "error": str(exc)})
|
||||
state.save()
|
||||
raise
|
||||
@@ -1103,7 +1038,6 @@ class WorkflowEngine:
|
||||
step_impl = registry.get(step_type)
|
||||
if not step_impl:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = f"Unknown step type: {step_type!r}"
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
@@ -1131,7 +1065,6 @@ class WorkflowEngine:
|
||||
or step_config.get("input", {}),
|
||||
"output": result.output,
|
||||
"status": result.status.value,
|
||||
"error": result.error,
|
||||
}
|
||||
self._record_result(context, state, step_id, step_data)
|
||||
|
||||
@@ -1157,7 +1090,6 @@ class WorkflowEngine:
|
||||
# is for transient/expected step failures only.
|
||||
if result.output.get("aborted"):
|
||||
state.status = RunStatus.ABORTED
|
||||
state.error = result.error
|
||||
state.append_log(
|
||||
{
|
||||
"event": "workflow_aborted",
|
||||
@@ -1200,7 +1132,6 @@ class WorkflowEngine:
|
||||
continue
|
||||
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = result.error
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
@@ -1374,18 +1305,11 @@ class WorkflowEngine:
|
||||
# Sequential path — identical to the historical behavior.
|
||||
if workers <= 1:
|
||||
results: list[Any] = []
|
||||
previous_item = context.item
|
||||
previous_inside_fan_out = context.inside_fan_out
|
||||
context.inside_fan_out = True
|
||||
try:
|
||||
for item_idx, item_val in enumerate(items):
|
||||
context.item = item_val
|
||||
results.append(run_item(item_idx, context))
|
||||
if state.status in halting:
|
||||
break
|
||||
finally:
|
||||
context.item = previous_item
|
||||
context.inside_fan_out = previous_inside_fan_out
|
||||
for item_idx, item_val in enumerate(items):
|
||||
context.item = item_val
|
||||
results.append(run_item(item_idx, context))
|
||||
if state.status in halting:
|
||||
break
|
||||
return results
|
||||
|
||||
# Concurrent path — bounded sliding window; results assembled in item order.
|
||||
@@ -1396,14 +1320,7 @@ class WorkflowEngine:
|
||||
# Each item runs against its own context copy so context.item is not
|
||||
# clobbered across threads; the shared steps dict is written only on the
|
||||
# disjoint parentId:templateId:index key (GIL-safe on distinct keys).
|
||||
return run_item(
|
||||
idx,
|
||||
dataclasses.replace(
|
||||
context,
|
||||
item=items[idx],
|
||||
inside_fan_out=True,
|
||||
),
|
||||
)
|
||||
return run_item(idx, dataclasses.replace(context, item=items[idx]))
|
||||
|
||||
def item_halt_status(idx: int) -> RunStatus | None:
|
||||
# If THIS item's own execution halted the run, return the resulting run
|
||||
@@ -1484,16 +1401,6 @@ class WorkflowEngine:
|
||||
# pool joined; restore the halting item's own outcome so the final run
|
||||
# status matches the sequential semantics.
|
||||
state.status = halted_status
|
||||
# Restore the halting item's error so it matches the terminal
|
||||
# status — a concurrent item may have overwritten state.error
|
||||
# before the pool joined. Assign unconditionally when a record
|
||||
# exists (even when the halting item's own error is falsy) so a
|
||||
# third-party step returning FAILED with no message never inherits
|
||||
# an unrelated concurrent item's error; this mirrors the sequential
|
||||
# path, which sets state.error = result.error verbatim.
|
||||
halt_rec = context.steps.get(item_id(halted_at))
|
||||
if isinstance(halt_rec, dict):
|
||||
state.error = halt_rec.get("error")
|
||||
return slots[: halted_at + 1]
|
||||
return slots[:collected]
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ..._console import console, err_console
|
||||
from ...extensions import normalize_priority
|
||||
@@ -413,23 +412,14 @@ def workflow_resolve(project_root: Path, workflow_id: str) -> dict[str, Any] | N
|
||||
priority = (
|
||||
"n/a" if layer.tier == "base" else str(normalize_priority(layer.priority))
|
||||
)
|
||||
# ``\[`` keeps the literal bracket: unescaped, Rich parses ``[base]`` /
|
||||
# ``[project-overlay]`` as a style tag and swallows the tier label whole.
|
||||
console.print(
|
||||
f" \u2022 \\[{_escape_markup(layer.tier)}] "
|
||||
f"{_escape_markup(layer.source)} "
|
||||
f" \u2022 [{layer.tier}] {layer.source} "
|
||||
f"(priority={priority})"
|
||||
)
|
||||
|
||||
console.print("Step attribution:")
|
||||
for composed in attribution:
|
||||
# Step IDs come from base-workflow / overlay YAML, which only bans ``:``
|
||||
# \u2014 brackets pass validation, so they reach Rich as markup. A balanced
|
||||
# ``[stuff]`` is swallowed; an unbalanced ``[/red]`` raises MarkupError.
|
||||
console.print(
|
||||
f" \u2022 {_escape_markup(composed.step_id)}: "
|
||||
f"{_escape_markup(composed.source)}"
|
||||
)
|
||||
console.print(f" \u2022 {composed.step_id}: {composed.source}")
|
||||
|
||||
return {
|
||||
"workflow_id": workflow_id,
|
||||
|
||||
@@ -26,8 +26,7 @@ class GateStep(StepBase):
|
||||
later with ``specify workflow resume``.
|
||||
|
||||
The user's choice is stored in ``output.choice``. ``on_reject``
|
||||
controls abort / skip / retry behaviour. ``verdict_input`` can name a
|
||||
workflow input to use as the choice when resuming non-interactively.
|
||||
controls abort / skip / retry behaviour.
|
||||
"""
|
||||
|
||||
type_key = "gate"
|
||||
@@ -43,8 +42,6 @@ class GateStep(StepBase):
|
||||
|
||||
options = config.get("options", ["approve", "reject"])
|
||||
on_reject = config.get("on_reject", "abort")
|
||||
has_verdict_input = "verdict_input" in config
|
||||
verdict_input = config.get("verdict_input")
|
||||
|
||||
# ``validate`` rejects a non-list (or empty) ``options``, and requires
|
||||
# every option to be a string, but the engine does not auto-validate
|
||||
@@ -75,26 +72,6 @@ class GateStep(StepBase):
|
||||
},
|
||||
)
|
||||
|
||||
if has_verdict_input and (
|
||||
not isinstance(verdict_input, str) or not verdict_input
|
||||
):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
|
||||
"a non-empty string."
|
||||
),
|
||||
)
|
||||
|
||||
if has_verdict_input and context.inside_fan_out:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' is "
|
||||
"not supported inside fan-out templates."
|
||||
),
|
||||
)
|
||||
|
||||
show_file = config.get("show_file")
|
||||
if isinstance(show_file, str) and "{{" in show_file:
|
||||
show_file = evaluate_expression(show_file, context)
|
||||
@@ -113,48 +90,16 @@ class GateStep(StepBase):
|
||||
"choice": None,
|
||||
}
|
||||
|
||||
choice: str | None = None
|
||||
bound_verdict_input: str | None = None
|
||||
if verdict_input is not None:
|
||||
value = context.inputs.get(verdict_input)
|
||||
if value is not None and value != "":
|
||||
if not isinstance(value, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output=output,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: verdict input "
|
||||
f"{verdict_input!r} must be a string, got "
|
||||
f"{type(value).__name__}."
|
||||
),
|
||||
)
|
||||
choice = next(
|
||||
(option for option in options if option.lower() == value.lower()),
|
||||
None,
|
||||
)
|
||||
if choice is None:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output=output,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: verdict input "
|
||||
f"{verdict_input!r} value {value!r} does not match any "
|
||||
"configured option."
|
||||
),
|
||||
)
|
||||
bound_verdict_input = verdict_input
|
||||
# Non-interactive: pause for later resume (the file is not read here)
|
||||
if not sys.stdin.isatty():
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
|
||||
if choice is None:
|
||||
# Non-interactive: pause for later resume (the file is not read here)
|
||||
if not sys.stdin.isatty():
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
|
||||
# Interactive: prompt the user. ``show_file`` contents are folded
|
||||
# into the displayed message so the operator can review the
|
||||
# referenced material before choosing. Composing the prompt text
|
||||
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
|
||||
# adding review material never widens the interactive seam.
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
# Interactive: prompt the user. ``show_file`` contents are folded
|
||||
# into the displayed message so the operator can review the
|
||||
# referenced material before choosing. Composing the prompt text
|
||||
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
|
||||
# adding review material never widens the interactive seam.
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
output["choice"] = choice
|
||||
|
||||
# Match rejection case-insensitively. ``_prompt`` echoes the option's
|
||||
@@ -174,8 +119,6 @@ class GateStep(StepBase):
|
||||
)
|
||||
if on_reject == "retry":
|
||||
# Pause so the next resume re-executes this gate
|
||||
if bound_verdict_input is not None:
|
||||
context.inputs[bound_verdict_input] = ""
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
# on_reject == "skip" → completed, downstream steps decide
|
||||
return StepResult(status=StepStatus.COMPLETED, output=output)
|
||||
@@ -291,13 +234,6 @@ class GateStep(StepBase):
|
||||
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
|
||||
f"'abort', 'skip', or 'retry'."
|
||||
)
|
||||
if "verdict_input" in config and (
|
||||
not isinstance(config["verdict_input"], str) or not config["verdict_input"]
|
||||
):
|
||||
errors.append(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
|
||||
"a non-empty string."
|
||||
)
|
||||
# Only inspect option text when every option is a string; otherwise the
|
||||
# `o.lower()` below would raise AttributeError on a non-string option
|
||||
# (already reported above) and break validate_workflow's never-raise contract.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -89,15 +88,6 @@ class PromptStep(StepBase):
|
||||
),
|
||||
)
|
||||
|
||||
# An invalid timeout reaches subprocess.run() and raises a raw
|
||||
# TypeError ("unsupported operand type(s) for +: 'float' and 'str'")
|
||||
# or ValueError, which the engine re-raises — taking down the whole
|
||||
# run with a message that names neither the step nor 'timeout'. Fail
|
||||
# this step cleanly instead, mirroring the shell step.
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
return StepResult(status=StepStatus.FAILED, error=timeout_error)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
timeout = config.get("timeout", 300)
|
||||
dispatch_result = self._try_dispatch(
|
||||
@@ -141,41 +131,6 @@ class PromptStep(StepBase):
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _timeout_error(config: dict[str, Any]) -> str | None:
|
||||
"""Return an error message if ``config['timeout']`` is invalid, else None.
|
||||
|
||||
Shared by execute() and validate() so both paths reject the same
|
||||
values with the same message, mirroring the shell step. An absent
|
||||
``timeout`` is valid (the default is used). bool is a subclass of int,
|
||||
but ``timeout: true`` is a config error rather than a duration, so it
|
||||
is rejected explicitly. Non-finite floats (YAML ``.inf``/``.nan``) pass
|
||||
a plain ``> 0`` check but would raise in subprocess.run(), and a
|
||||
non-positive timeout makes subprocess.run() report an immediate
|
||||
TimeoutExpired, so both are rejected too.
|
||||
"""
|
||||
if "timeout" not in config:
|
||||
return None
|
||||
timeout = config["timeout"]
|
||||
try:
|
||||
valid_timeout = (
|
||||
not isinstance(timeout, bool)
|
||||
and isinstance(timeout, (int, float))
|
||||
and timeout > 0
|
||||
and math.isfinite(timeout)
|
||||
)
|
||||
except OverflowError:
|
||||
# An int too large to convert to float (e.g. a 400-digit YAML
|
||||
# scalar) clears every clause above and raises here — and would
|
||||
# raise the same from subprocess.run(timeout=...).
|
||||
valid_timeout = False
|
||||
if not valid_timeout:
|
||||
return (
|
||||
f"Prompt step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive number of seconds, got {timeout!r}."
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _try_dispatch(
|
||||
prompt: str,
|
||||
@@ -214,17 +169,6 @@ class PromptStep(StepBase):
|
||||
if not exec_args:
|
||||
return None
|
||||
|
||||
# Windows: ``subprocess.run`` calls ``CreateProcess``, which does not
|
||||
# consult ``PATHEXT``, so a bare command name like ``claude`` installed
|
||||
# as ``claude.cmd`` (the usual npm shim layout) fails with
|
||||
# ``WinError 2``. That OSError is swallowed below and reported as "CLI
|
||||
# not found or not installed" -- even though the preflight above just
|
||||
# found it. Reuse the already-resolved path so the shim is executed,
|
||||
# mirroring ``IntegrationBase.dispatch_command``, which the ``command``
|
||||
# step already goes through. On POSIX this is the same executable.
|
||||
if fallback_cli_path:
|
||||
exec_args = [fallback_cli_path, *exec_args[1:]]
|
||||
|
||||
import subprocess
|
||||
|
||||
project_root = (
|
||||
@@ -295,7 +239,4 @@ class PromptStep(StepBase):
|
||||
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
)
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
errors.append(timeout_error)
|
||||
return errors
|
||||
|
||||
@@ -121,20 +121,12 @@ class ShellStep(StepBase):
|
||||
if "timeout" not in config:
|
||||
return None
|
||||
timeout = config["timeout"]
|
||||
try:
|
||||
invalid_timeout = (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout)
|
||||
or timeout <= 0
|
||||
)
|
||||
except OverflowError:
|
||||
# An int too large to convert to float (e.g. a 400-digit YAML
|
||||
# scalar) is not a bool and *is* an int, so it clears every clause
|
||||
# before ``isfinite()`` and raises there — and would raise the same
|
||||
# from subprocess.run(timeout=...). Mirrors the prompt step.
|
||||
invalid_timeout = True
|
||||
if invalid_timeout:
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout)
|
||||
or timeout <= 0
|
||||
):
|
||||
return (
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive number of seconds, got {timeout!r}."
|
||||
|
||||
@@ -26,45 +26,6 @@ def test_missing_required_field_is_reported_by_name():
|
||||
assert any("bundle.license" in e for e in errors)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field", ["name", "role", "description", "author", "license"]
|
||||
)
|
||||
def test_explicit_null_bundle_field_is_reported_as_missing(field):
|
||||
"""A field present but null is how YAML spells an empty value (`author:`).
|
||||
|
||||
`str(None)` is the literal text "None", which is non-empty, so it passed the
|
||||
required-field checks: the bundle validated clean and shipped "None" as its
|
||||
author/license/description.
|
||||
"""
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"][field] = None
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert getattr(manifest.bundle, field) == ""
|
||||
assert any(f"bundle.{field}" in e for e in manifest.structural_errors())
|
||||
|
||||
|
||||
def test_explicit_null_speckit_version_is_reported_as_missing():
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["speckit_version"] = None
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.requires.speckit_version == ""
|
||||
assert any("speckit_version" in e for e in manifest.structural_errors())
|
||||
|
||||
|
||||
def test_explicit_null_component_id_is_not_named_none():
|
||||
"""A null component id must not become a component literally named "None"."""
|
||||
data = valid_manifest_dict()
|
||||
for kind, items in (data.get("provides") or {}).items():
|
||||
if isinstance(items, list) and items and isinstance(items[0], dict):
|
||||
items[0]["id"] = None
|
||||
break
|
||||
else: # pragma: no cover - fixture is expected to provide components
|
||||
pytest.skip("fixture has no component list to null out")
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.components, "fixture is expected to declare components"
|
||||
assert all(ref.id != "None" for ref in manifest.components)
|
||||
|
||||
|
||||
def test_unsupported_schema_version_is_rejected():
|
||||
data = valid_manifest_dict(schema_version="9.9")
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Contract tests: every bundled preset must ship inside the wheel's core_pack.
|
||||
|
||||
``specify preset add <id>`` resolves a bundled preset via
|
||||
``specify_cli._assets._locate_bundled_preset``, which checks the wheel's
|
||||
``specify_cli/core_pack/presets/<id>/`` directory first. Any preset marked
|
||||
``bundled: true`` in ``presets/catalog.json`` must therefore be force-included
|
||||
at build time; otherwise the released wheel advertises a bundled preset it does
|
||||
not actually ship, and ``specify preset add <id>`` falls through and reports the
|
||||
preset as missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
def _force_include() -> dict[str, str]:
|
||||
with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file:
|
||||
pyproject = tomllib.load(pyproject_file)
|
||||
return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"]
|
||||
|
||||
|
||||
def _bundled_preset_ids() -> list[str]:
|
||||
catalog = json.loads((REPO_ROOT / "presets" / "catalog.json").read_text())
|
||||
return sorted(
|
||||
preset_id
|
||||
for preset_id, entry in catalog["presets"].items()
|
||||
if entry.get("bundled")
|
||||
)
|
||||
|
||||
|
||||
def test_every_bundled_preset_is_force_included():
|
||||
force_include = _force_include()
|
||||
bundled = _bundled_preset_ids()
|
||||
|
||||
assert bundled, "expected at least one bundled preset in presets/catalog.json"
|
||||
for preset_id in bundled:
|
||||
assert force_include.get(f"presets/{preset_id}") == (
|
||||
f"specify_cli/core_pack/presets/{preset_id}"
|
||||
), f"bundled preset '{preset_id}' is missing from the wheel force-include list"
|
||||
|
||||
|
||||
def test_constitution_sync_is_bundled_and_shipped():
|
||||
# Explicit regression guard: constitution-sync was advertised as bundled
|
||||
# before it was added to the wheel force-include list.
|
||||
assert "constitution-sync" in _bundled_preset_ids()
|
||||
assert _force_include()["presets/constitution-sync"] == (
|
||||
"specify_cli/core_pack/presets/constitution-sync"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,15 +116,12 @@ class TestCatalogURLValidation:
|
||||
[
|
||||
"https://[::1", # unclosed ipv6 bracket
|
||||
"https://[not-an-ip]/c.json", # bracketed non-ip host
|
||||
"https://example.com:notaport/c.json", # non-numeric port
|
||||
"https://example.com:65536/c.json", # out-of-range port
|
||||
],
|
||||
)
|
||||
def test_malformed_url_rejected_cleanly(self, url):
|
||||
# A malformed authority makes urlparse/hostname raise ValueError, and a
|
||||
# bad port makes ``parsed.port`` raise it. The validator must turn that
|
||||
# into its normal catalog error, not leak a raw ValueError to the caller
|
||||
# (or, for a bad port, accept the URL and fail later at fetch time).
|
||||
# A malformed authority makes urlparse/hostname raise ValueError. The
|
||||
# validator must turn that into its normal catalog error, not leak a
|
||||
# raw ValueError to the caller.
|
||||
with pytest.raises(IntegrationCatalogError, match="malformed"):
|
||||
IntegrationCatalog._validate_catalog_url(url)
|
||||
|
||||
|
||||
@@ -109,21 +109,6 @@ class TestCopilotIntegration:
|
||||
assert settings not in created
|
||||
assert not any("settings.json" in k for k in m.files)
|
||||
|
||||
def test_setup_preserves_non_utf8_vscode_settings(self, tmp_path, caplog):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
vscode_dir = tmp_path / ".vscode"
|
||||
vscode_dir.mkdir(parents=True)
|
||||
settings = vscode_dir / "settings.json"
|
||||
original = b'{"editor.fontSize": 14}\xff'
|
||||
settings.write_bytes(original)
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
assert settings.read_bytes() == original
|
||||
assert "Could not parse" in caplog.text
|
||||
|
||||
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
|
||||
@@ -3831,68 +3831,6 @@ class TestIntegrationUpgrade:
|
||||
"upgrade of the active integration re-registers extension commands"
|
||||
)
|
||||
|
||||
def test_upgrade_copilot_skills_restores_extension_skill_over_regenerated_dir(
|
||||
self, tmp_path
|
||||
):
|
||||
"""End-to-end regression for #3849 (upgrade-overwrites-copilot-skills).
|
||||
|
||||
In Copilot skills mode, ``integration upgrade`` runs ``setup()`` — which
|
||||
regenerates the core-template skill directories — *before* re-registering
|
||||
installed extensions. The extension re-registration then hits the
|
||||
``skill_dir_preexists`` guard in ``_register_extension_skills`` (the skill
|
||||
sub-directory exists, courtesy of ``setup()``, but its ``SKILL.md`` has
|
||||
not been rewritten with extension content), so pre-fix the extension
|
||||
skill was silently left missing — its command content lost even though the
|
||||
extension remained installed and registered.
|
||||
|
||||
The fix threads ``force=True`` from ``integration_upgrade()`` down to
|
||||
``_register_extension_skills`` so the guard is bypassed and the extension
|
||||
content is re-composed on top of the just-regenerated directory. This test
|
||||
exercises the full ``specify integration upgrade`` command path and fails
|
||||
without the fix (the skill is never recreated).
|
||||
"""
|
||||
project = _init_project(
|
||||
tmp_path, "copilot", integration_options="--skills"
|
||||
)
|
||||
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
|
||||
skill_dir = project / ".github" / "skills" / "speckit-git-feature"
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
assert skill_file.exists(), (
|
||||
"precondition: git extension renders as a Copilot skill"
|
||||
)
|
||||
original = skill_file.read_text(encoding="utf-8")
|
||||
assert "source: extension:git" in original, (
|
||||
"precondition: skill carries the git extension ownership marker"
|
||||
)
|
||||
|
||||
# Simulate the exact pre-condition the bug depends on: the skill file is
|
||||
# gone but its directory survives (as it does once setup() regenerates the
|
||||
# core-template layout during upgrade), triggering the skill_dir_preexists
|
||||
# skip guard on re-registration.
|
||||
skill_file.unlink()
|
||||
assert skill_dir.exists() and not skill_file.exists()
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
assert skill_file.exists(), (
|
||||
"upgrade must restore the extension skill even when its directory "
|
||||
"already exists (regression #3849)"
|
||||
)
|
||||
restored = skill_file.read_text(encoding="utf-8")
|
||||
assert "source: extension:git" in restored, (
|
||||
"restored skill must contain the git extension content, not a bare "
|
||||
"core-template stub"
|
||||
)
|
||||
assert "# Git Feature Skill" in restored
|
||||
|
||||
def test_upgrade_active_integration_reregisters_presets(self, tmp_path):
|
||||
"""Upgrading the active integration restores missing preset artifacts."""
|
||||
import yaml
|
||||
|
||||
@@ -242,34 +242,6 @@ class TestManifestUninstall:
|
||||
"remove_manifest=False must keep the manifest file on disk"
|
||||
)
|
||||
|
||||
def test_undeletable_manifest_is_skipped_not_raised(self, tmp_path):
|
||||
"""An undeletable manifest must not abort the whole uninstall.
|
||||
|
||||
The tracked files are removed *before* the manifest, so raising here
|
||||
loses the ``(removed, skipped)`` result the caller needs: the CLI's
|
||||
post-uninstall bookkeeping (reassigning the default integration,
|
||||
rewriting/removing ``integration.json``, clearing init options) never
|
||||
runs, leaving a removed integration still recorded as installed.
|
||||
|
||||
Leaving a directory at the manifest path is a portable way to make
|
||||
``unlink()`` fail with no chmod and no monkeypatch: it raises
|
||||
``IsADirectoryError`` on Linux and ``PermissionError`` on
|
||||
Windows/macOS, both ``OSError`` subclasses.
|
||||
"""
|
||||
m = IntegrationManifest("test", tmp_path, version="1.0")
|
||||
m.record_file("f.txt", "content")
|
||||
m.save()
|
||||
m.manifest_path.unlink()
|
||||
m.manifest_path.mkdir()
|
||||
|
||||
removed, skipped = m.uninstall()
|
||||
|
||||
assert removed == [tmp_path / "f.txt"]
|
||||
assert not (tmp_path / "f.txt").exists()
|
||||
assert m.manifest_path in skipped, (
|
||||
"an undeletable manifest must be reported in skipped"
|
||||
)
|
||||
|
||||
def test_cleans_empty_parent_dirs(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("a/b/c/f.txt", "content")
|
||||
@@ -375,14 +347,6 @@ class TestManifestLoadValidation:
|
||||
with pytest.raises(ValueError, match="invalid JSON"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_non_utf8_json_raises_value_error(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"\xff\xfe")
|
||||
|
||||
with pytest.raises(ValueError, match="valid UTF-8"):
|
||||
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
|
||||
|
||||
@@ -370,69 +370,3 @@ def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) ->
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
|
||||
|
||||
|
||||
class TestGetInvokeSeparatorTolerance:
|
||||
"""`get_invoke_separator` must fall back to "." for an unusable
|
||||
`integration.json`, matching its bash and PowerShell twins.
|
||||
|
||||
The bash twin tries jq -> python3 -> awk and keeps its `separator="."`
|
||||
default on any parse failure; the PowerShell twin likewise returns ".".
|
||||
The Python twin instead indexed the parsed value directly, so two shapes
|
||||
escaped its `except (OSError, json.JSONDecodeError)`:
|
||||
|
||||
* a non-mapping top level (`[]`, `"forge"`, `42`, `null`) is valid JSON,
|
||||
so JSONDecodeError never fires and `.get()` raised AttributeError;
|
||||
* a non-UTF-8 file raises UnicodeDecodeError -- a ValueError, not an
|
||||
OSError. Realistic on Windows, where PowerShell 5.1's `Out-File`/`>`
|
||||
default to UTF-16.
|
||||
|
||||
The sibling `read_feature_json_feature_directory` in the same module
|
||||
already guards both.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _load_common():
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("_speckit_common_py", COMMON_PY)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Register before exec: the module defines @dataclass types, and
|
||||
# dataclasses resolves cls.__module__ through sys.modules.
|
||||
sys.modules[spec.name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception: # pragma: no cover - defensive cleanup
|
||||
sys.modules.pop(spec.name, None)
|
||||
raise
|
||||
return module
|
||||
|
||||
def _repo(self, tmp_path: Path, body: str | bytes) -> Path:
|
||||
(tmp_path / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
target = tmp_path / ".specify" / "integration.json"
|
||||
if isinstance(body, bytes):
|
||||
target.write_bytes(body)
|
||||
else:
|
||||
target.write_text(body, encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body", ["[]", '[{"a": 1}]', '"forge"', "42", "true", "null"]
|
||||
)
|
||||
def test_non_mapping_integration_json_falls_back(self, tmp_path: Path, body: str):
|
||||
common = self._load_common()
|
||||
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "."
|
||||
|
||||
def test_non_utf8_integration_json_falls_back(self, tmp_path: Path):
|
||||
common = self._load_common()
|
||||
raw = '{"default_integration": "forge"}'.encode("utf-16")
|
||||
assert common.get_invoke_separator(self._repo(tmp_path, raw)) == "."
|
||||
|
||||
def test_hyphen_separator_is_still_honoured(self, tmp_path: Path):
|
||||
"""Regression guard: the real feature must keep working."""
|
||||
common = self._load_common()
|
||||
body = json.dumps({
|
||||
"default_integration": "droid",
|
||||
"integration_settings": {"droid": {"invoke_separator": "-"}},
|
||||
})
|
||||
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "-"
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import io
|
||||
import stat
|
||||
import struct
|
||||
import tarfile
|
||||
import weakref
|
||||
import zipfile
|
||||
import zlib
|
||||
@@ -14,16 +13,11 @@ import pytest
|
||||
|
||||
from specify_cli._download_security import (
|
||||
MAX_ZIP_CENTRAL_DIRECTORY_BYTES,
|
||||
archive_format_from_content_type,
|
||||
archive_format_from_name,
|
||||
build_safe_download_path,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
is_loopback_url,
|
||||
read_response_limited,
|
||||
read_zip_member_limited,
|
||||
safe_extract_archive,
|
||||
safe_extract_tar,
|
||||
safe_extract_zip,
|
||||
)
|
||||
|
||||
@@ -320,176 +314,6 @@ def test_build_safe_download_path_rejects_nonportable_identifiers(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("package.zip", "zip"),
|
||||
("PACKAGE.TAR.GZ", "tar.gz"),
|
||||
("https://example.com/package.tgz?download=1", "tar.gz"),
|
||||
("package.tar", None),
|
||||
],
|
||||
)
|
||||
def test_archive_format_from_name(name, expected):
|
||||
assert archive_format_from_name(name) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content_type", "expected"),
|
||||
[
|
||||
("application/zip", "zip"),
|
||||
("application/x-zip-compressed; charset=binary", "zip"),
|
||||
("application/gzip", "tar.gz"),
|
||||
("application/x-gzip", "tar.gz"),
|
||||
("application/octet-stream", None),
|
||||
],
|
||||
)
|
||||
def test_archive_format_from_content_type(content_type, expected):
|
||||
assert archive_format_from_content_type(content_type) == expected
|
||||
|
||||
|
||||
def _write_tar_gz(path, members):
|
||||
with tarfile.open(path, "w:gz") as archive:
|
||||
for name, content in members:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
|
||||
def test_detect_archive_format_accepts_tar_suffixes(tmp_path, suffix):
|
||||
archive_path = tmp_path / f"package{suffix}"
|
||||
_write_tar_gz(archive_path, [("file.txt", b"contents")])
|
||||
|
||||
assert detect_archive_format(archive_path) == "tar.gz"
|
||||
|
||||
|
||||
def test_detect_archive_format_allows_content_type_fallback(tmp_path):
|
||||
archive_path = tmp_path / "download"
|
||||
_write_tar_gz(archive_path, [("file.txt", b"contents")])
|
||||
|
||||
assert (
|
||||
detect_archive_format(
|
||||
archive_path,
|
||||
source_name="https://example.com/download",
|
||||
content_type="application/gzip",
|
||||
)
|
||||
== "tar.gz"
|
||||
)
|
||||
|
||||
|
||||
def test_detect_archive_format_rejects_suffix_content_mismatch(tmp_path):
|
||||
archive_path = tmp_path / "package.zip"
|
||||
_write_tar_gz(archive_path, [("file.txt", b"contents")])
|
||||
|
||||
with pytest.raises(ValueError, match="format mismatch"):
|
||||
detect_archive_format(archive_path)
|
||||
|
||||
|
||||
def test_detect_archive_format_rejects_suffix_header_mismatch(tmp_path):
|
||||
archive_path = tmp_path / "package.zip"
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("file.txt", "contents")
|
||||
|
||||
with pytest.raises(ValueError, match="Content-Type"):
|
||||
detect_archive_format(
|
||||
archive_path,
|
||||
content_type="application/gzip",
|
||||
)
|
||||
|
||||
|
||||
def test_build_safe_download_path_uses_archive_suffix(tmp_path):
|
||||
path = build_safe_download_path(tmp_path, "package", "1.0.0", suffix=".tar.gz")
|
||||
assert path.name == "package-1.0.0.tar.gz"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name",
|
||||
["../evil.txt", "nested/../../evil.txt", "C:/Windows/evil.txt"],
|
||||
)
|
||||
def test_safe_extract_tar_rejects_traversal(tmp_path, member_name):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
_write_tar_gz(archive_path, [(member_name, b"nope")])
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe path"):
|
||||
safe_extract_tar(archive_path, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("link_type", "message"),
|
||||
[(tarfile.SYMTYPE, "symlink"), (tarfile.LNKTYPE, "hard link")],
|
||||
)
|
||||
def test_safe_extract_tar_rejects_links_without_partial_extraction(
|
||||
tmp_path, link_type, message
|
||||
):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
safe = tarfile.TarInfo("safe.txt")
|
||||
safe.size = 4
|
||||
archive.addfile(safe, io.BytesIO(b"safe"))
|
||||
link = tarfile.TarInfo("escape")
|
||||
link.type = link_type
|
||||
link.linkname = "../../outside"
|
||||
archive.addfile(link)
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
with pytest.raises(ValueError, match=message):
|
||||
safe_extract_tar(archive_path, out_dir)
|
||||
|
||||
assert not out_dir.exists() or not any(out_dir.rglob("*"))
|
||||
|
||||
|
||||
def test_safe_extract_tar_rejects_special_file(tmp_path):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
fifo = tarfile.TarInfo("pipe")
|
||||
fifo.type = tarfile.FIFOTYPE
|
||||
archive.addfile(fifo)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe member type"):
|
||||
safe_extract_tar(archive_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_tar_rejects_conflicting_paths(tmp_path):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
_write_tar_gz(
|
||||
archive_path,
|
||||
[("Folder/file.txt", b"one"), ("folder/FILE.txt", b"two")],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Conflicting path"):
|
||||
safe_extract_tar(archive_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_tar_enforces_entry_and_size_limits(tmp_path):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
_write_tar_gz(
|
||||
archive_path,
|
||||
[("one.txt", b"1234"), ("two.txt", b"5678")],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="too many entries"):
|
||||
safe_extract_tar(archive_path, tmp_path / "entries", max_entries=1)
|
||||
with pytest.raises(ValueError, match="member.*maximum size"):
|
||||
safe_extract_tar(archive_path, tmp_path / "member", max_member_bytes=3)
|
||||
with pytest.raises(ValueError, match="uncompressed size"):
|
||||
safe_extract_tar(archive_path, tmp_path / "total", max_total_bytes=7)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"])
|
||||
def test_safe_extract_archive_has_format_parity(tmp_path, suffix):
|
||||
archive_path = tmp_path / f"package{suffix}"
|
||||
if suffix == ".zip":
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("nested/file.txt", b"contents")
|
||||
else:
|
||||
_write_tar_gz(archive_path, [("nested/file.txt", b"contents")])
|
||||
|
||||
out_dir = tmp_path / f"out-{suffix.replace('.', '-')}"
|
||||
safe_extract_archive(archive_path, out_dir)
|
||||
|
||||
assert (out_dir / "nested" / "file.txt").read_bytes() == b"contents"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name",
|
||||
[
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"""Security tests for the extension URL download cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.extensions import ExtensionCatalog, ExtensionManager
|
||||
from specify_cli.extensions import _commands
|
||||
|
||||
|
||||
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _require_secure_dir_fd() -> None:
|
||||
if (
|
||||
not getattr(os, "O_NOFOLLOW", 0)
|
||||
or os.open not in os.supports_dir_fd
|
||||
or os.mkdir not in os.supports_dir_fd
|
||||
):
|
||||
pytest.skip("requires dir_fd and O_NOFOLLOW support")
|
||||
|
||||
|
||||
def _symlink_directory(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"directory symlinks are unavailable: {exc}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ancestor_parts",
|
||||
[
|
||||
("extensions",),
|
||||
("extensions", ".cache"),
|
||||
("extensions", ".cache", "downloads"),
|
||||
],
|
||||
)
|
||||
def test_symlinked_cache_ancestor_is_refused(
|
||||
project_dir: Path, tmp_path: Path, ancestor_parts: tuple[str, ...]
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
parent = project_dir / ".specify"
|
||||
for part in ancestor_parts[:-1]:
|
||||
parent = parent / part
|
||||
parent.mkdir()
|
||||
_symlink_directory(parent / ancestor_parts[-1], outside)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_commands._validate_safe_cache_dir(project_dir)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ancestor_parts",
|
||||
[
|
||||
("extensions",),
|
||||
("extensions", ".cache"),
|
||||
("extensions", ".cache", "downloads"),
|
||||
],
|
||||
)
|
||||
def test_symlinked_cache_ancestor_is_refused_without_dir_fd(
|
||||
project_dir: Path,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
ancestor_parts: tuple[str, ...],
|
||||
) -> None:
|
||||
"""The portable (Windows) validation path must also refuse a symlinked
|
||||
cache ancestor and never create anything under the symlink target."""
|
||||
monkeypatch.setattr(os, "supports_dir_fd", set())
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
parent = project_dir / ".specify"
|
||||
for part in ancestor_parts[:-1]:
|
||||
parent = parent / part
|
||||
parent.mkdir()
|
||||
_symlink_directory(parent / ancestor_parts[-1], outside)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_commands._validate_safe_cache_dir(project_dir)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
def test_cache_ancestor_resolving_outside_project_is_refused(
|
||||
project_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
cache_root = project_dir / ".specify" / "extensions" / ".cache"
|
||||
cache_root.mkdir(parents=True)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
real_resolve = Path.resolve
|
||||
|
||||
def fake_resolve(self: Path, *args, **kwargs) -> Path:
|
||||
if self == cache_root:
|
||||
return real_resolve(outside, *args, **kwargs)
|
||||
return real_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fake_resolve)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_commands._validate_safe_cache_dir(project_dir)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
def test_safe_open_refuses_exclusive_leaf_collision(project_dir: Path) -> None:
|
||||
_require_secure_dir_fd()
|
||||
download_dir = _commands._validate_safe_cache_dir(project_dir)
|
||||
zip_filename = "extension-url-download-collision.zip"
|
||||
collision = download_dir / zip_filename
|
||||
collision.write_bytes(b"sentinel")
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_commands._safe_open_download_zip(
|
||||
project_dir, download_dir, zip_filename
|
||||
)
|
||||
|
||||
assert collision.read_bytes() == b"sentinel"
|
||||
|
||||
|
||||
def test_safe_open_refuses_swapped_cache_ancestor(
|
||||
project_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
download_dir = _commands._validate_safe_cache_dir(project_dir)
|
||||
cache_root = project_dir / ".specify" / "extensions" / ".cache"
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
shutil.rmtree(cache_root)
|
||||
_symlink_directory(cache_root, outside)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_commands._safe_open_download_zip(
|
||||
project_dir,
|
||||
download_dir,
|
||||
"extension-url-download-swapped.zip",
|
||||
)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
def test_safe_open_refuses_symlinked_project_root(
|
||||
project_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
project_link = tmp_path / "project-link"
|
||||
_symlink_directory(project_link, project_dir)
|
||||
download_dir = project_link / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_commands._safe_open_download_zip(
|
||||
project_link,
|
||||
download_dir,
|
||||
"extension-url-download-project-link.zip",
|
||||
)
|
||||
|
||||
|
||||
def test_safe_open_succeeds_without_dir_fd_support(
|
||||
project_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""On a platform without dir_fd (e.g. Windows) the portable path must
|
||||
still hand back a usable, exclusively-created descriptor rather than
|
||||
failing closed."""
|
||||
monkeypatch.setattr(os, "supports_dir_fd", set())
|
||||
|
||||
download_dir = _commands._validate_safe_cache_dir(project_dir)
|
||||
assert download_dir == (
|
||||
project_dir / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
)
|
||||
|
||||
fd = _commands._safe_open_download_zip(
|
||||
project_dir, download_dir, "extension-url-download-portable.zip"
|
||||
)
|
||||
try:
|
||||
os.write(fd, b"payload")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
assert os.read(fd, 7) == b"payload"
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def test_safe_open_without_dir_fd_refuses_symlinked_leaf(
|
||||
project_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The portable path must refuse a leaf pre-staged as a symlink so an
|
||||
attacker cannot redirect the exclusive create outside the project."""
|
||||
monkeypatch.setattr(os, "supports_dir_fd", set())
|
||||
download_dir = _commands._validate_safe_cache_dir(project_dir)
|
||||
outside = tmp_path / "outside.zip"
|
||||
zip_filename = "extension-url-download-symlink-leaf.zip"
|
||||
try:
|
||||
(download_dir / zip_filename).symlink_to(outside)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"symlinks are unavailable: {exc}")
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_commands._safe_open_download_zip(project_dir, download_dir, zip_filename)
|
||||
|
||||
assert not outside.exists()
|
||||
|
||||
|
||||
def test_url_install_succeeds_without_dir_fd_support(
|
||||
project_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A full ``--from`` install must work on platforms without dir_fd rather
|
||||
than failing closed, exercising the portable hardened download path."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def fake_install(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
captured["bytes"] = archive_file.read()
|
||||
archive_file.seek(0)
|
||||
return SimpleNamespace(
|
||||
id="test-ext",
|
||||
name="Test Extension",
|
||||
version="1.0.0",
|
||||
description="",
|
||||
warnings=[],
|
||||
commands=[],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(os, "supports_dir_fd", set())
|
||||
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
ExtensionCatalog,
|
||||
"_open_url",
|
||||
lambda *args, **kwargs: FakeResponse(_MINIMAL_ZIP_BYTES),
|
||||
)
|
||||
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install)
|
||||
monkeypatch.setattr(_commands, "_refresh_events_and_warn", lambda root: None)
|
||||
monkeypatch.setattr(_commands, "load_init_options", lambda root: {})
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"extension",
|
||||
"add",
|
||||
"test-ext",
|
||||
"--from",
|
||||
"https://example.com/test-ext.zip",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["bytes"] == _MINIMAL_ZIP_BYTES
|
||||
|
||||
|
||||
def test_url_install_writes_and_cleans_up_secure_download(
|
||||
project_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def fake_install(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
captured["path"] = zip_path
|
||||
captured["mode"] = os.fstat(archive_file.fileno()).st_mode & 0o777
|
||||
captured["exists_during_install"] = zip_path.exists()
|
||||
captured["bytes"] = archive_file.read()
|
||||
archive_file.seek(0)
|
||||
return SimpleNamespace(
|
||||
id="test-ext",
|
||||
name="Test Extension",
|
||||
version="1.0.0",
|
||||
description="",
|
||||
warnings=[],
|
||||
commands=[],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
ExtensionCatalog,
|
||||
"_open_url",
|
||||
lambda *args, **kwargs: FakeResponse(_MINIMAL_ZIP_BYTES),
|
||||
)
|
||||
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install)
|
||||
monkeypatch.setattr(_commands, "_refresh_events_and_warn", lambda root: None)
|
||||
monkeypatch.setattr(_commands, "load_init_options", lambda root: {})
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"extension",
|
||||
"add",
|
||||
"test-ext",
|
||||
"--from",
|
||||
"https://example.com/test-ext.zip",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["bytes"] == _MINIMAL_ZIP_BYTES
|
||||
assert captured["mode"] == 0o600
|
||||
# The archive is an anonymous inode: it is never visible on disk, even
|
||||
# while installation consumes the open descriptor.
|
||||
assert captured["exists_during_install"] is False
|
||||
zip_path = captured["path"]
|
||||
assert isinstance(zip_path, Path)
|
||||
assert zip_path.parent == (
|
||||
project_dir / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
)
|
||||
assert zip_path.name.startswith("extension-url-download-")
|
||||
assert not zip_path.exists()
|
||||
|
||||
|
||||
def test_url_install_open_error_surfaces_as_controlled_exit(
|
||||
project_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An ``OSError`` from the hardened create (e.g. an exclusive-leaf
|
||||
collision or a swapped ancestor) must fail closed as ``typer.Exit(1)``
|
||||
rather than escaping as an unhandled traceback, and installation must
|
||||
not run."""
|
||||
download_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
|
||||
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
ExtensionCatalog,
|
||||
"_open_url",
|
||||
lambda *args, **kwargs: io.BytesIO(_MINIMAL_ZIP_BYTES),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_commands, "_validate_safe_cache_dir", lambda root: download_dir
|
||||
)
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _raise_collision(project_root, dir_, zip_filename):
|
||||
raise FileExistsError("leaf already exists")
|
||||
|
||||
monkeypatch.setattr(_commands, "_safe_open_download_zip", _raise_collision)
|
||||
install_spy = MagicMock()
|
||||
monkeypatch.setattr(ExtensionManager, "install_from_zip", install_spy)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"extension",
|
||||
"add",
|
||||
"test-ext",
|
||||
"--from",
|
||||
"https://example.com/test-ext.zip",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Could not safely create download file" in result.output
|
||||
install_spy.assert_not_called()
|
||||
@@ -2943,135 +2943,6 @@ class TestExtensionSkillRegistration:
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
|
||||
|
||||
# ===== Regression test: upgrade-overwrites-copilot-skills (#3849) =====
|
||||
|
||||
class TestRegisterExtensionSkillsForceFlag:
|
||||
"""Regression tests for the ``force`` flag on ``_register_extension_skills``.
|
||||
|
||||
Issue #3849: ``integration upgrade --force`` called ``setup()`` which
|
||||
regenerated all core-template SKILL.md files, then called
|
||||
``register_enabled_extensions_for_agent()``. The skip-guard in
|
||||
``_register_extension_skills`` treated the freshly-written core files as
|
||||
existing user content and skipped every extension skill, leaving only core
|
||||
template content on disk.
|
||||
|
||||
The fix introduces ``force=True`` in the upgrade path so the guard does not
|
||||
fire for core-template files that setup() just wrote.
|
||||
"""
|
||||
|
||||
def test_force_false_skips_existing_skill(self, project_dir, temp_dir):
|
||||
"""Without force=True the skip guard must still protect existing files."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Manually pre-create a SKILL.md as if setup() had already written it
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
# Default (force=False): existing file must not be overwritten
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=False)
|
||||
assert "speckit-test-ext-hello" not in written
|
||||
assert skill_file.read_text(encoding="utf-8") == "core-template content only"
|
||||
|
||||
def test_force_true_overwrites_existing_skill(self, project_dir, temp_dir):
|
||||
"""With force=True the function must overwrite the existing SKILL.md.
|
||||
|
||||
This is the core regression test for #3849: calling
|
||||
``_register_extension_skills(force=True)`` after ``setup()`` has
|
||||
written a fresh core-template SKILL.md must replace it with the
|
||||
composed extension content.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Simulate what setup() writes: a bare core-template SKILL.md
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
# Upgrade path (force=True): extension content should replace the core file
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=True)
|
||||
assert "speckit-test-ext-hello" in written, (
|
||||
"force=True should overwrite the core-template file and return the skill name"
|
||||
)
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "Run this to say hello." in content, (
|
||||
"Extension command body must appear in the overwritten SKILL.md"
|
||||
)
|
||||
assert "core-template content only" not in content, (
|
||||
"Core-template placeholder must have been replaced by extension content"
|
||||
)
|
||||
|
||||
def test_register_enabled_extensions_for_agent_force_flag_threads_through(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""force=True on register_enabled_extensions_for_agent must reach _register_extension_skills.
|
||||
|
||||
End-to-end check: after an upgrade writes a fresh core-template SKILL.md,
|
||||
``register_enabled_extensions_for_agent(force=True)`` must produce a
|
||||
SKILL.md that contains the extension content.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
# Install extension so it is in the registry
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Simulate a freshly-regenerated core-template SKILL.md (as setup() would write)
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
# Re-register with force=True (upgrade path)
|
||||
manager.register_enabled_extensions_for_agent("claude", force=True)
|
||||
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "Run this to say hello." in content, (
|
||||
"After register_enabled_extensions_for_agent(force=True), the SKILL.md "
|
||||
"must contain the extension body, not just the core-template stub."
|
||||
)
|
||||
|
||||
def test_force_true_with_preexisting_dir_but_no_skill_file(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""force=True must write into a pre-existing directory with no SKILL.md.
|
||||
|
||||
The second skip guard (``elif skill_dir_preexists``) should also be
|
||||
bypassed by force=True so an upgrade can create a missing SKILL.md
|
||||
even when the skill sub-directory already exists.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Create the skill directory without the SKILL.md file
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
assert not skill_file.exists()
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=True)
|
||||
assert "speckit-test-ext-hello" in written
|
||||
assert skill_file.exists()
|
||||
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ===== Extension Skill Unregistration Tests =====
|
||||
|
||||
class TestExtensionSkillUnregistration:
|
||||
|
||||
+6
-265
@@ -49,38 +49,6 @@ from specify_cli._utils import version_satisfies
|
||||
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
|
||||
|
||||
|
||||
def _open_test_download_zip(project_root, download_dir, zip_filename):
|
||||
"""Cross-platform stand-in for the POSIX-only secure cache primitive.
|
||||
|
||||
Mirrors production behavior by making the leaf disappear from disk while
|
||||
the descriptor stays open. On POSIX the file is unlinked immediately; on
|
||||
Windows an in-use file cannot be unlinked, so it is opened with
|
||||
``O_TEMPORARY`` and removed automatically when the descriptor closes.
|
||||
"""
|
||||
target = download_dir / zip_filename
|
||||
o_temporary = getattr(os, "O_TEMPORARY", 0)
|
||||
if o_temporary:
|
||||
return os.open(
|
||||
target,
|
||||
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary,
|
||||
0o600,
|
||||
)
|
||||
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
os.unlink(target)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
raise
|
||||
return fd
|
||||
|
||||
|
||||
def _validate_safe_cache_dir_test_stand_in(project_root):
|
||||
"""Cross-platform stand-in for the secure cache validator."""
|
||||
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
return download_dir
|
||||
|
||||
|
||||
def can_create_symlink(tmp_path: Path) -> bool:
|
||||
"""Return True when the current platform/user can create file symlinks."""
|
||||
target = tmp_path / "symlink-target.txt"
|
||||
@@ -608,7 +576,7 @@ class TestExtensionManifest:
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"):
|
||||
with pytest.raises(ValidationError, match="must provide at least one command or hook"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hooks_only_extension(self, temp_dir, valid_manifest_data):
|
||||
@@ -646,67 +614,6 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid provides.commands"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize("section", ["extension", "requires", "provides"])
|
||||
@pytest.mark.parametrize("bad", [None, [], "text"])
|
||||
def test_required_section_not_mapping_rejected(
|
||||
self, temp_dir, valid_manifest_data, section, bad
|
||||
):
|
||||
"""A required section that is written but empty or wrongly shaped must
|
||||
raise ValidationError, not a raw TypeError/AttributeError.
|
||||
|
||||
REQUIRED_FIELDS only checks key presence, so `provides:` with no value
|
||||
passed it and then hit `None.get(...)`. That AttributeError escaped
|
||||
list_installed()'s ValidationError-only "Corrupted extension" fallback,
|
||||
so one bad extension made `specify extension list` exit 1 instead of
|
||||
listing the others.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data[section] = bad
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match=f"Invalid {section}"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_empty_provides_mapping_is_still_accepted_with_hooks(
|
||||
self, temp_dir, valid_manifest_data
|
||||
):
|
||||
"""Regression guard: `provides: {}` is a well-SHAPED mapping, so the new
|
||||
shape check must not reject it — an extension may provide only hooks."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"] = {}
|
||||
assert valid_manifest_data.get("hooks"), "fixture is expected to define hooks"
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
ExtensionManifest(manifest_path) # must not raise
|
||||
|
||||
def test_empty_provides_and_no_hooks_keeps_its_own_message(
|
||||
self, temp_dir, valid_manifest_data
|
||||
):
|
||||
"""...and with no hooks (or events) either, it reports the "nothing
|
||||
provided" message rather than the new shape error."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"] = {}
|
||||
valid_manifest_data.pop("hooks", None)
|
||||
valid_manifest_data.pop("events", None)
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="at least one command, hook, or event"
|
||||
):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with hooks as a list is rejected."""
|
||||
import yaml
|
||||
@@ -2261,79 +2168,6 @@ class TestExtensionManager:
|
||||
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="requires replacing an open file")
|
||||
def test_install_from_zip_uses_open_archive_after_path_replacement(
|
||||
self, extension_dir, project_dir, temp_dir
|
||||
):
|
||||
"""An authoritative archive stream must survive pathname replacement."""
|
||||
import zipfile
|
||||
|
||||
zip_path = temp_dir / "original-extension.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as archive:
|
||||
for file_path in extension_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
archive.write(file_path, file_path.relative_to(extension_dir))
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
with zip_path.open("rb") as archive_file:
|
||||
zip_path.unlink()
|
||||
with zipfile.ZipFile(zip_path, "w"):
|
||||
pass
|
||||
manifest = manager.install_from_zip(
|
||||
zip_path,
|
||||
"0.1.0",
|
||||
archive_file=archive_file,
|
||||
)
|
||||
|
||||
assert manifest.id == "test-ext"
|
||||
assert manager.registry.is_installed("test-ext")
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
|
||||
@pytest.mark.parametrize("nested", [False, True])
|
||||
def test_install_from_tar_archive(
|
||||
self, extension_dir, project_dir, temp_dir, suffix, nested
|
||||
):
|
||||
"""Tar archives install with the same flat/nested behavior as ZIP."""
|
||||
import tarfile
|
||||
|
||||
archive_path = temp_dir / f"test-ext{suffix}"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
for file_path in extension_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
relative = file_path.relative_to(extension_dir)
|
||||
arcname = Path("test-ext-v1") / relative if nested else relative
|
||||
archive.add(file_path, arcname=arcname)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_archive(archive_path, "0.1.0")
|
||||
|
||||
assert manifest.id == "test-ext"
|
||||
assert manager.registry.is_installed("test-ext")
|
||||
|
||||
def test_install_from_tar_rejects_symlink_entry(
|
||||
self, extension_dir, project_dir, temp_dir
|
||||
):
|
||||
import tarfile
|
||||
|
||||
archive_path = temp_dir / "symlink-extension.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
for file_path in extension_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
archive.add(
|
||||
file_path,
|
||||
arcname=file_path.relative_to(extension_dir),
|
||||
)
|
||||
link = tarfile.TarInfo("templates/escape")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "../../outside"
|
||||
archive.addfile(link)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
with pytest.raises(ValidationError, match="Unsafe symlink"):
|
||||
manager.install_from_archive(archive_path, "0.1.0")
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
|
||||
"""Test that duplicate install error message suggests --force."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -2828,36 +2662,6 @@ Real body starts here.
|
||||
|
||||
assert parsed["description"] == "first line\nsecond line\n"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("description", "expected"),
|
||||
[
|
||||
(None, ""), # "description:" with no value
|
||||
(42, "42"), # unquoted number
|
||||
(True, "True"), # unquoted boolean
|
||||
(["a", "b"], "['a', 'b']"), # was silently concatenated to "ab"
|
||||
],
|
||||
)
|
||||
def test_render_toml_command_coerces_non_string_description(
|
||||
self, description, expected
|
||||
):
|
||||
"""Frontmatter comes from yaml.safe_load, so description can be any type.
|
||||
|
||||
_render_basic_toml_string iterates the value and calls ord() per
|
||||
character, so a non-string raised a raw TypeError and a list of
|
||||
single-character items was silently concatenated into a wrong value.
|
||||
render_yaml_command (same class) already coerces; this brings the TOML
|
||||
branch to parity.
|
||||
"""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": description}, "body", "extension:test-ext"
|
||||
)
|
||||
|
||||
parsed = tomllib.loads(output)
|
||||
assert parsed["description"] == expected
|
||||
|
||||
def test_render_toml_command_escapes_control_characters(self):
|
||||
"""Control characters and a lone CR must be escaped so the TOML parses.
|
||||
|
||||
@@ -5811,35 +5615,6 @@ class TestExtensionCatalog:
|
||||
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[0].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
|
||||
def test_download_extension_preserves_tar_archive_format(
|
||||
self, temp_dir, suffix
|
||||
):
|
||||
import tarfile
|
||||
from unittest.mock import patch
|
||||
|
||||
archive_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
|
||||
content = b"extension:\n id: test-ext\n"
|
||||
member = tarfile.TarInfo("extension.yml")
|
||||
member.size = len(content)
|
||||
archive.addfile(member, io.BytesIO(content))
|
||||
archive_bytes = archive_buffer.getvalue()
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
ext_info = {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"download_url": f"https://example.com/test-ext{suffix}",
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch.object(catalog, "_open_url", return_value=self._mock_response(archive_bytes)):
|
||||
archive_path = catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert archive_path.name == "test-ext-1.0.0.tar.gz"
|
||||
assert archive_path.read_bytes() == archive_bytes
|
||||
|
||||
|
||||
|
||||
# ===== CatalogEntry Tests =====
|
||||
@@ -7525,15 +7300,7 @@ class TestExtensionAddCLI:
|
||||
|
||||
manifest_id = "[red]bad[/red]"
|
||||
|
||||
def fake_install_from_zip(
|
||||
self_obj,
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority=10,
|
||||
force=False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
|
||||
return SimpleNamespace(
|
||||
id=manifest_id,
|
||||
name="Bad Extension",
|
||||
@@ -7547,9 +7314,7 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
|
||||
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
|
||||
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \
|
||||
patch.object(ExtensionRegistry, "get", return_value={}):
|
||||
result = runner.invoke(
|
||||
@@ -7597,7 +7362,6 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
side_effect=urllib.error.URLError("bad [red]download[/red]"),
|
||||
@@ -7639,7 +7403,6 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
return_value=FakeResponse(b"<!DOCTYPE html><html>Sign in</html>"),
|
||||
@@ -7690,7 +7453,6 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
return_value=FakeResponse(_MINIMAL_ZIP_BYTES),
|
||||
@@ -7746,15 +7508,7 @@ class TestExtensionAddCLI:
|
||||
seen["headers"] = extra_headers
|
||||
return FakeResponse(_MINIMAL_ZIP_BYTES)
|
||||
|
||||
def fake_install(
|
||||
self_obj,
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority=10,
|
||||
force=False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
def fake_install(self_obj, zip_path, speckit_version, priority=10, force=False):
|
||||
return SimpleNamespace(
|
||||
id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[]
|
||||
)
|
||||
@@ -7762,10 +7516,8 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
|
||||
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
|
||||
patch.object(ExtensionManager, "install_from_zip", fake_install):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -7838,19 +7590,10 @@ class TestExtensionAddCLI:
|
||||
downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
installed = {}
|
||||
|
||||
def fake_install_from_zip(
|
||||
self_obj,
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority=10,
|
||||
force=False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
|
||||
captured_path = Path(zip_path)
|
||||
installed["zip_path"] = captured_path
|
||||
installed["zip_bytes"] = archive_file.read()
|
||||
archive_file.seek(0)
|
||||
installed["zip_bytes"] = captured_path.read_bytes()
|
||||
return SimpleNamespace(
|
||||
id="escape",
|
||||
name="Escape Test",
|
||||
@@ -7864,9 +7607,7 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
|
||||
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
|
||||
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -7927,7 +7668,7 @@ class TestDownloadExtensionBundled:
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.side_effect = io.BytesIO(_MINIMAL_ZIP_BYTES).read
|
||||
mock_response.read.side_effect = io.BytesIO(b"fake zip data").read
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
||||
|
||||
@@ -11,7 +11,6 @@ See proposals/monorepo-support and github/spec-kit discussion #2834.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -31,11 +30,6 @@ HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
_PS_EXE = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
# Windows PowerShell 5.1 (.NET Framework) specifically, never pwsh: the only
|
||||
# host that lacks the .NET Core-only [System.IO.Path] members, so it is the only
|
||||
# one that can pin a 5.1 compatibility regression (issue #3749).
|
||||
_WINDOWS_POWERSHELL = _POWERSHELL if os.name == "nt" else None
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""Inherited env minus all SPECIFY_* vars, so a developer/CI override
|
||||
@@ -107,10 +101,6 @@ requires_pwsh = pytest.mark.skipif(
|
||||
not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available"
|
||||
)
|
||||
|
||||
requires_windows_powershell = pytest.mark.skipif(
|
||||
_WINDOWS_POWERSHELL is None, reason="Windows PowerShell 5.1 not available"
|
||||
)
|
||||
|
||||
|
||||
# ── Bash: positive cases ────────────────────────────────────────────────────
|
||||
|
||||
@@ -475,131 +465,3 @@ def test_ps_file_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
result = _ps("Get-RepoRoot", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
|
||||
|
||||
# ── Windows PowerShell 5.1 compatibility (issue #3749) ──────────────────────
|
||||
#
|
||||
# The CI matrix runs these PowerShell tests under `pwsh` on every OS, and pwsh
|
||||
# is .NET Core, so a .NET Framework-only regression is invisible to it. The
|
||||
# static test below therefore runs everywhere and is the one that actually
|
||||
# guards CI; the runtime tests pin the real behavior where a 5.1 host exists.
|
||||
|
||||
# .NET Core-only [System.IO.Path] members. Absent on .NET Framework, so calling
|
||||
# one throws "does not contain a method named ..." on Windows PowerShell 5.1.
|
||||
_DOTNET_CORE_ONLY_PATH_MEMBERS = (
|
||||
"TrimEndingDirectorySeparator",
|
||||
"EndsInDirectorySeparator",
|
||||
"GetRelativePath",
|
||||
"Join",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member", _DOTNET_CORE_ONLY_PATH_MEMBERS)
|
||||
def test_shipped_ps1_avoids_dotnet_core_only_path_members(member: str) -> None:
|
||||
"""No shipped .ps1 may call a .NET Core-only [System.IO.Path] member.
|
||||
|
||||
Windows PowerShell 5.1 ships on every Windows box and runs on .NET
|
||||
Framework, where these members do not exist. A call is not a graceful
|
||||
degradation but a hard "Method invocation failed" at the call site, which
|
||||
for a root resolver aborts the command before it starts.
|
||||
|
||||
Runs on all platforms because the CI matrix only has pwsh (.NET Core),
|
||||
where such a call works fine -- so this static check is what keeps CI able
|
||||
to catch the regression at all.
|
||||
"""
|
||||
# Anchored to the Path type: [string]::Join and other same-named members on
|
||||
# .NET Framework types are unaffected and must not be flagged.
|
||||
pattern = re.compile(
|
||||
r"\[(?:System\.IO\.)?Path\]::" + re.escape(member) + r"\s*\(",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
offenders = []
|
||||
for ps1 in sorted(PROJECT_ROOT.glob("scripts/powershell/*.ps1")) + sorted(
|
||||
PROJECT_ROOT.glob("extensions/*/scripts/powershell/*.ps1")
|
||||
):
|
||||
for lineno, line in enumerate(
|
||||
ps1.read_text(encoding="utf-8").splitlines(), start=1
|
||||
):
|
||||
code = line.split("#", 1)[0]
|
||||
if pattern.search(code):
|
||||
offenders.append(f"{ps1.relative_to(PROJECT_ROOT)}:{lineno}")
|
||||
assert not offenders, (
|
||||
f"[System.IO.Path]::{member}() is .NET Core only and throws on Windows "
|
||||
f"PowerShell 5.1; found at {offenders}. Use a .NET Framework-safe "
|
||||
f"equivalent (e.g. TrimEnd('/', '\\') for a trailing separator)."
|
||||
)
|
||||
|
||||
|
||||
@requires_windows_powershell
|
||||
def test_ps51_init_dir_resolves(tmp_path: Path) -> None:
|
||||
"""SPECIFY_INIT_DIR must resolve under Windows PowerShell 5.1 (issue #3749).
|
||||
|
||||
Before the fix, Resolve-SpecifyInitDir called the .NET Core-only
|
||||
[System.IO.Path]::TrimEndingDirectorySeparator, so every 5.1 invocation
|
||||
threw at that line -- root resolution failed before the requested command
|
||||
ran, and $initRoot stayed $null so the very next Join-Path threw too.
|
||||
"""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = subprocess.run(
|
||||
[_WINDOWS_POWERSHELL, "-NoProfile", "-Command", f'. "{COMMON_PS}"; Get-RepoRoot'],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert "does not contain a method named" not in result.stderr, result.stderr
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_windows_powershell
|
||||
def test_ps51_init_dir_trailing_separator_trimmed(tmp_path: Path) -> None:
|
||||
"""The 5.1-safe trim must still strip a trailing separator, for bash parity.
|
||||
|
||||
Resolve-Path echoes back the input's trailing separator; the bash resolver's
|
||||
`cd && pwd` never yields one, so the two must agree.
|
||||
"""
|
||||
web = _make_project(tmp_path, "web")
|
||||
for suffix in ("/", "\\"):
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}{suffix}"}
|
||||
result = subprocess.run(
|
||||
[
|
||||
_WINDOWS_POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
f'. "{COMMON_PS}"; Get-RepoRoot',
|
||||
],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_drive_root_reports_root_not_bare_drive(tmp_path: Path) -> None:
|
||||
"""A path that IS its own root must survive the trim intact.
|
||||
|
||||
A bare TrimEnd('/', '\\') -- the obvious 5.1-safe swap -- turns 'C:\\' into
|
||||
'C:', which is not the drive root but a drive-relative reference that every
|
||||
later path API re-resolves against the *current directory*. Validation would
|
||||
then probe the wrong tree entirely and, on a cwd that happens to contain
|
||||
.specify/, silently accept 'C:' as the project root. The drive root
|
||||
normally has no .specify/, so assert on the error naming the intact root.
|
||||
"""
|
||||
root = Path(tmp_path.anchor or "/")
|
||||
if (root / ".specify").exists():
|
||||
pytest.skip("filesystem root is itself a Spec Kit project")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(root)}
|
||||
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "not a Spec Kit project" in result.stderr
|
||||
# The error echoes the resolved root, so it pins what the trim produced:
|
||||
# 'C:\' (or '/') intact, never the bare 'C:' (or '') a naive TrimEnd leaves.
|
||||
reported = result.stderr.replace("\r", "").rstrip("\n").split("directory): ", 1)[-1]
|
||||
assert reported == str(root)
|
||||
|
||||
+2
-463
@@ -14,7 +14,6 @@ import pytest
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import tarfile
|
||||
import shutil
|
||||
import warnings
|
||||
import zipfile
|
||||
@@ -671,27 +670,6 @@ class TestPresetManager:
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
def test_install_from_zip_forwards_force(
|
||||
self, project_dir, pack_dir, temp_dir
|
||||
):
|
||||
"""The compatibility wrapper must retain forced reinstall behavior."""
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for file_path in pack_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
zf.write(file_path, file_path.relative_to(pack_dir))
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
manifest = manager.install_from_zip(
|
||||
zip_path,
|
||||
"0.1.5",
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
def test_install_from_zip_nested(self, project_dir, pack_dir, temp_dir):
|
||||
"""Test installing from ZIP with nested directory."""
|
||||
zip_path = temp_dir / "test-pack.zip"
|
||||
@@ -737,45 +715,6 @@ class TestPresetManager:
|
||||
|
||||
assert not manager.registry.is_installed("test-pack")
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
|
||||
@pytest.mark.parametrize("nested", [False, True])
|
||||
def test_install_from_tar_archive(
|
||||
self, project_dir, pack_dir, temp_dir, suffix, nested
|
||||
):
|
||||
"""Tar archives install with the same flat/nested behavior as ZIP."""
|
||||
archive_path = temp_dir / f"test-pack{suffix}"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
for file_path in pack_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
relative = file_path.relative_to(pack_dir)
|
||||
arcname = Path("test-pack-v1") / relative if nested else relative
|
||||
archive.add(file_path, arcname=arcname)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manifest = manager.install_from_archive(archive_path, "0.1.5")
|
||||
|
||||
assert manifest.id == "test-pack"
|
||||
assert manager.registry.is_installed("test-pack")
|
||||
|
||||
def test_install_from_tar_rejects_symlink_entry(
|
||||
self, project_dir, pack_dir, temp_dir
|
||||
):
|
||||
archive_path = temp_dir / "symlink-preset.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
for file_path in pack_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
archive.add(file_path, arcname=file_path.relative_to(pack_dir))
|
||||
link = tarfile.TarInfo("templates/escape")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "../../outside"
|
||||
archive.addfile(link)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
with pytest.raises(PresetValidationError, match="Unsafe symlink"):
|
||||
manager.install_from_archive(archive_path, "0.1.5")
|
||||
|
||||
assert not manager.registry.is_installed("test-pack")
|
||||
|
||||
def test_remove(self, project_dir, pack_dir):
|
||||
"""Test removing a preset."""
|
||||
manager = PresetManager(project_dir)
|
||||
@@ -2729,39 +2668,6 @@ class TestPresetCatalog:
|
||||
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[0].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
|
||||
def test_download_pack_preserves_tar_archive_format(
|
||||
self, project_dir, suffix
|
||||
):
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
archive_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
|
||||
content = b"preset:\n id: test-pack\n"
|
||||
member = tarfile.TarInfo("preset.yml")
|
||||
member.size = len(content)
|
||||
archive.addfile(member, io.BytesIO(content))
|
||||
archive_bytes = archive_buffer.getvalue()
|
||||
response = MagicMock()
|
||||
response.read.side_effect = io.BytesIO(archive_bytes).read
|
||||
response.__enter__.return_value = response
|
||||
response.__exit__.return_value = False
|
||||
catalog = PresetCatalog(project_dir)
|
||||
pack_info = {
|
||||
"id": "test-pack",
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
"download_url": f"https://example.com/test-pack{suffix}",
|
||||
"_install_allowed": True,
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
|
||||
patch.object(catalog, "_open_url", return_value=response):
|
||||
archive_path = catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
assert archive_path.name == "test-pack-1.0.0.tar.gz"
|
||||
assert archive_path.read_bytes() == archive_bytes
|
||||
|
||||
|
||||
# ===== Integration Tests =====
|
||||
|
||||
@@ -2920,80 +2826,6 @@ class TestPresetCatalogMultiCatalog:
|
||||
assert "https://example.com/[cat].json" in result.output
|
||||
assert "desc [with] brackets" in result.output
|
||||
|
||||
def test_catalog_add_escapes_rich_markup(self, project_dir):
|
||||
"""`preset catalog add` must not parse the name/url as Rich markup.
|
||||
|
||||
An unbalanced closing tag raised MarkupError *after* the entry was
|
||||
already written to preset-catalogs.yml, so the user saw a traceback
|
||||
and no confirmation for a catalog that had in fact been added.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
name = "[/red]my-catalog"
|
||||
url = "https://example.com/[bold]c.json"
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app, ["preset", "catalog", "add", url, "--name", name]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
# Rendered verbatim, not swallowed as markup.
|
||||
assert name in result.output
|
||||
assert url in result.output
|
||||
# Only rendering is escaped: the raw values still round-trip to disk.
|
||||
config = yaml.safe_load(
|
||||
(project_dir / ".specify" / "preset-catalogs.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert config["catalogs"][0]["name"] == name
|
||||
assert config["catalogs"][0]["url"] == url
|
||||
|
||||
def test_catalog_remove_escapes_rich_markup(self, project_dir):
|
||||
"""`preset catalog remove` must not parse the name as Rich markup."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
name = "[/red]my-catalog"
|
||||
(project_dir / ".specify" / "preset-catalogs.yml").write_text(
|
||||
yaml.dump({
|
||||
"catalogs": [
|
||||
{
|
||||
"name": name,
|
||||
"url": "https://example.com/c.json",
|
||||
"priority": 1,
|
||||
"install_allowed": False,
|
||||
}
|
||||
]
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "catalog", "remove", name])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert name in result.output
|
||||
|
||||
def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir):
|
||||
"""The not-found error path renders the name too."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
(project_dir / ".specify" / "preset-catalogs.yml").write_text(
|
||||
yaml.dump({"catalogs": []}), encoding="utf-8"
|
||||
)
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app, ["preset", "catalog", "remove", "[/red]absent"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "[/red]absent" in result.output
|
||||
|
||||
def test_env_var_overrides_catalogs(self, project_dir, monkeypatch):
|
||||
"""Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults."""
|
||||
monkeypatch.setenv(
|
||||
@@ -10178,7 +10010,7 @@ class TestBundledPresetLocator:
|
||||
self.read_sizes.append(size)
|
||||
return super().read(size)
|
||||
|
||||
response = FakeResponse(b"PK\x05\x06" + b"\x00" * 18)
|
||||
response = FakeResponse(b"zip-bytes")
|
||||
installed = {}
|
||||
|
||||
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
|
||||
@@ -10199,7 +10031,7 @@ class TestBundledPresetLocator:
|
||||
|
||||
assert response.read_sizes
|
||||
assert installed == {
|
||||
"zip_bytes": b"PK\x05\x06" + b"\x00" * 18,
|
||||
"zip_bytes": b"zip-bytes",
|
||||
"speckit_version": "0.6.0",
|
||||
"priority": 7,
|
||||
}
|
||||
@@ -12377,296 +12209,3 @@ class TestPresetCatalogRichMarkup:
|
||||
):
|
||||
value = self.MARKUP_PRESET[field]
|
||||
assert value in output
|
||||
# Tags are joined into a single line, so assert on the rendered join.
|
||||
assert ", ".join(self.MARKUP_PRESET["tags"]) in output
|
||||
|
||||
|
||||
class TestInstalledPresetRichMarkup:
|
||||
"""Locally installed preset metadata must render as literal text.
|
||||
|
||||
``preset.yml`` is user-editable, so its fields can contain ``[...]``.
|
||||
``TestPresetCatalogRichMarkup`` covers the catalog branch of these
|
||||
commands; the installed-preset branch of ``preset list``/``preset info``
|
||||
and all of ``preset resolve`` were left unescaped, so a field like
|
||||
``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an
|
||||
unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``,
|
||||
aborting the command with a traceback.
|
||||
"""
|
||||
|
||||
MARKUP_FIELDS = {
|
||||
"name": "[green]Markup Name[/green]",
|
||||
"version": "1.0.0",
|
||||
"description": "[yellow]Markup Description[/yellow]",
|
||||
"author": "[magenta]Markup Author[/magenta]",
|
||||
"repository": "[bold]Markup Repository[/bold]",
|
||||
"license": "[cyan]Markup License[/cyan]",
|
||||
}
|
||||
|
||||
def _install(self, temp_dir, project_dir, preset_overrides=None, strategy=None,
|
||||
pack_id="markup-pack", priority=10, tmpl_description=None):
|
||||
"""Install a preset from a directory built with the given manifest fields."""
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
src = temp_dir / f"src-{pack_id}"
|
||||
(src / "templates").mkdir(parents=True)
|
||||
(src / "templates" / "spec-template.md").write_text("# tmpl\n")
|
||||
|
||||
preset_section = {
|
||||
"id": pack_id,
|
||||
"name": pack_id,
|
||||
"version": "1.0.0",
|
||||
"description": "plain description",
|
||||
}
|
||||
preset_section.update(preset_overrides or {})
|
||||
tmpl = {
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
}
|
||||
if tmpl_description is not None:
|
||||
tmpl["description"] = tmpl_description
|
||||
if strategy:
|
||||
tmpl["strategy"] = strategy
|
||||
(src / "preset.yml").write_text(yaml.dump({
|
||||
"schema_version": "1.0",
|
||||
"preset": preset_section,
|
||||
"requires": {"speckit_version": ">=0.0.1"},
|
||||
"provides": {"templates": [tmpl]},
|
||||
"tags": ["[italic]markup-tag[/italic]"],
|
||||
}))
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(src, "9.9.9", priority)
|
||||
return manager
|
||||
|
||||
def _invoke(self, project_dir, args):
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
return CliRunner().invoke(app, args)
|
||||
|
||||
def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir):
|
||||
"""Every ``preset.yml`` field must survive verbatim in list/info output."""
|
||||
self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS)
|
||||
|
||||
for args in (["preset", "list"], ["preset", "info", "markup-pack"]):
|
||||
result = self._invoke(project_dir, args)
|
||||
assert result.exit_code == 0, result.output
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
# `preset list` does not render repository/license.
|
||||
fields = ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS
|
||||
for field in fields:
|
||||
assert self.MARKUP_FIELDS[field] in output, (field, args, output)
|
||||
assert "[italic]markup-tag[/italic]" in output, (args, output)
|
||||
|
||||
def test_info_does_not_swallow_template_description(self, temp_dir, project_dir):
|
||||
"""The per-template line in ``preset info`` must escape the template description.
|
||||
|
||||
``name``/``type`` are format-restricted by manifest validation, but
|
||||
``description`` is free-form, so it is the field that can carry markup.
|
||||
"""
|
||||
self._install(
|
||||
temp_dir,
|
||||
project_dir,
|
||||
tmpl_description="Template [desc] here",
|
||||
)
|
||||
result = self._invoke(project_dir, ["preset", "info", "markup-pack"])
|
||||
assert result.exit_code == 0, result.output
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
assert "spec-template (template): Template [desc] here" in output, output
|
||||
|
||||
def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir):
|
||||
"""An unbalanced tag must not raise MarkupError and abort the command."""
|
||||
self._install(
|
||||
temp_dir,
|
||||
project_dir,
|
||||
preset_overrides={"description": "Broken [/red] tag"},
|
||||
)
|
||||
|
||||
for args in (["preset", "list"], ["preset", "info", "markup-pack"]):
|
||||
result = self._invoke(project_dir, args)
|
||||
assert result.exit_code == 0, (args, result.output, result.exception)
|
||||
assert "Broken [/red] tag" in strip_ansi(result.output)
|
||||
|
||||
def test_resolve_escapes_template_name(self, project_dir):
|
||||
"""``preset resolve`` echoes its argument; an unbalanced tag must not crash."""
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"])
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
assert "no[/red]such" in strip_ansi(result.output)
|
||||
|
||||
def test_resolve_escapes_layer_path_and_source(self, project_dir):
|
||||
"""The top-layer path/source lines must render markup literally.
|
||||
|
||||
A preset can be installed from any directory, so the resolved path can
|
||||
contain ``[...]``; the layer source carries the pack id and version.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
# A closing tag cannot live inside a path segment: `Path` treats its
|
||||
# `/` as a separator on POSIX and rewrites it to `\` on Windows. The
|
||||
# opening tag covers the swallowing case for the path; the unbalanced
|
||||
# closing tag rides on `source`, which is a plain string.
|
||||
layer = {
|
||||
"path": Path("/tmp/[red]dir/spec-template.md"),
|
||||
"source": "pack [/red] v1.0.0",
|
||||
"strategy": "replace",
|
||||
}
|
||||
with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]):
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
|
||||
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
assert "[red]dir" in output, output
|
||||
assert "pack [/red] v1.0.0" in output, output
|
||||
|
||||
def test_resolve_escapes_fallback_path_and_source(self, project_dir):
|
||||
"""The no-layer fallback branch must escape ``resolve_with_source`` output."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
with patch.object(
|
||||
PresetResolver, "collect_all_layers", return_value=[]
|
||||
), patch.object(
|
||||
PresetResolver,
|
||||
"resolve_with_source",
|
||||
return_value={
|
||||
"path": "/tmp/[blue]fallback[/blue]/spec-template.md",
|
||||
"source": "fallback [/red] source",
|
||||
},
|
||||
):
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
|
||||
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
assert "[blue]fallback[/blue]" in output, output
|
||||
assert "fallback [/red] source" in output, output
|
||||
|
||||
def test_resolve_escapes_composition_error(self, project_dir):
|
||||
"""A composition exception message must not be parsed as markup."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
layers = [
|
||||
{
|
||||
"path": Path("/tmp/top/spec-template.md"),
|
||||
"source": "top-pack v1.0.0",
|
||||
"strategy": "append",
|
||||
},
|
||||
{
|
||||
"path": Path("/tmp/base/spec-template.md"),
|
||||
"source": "base-pack v1.0.0",
|
||||
"strategy": "append",
|
||||
},
|
||||
]
|
||||
with patch.object(
|
||||
PresetResolver, "collect_all_layers", return_value=layers
|
||||
), patch.object(
|
||||
PresetResolver,
|
||||
"resolve_content",
|
||||
side_effect=RuntimeError("compose failed: [/red] bad layer"),
|
||||
):
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
|
||||
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
assert "compose failed: [/red] bad layer" in output, output
|
||||
|
||||
def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir):
|
||||
"""The composition chain's ``[<strategy>]`` label must not be eaten as a tag."""
|
||||
self._install(temp_dir, project_dir, strategy="replace",
|
||||
pack_id="base-pack", priority=20)
|
||||
self._install(temp_dir, project_dir, strategy="append",
|
||||
pack_id="app-pack", priority=5)
|
||||
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Composition chain" in output, output
|
||||
assert "[base]" in output, output
|
||||
assert "[append]" in output, output
|
||||
|
||||
|
||||
class TestConstitutionSyncPreset:
|
||||
"""The bundled opt-in ``constitution-sync`` preset re-adds propagation.
|
||||
|
||||
Follow-up to #3790: core ``/constitution`` no longer propagates guidance
|
||||
into templates. This preset restores that behavior for teams that treat
|
||||
materialized templates as reviewed artifacts, delivered as a ``wrap`` of
|
||||
the core command so it stays forward-compatible with core changes.
|
||||
"""
|
||||
|
||||
PRESET_DIR = Path(__file__).parent.parent / "presets" / "constitution-sync"
|
||||
|
||||
def test_manifest_provides_wrap_of_constitution(self):
|
||||
manifest = yaml.safe_load((self.PRESET_DIR / "preset.yml").read_text())
|
||||
assert manifest["preset"]["id"] == "constitution-sync"
|
||||
entries = manifest["provides"]["templates"]
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry["type"] == "command"
|
||||
assert entry["name"] == "speckit.constitution"
|
||||
assert entry["strategy"] == "wrap"
|
||||
# Must target the post-#3790 baseline so propagation is not double-applied.
|
||||
assert manifest["requires"]["speckit_version"] == ">=0.14.4"
|
||||
|
||||
def test_wrapper_uses_core_template_and_propagates(self):
|
||||
text = (self.PRESET_DIR / "commands" / "speckit.constitution.md").read_text()
|
||||
|
||||
# Parse the Markdown frontmatter as YAML rather than substring-matching,
|
||||
# so `strategy: wrap` is asserted structurally (not as text that could
|
||||
# appear in the body) and {CORE_TEMPLATE} is asserted in the body only.
|
||||
assert text.startswith("---\n")
|
||||
_, frontmatter_block, body = text.split("---", 2)
|
||||
frontmatter = yaml.safe_load(frontmatter_block)
|
||||
assert frontmatter["strategy"] == "wrap"
|
||||
|
||||
assert "{CORE_TEMPLATE}" in body
|
||||
assert "strategy: wrap" not in body # only in frontmatter
|
||||
# The three governed scaffolds the old checklist propagated into.
|
||||
assert "plan-template.md" in body
|
||||
assert "spec-template.md" in body
|
||||
assert "tasks-template.md" in body
|
||||
# Must not mutate versioned preset/extension artifacts.
|
||||
assert "Do not edit versioned preset- or extension-provided template or command files" in body
|
||||
|
||||
def test_catalog_lists_bundled_preset(self):
|
||||
manifest = yaml.safe_load((self.PRESET_DIR / "preset.yml").read_text())
|
||||
catalog = json.loads((self.PRESET_DIR.parent / "catalog.json").read_text())
|
||||
entry = catalog["presets"]["constitution-sync"]
|
||||
assert entry["bundled"] is True
|
||||
assert entry["version"] == manifest["preset"]["version"]
|
||||
assert entry["provides"]["commands"] == 1
|
||||
assert entry["provides"]["templates"] == 0
|
||||
|
||||
def test_wrap_composes_over_core_constitution(self, project_dir):
|
||||
"""Installing the preset yields a wrap layer atop the bundled core."""
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(self.PRESET_DIR, "0.15.0")
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
layers = resolver.collect_all_layers("speckit.constitution", "command")
|
||||
assert len(layers) >= 2, "expected preset wrap layer plus a core base"
|
||||
assert layers[0]["strategy"] == "wrap"
|
||||
assert any("constitution-sync" in str(layer["path"]) for layer in layers)
|
||||
assert layers[-1]["source"] == "core (bundled)"
|
||||
|
||||
def test_resolved_content_embeds_core_and_sync_pass(self, project_dir):
|
||||
"""resolve_content substitutes {CORE_TEMPLATE} so the effective command
|
||||
contains both the bundled core body and the propagation pass."""
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(self.PRESET_DIR, "0.15.0")
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
content = resolver.resolve_content("speckit.constitution", "command")
|
||||
assert content is not None
|
||||
# {CORE_TEMPLATE} must be replaced, not left literal.
|
||||
assert "{CORE_TEMPLATE}" not in content
|
||||
# Core body is present (distinctive core-only heading).
|
||||
assert "## Scope Guard" in content
|
||||
# The wrapper's propagation pass is present and supersedes the guard.
|
||||
assert "## Constitution Template Sync" in content
|
||||
assert "supersedes the \"Scope Guard\" above" in content
|
||||
assert "plan-template.md" in content
|
||||
|
||||
@@ -30,7 +30,7 @@ LOCAL_REFRESH_TEST_EXTRA_DEPS = (
|
||||
f"--quiet --no-header --output-file {COMMITTED_AUDIT_REQUIREMENTS}"
|
||||
)
|
||||
WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS = (
|
||||
"uv pip compile pyproject.toml --extra test --universal --generate-hashes "
|
||||
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
|
||||
"--quiet --no-header --output-file"
|
||||
)
|
||||
WORKFLOW_SYNC_SCRIPT = "python .github/scripts/check_security_requirements.py"
|
||||
@@ -99,9 +99,7 @@ class TestDependencyAuditWorkflow:
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_BASE"] == (
|
||||
"${{ github.event.pull_request.base.sha || github.event.before || '' }}"
|
||||
)
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == (
|
||||
"${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
)
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == "${{ github.sha }}"
|
||||
assert sync_check["run"] == WORKFLOW_SYNC_SCRIPT
|
||||
assert committed_audit["run"] == LOCAL_PIP_AUDIT
|
||||
|
||||
@@ -241,14 +239,10 @@ class TestDependencyAuditWorkflow:
|
||||
|
||||
def test_sync_script_skips_when_dependency_inputs_are_unchanged(self, monkeypatch, capsys):
|
||||
sync_script = _load_sync_script()
|
||||
commands = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
commands.append(command)
|
||||
if command[:2] == ["git", "merge-base"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout="base123\n", stderr="")
|
||||
assert command == [
|
||||
"git", "diff", "--name-only", "base123", "HEAD", "--",
|
||||
"git", "diff", "--name-only", "HEAD^", "HEAD", "--",
|
||||
"pyproject.toml", ".github/security-audit-requirements.txt",
|
||||
]
|
||||
assert kwargs["check"] is True
|
||||
@@ -257,21 +251,16 @@ class TestDependencyAuditWorkflow:
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 0
|
||||
assert commands[0] == ["git", "merge-base", "HEAD^", "HEAD"]
|
||||
assert "sync check skipped" in capsys.readouterr().out
|
||||
|
||||
def test_sync_script_uses_github_diff_refs_when_available(self, monkeypatch):
|
||||
sync_script = _load_sync_script()
|
||||
monkeypatch.setenv("DEPENDENCY_DIFF_BASE", "abc123")
|
||||
monkeypatch.setenv("DEPENDENCY_DIFF_HEAD", "def456")
|
||||
commands = []
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
commands.append(command)
|
||||
if command[:2] == ["git", "merge-base"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout="merge123\n", stderr="")
|
||||
assert command == [
|
||||
"git", "diff", "--name-only", "merge123", "def456", "--",
|
||||
"git", "diff", "--name-only", "abc123", "def456", "--",
|
||||
"pyproject.toml", ".github/security-audit-requirements.txt",
|
||||
]
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
@@ -279,7 +268,6 @@ class TestDependencyAuditWorkflow:
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script._dependency_inputs_changed() is False
|
||||
assert commands[0] == ["git", "merge-base", "abc123", "def456"]
|
||||
|
||||
def test_sync_script_compiles_and_compares_when_dependency_inputs_changed(
|
||||
self, monkeypatch, tmp_path
|
||||
@@ -296,13 +284,10 @@ class TestDependencyAuditWorkflow:
|
||||
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
if command[:2] == ["git", "merge-base"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout="base123\n", stderr="")
|
||||
if command[:2] == ["git", "diff"]:
|
||||
if command[0] == "git":
|
||||
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
|
||||
compile_commands.append(command)
|
||||
assert kwargs["check"] is True
|
||||
assert generated_requirements.read_text(encoding="utf-8") == "pytest==1\n"
|
||||
generated_requirements.write_text("pytest==1\n", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
@@ -312,7 +297,6 @@ class TestDependencyAuditWorkflow:
|
||||
assert len(compile_commands) == 1
|
||||
compile_command = " ".join(compile_commands[0])
|
||||
assert WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS in compile_command
|
||||
assert "--upgrade" not in compile_commands[0]
|
||||
assert "--output-file" in compile_commands[0]
|
||||
assert str(generated_requirements) in compile_commands[0]
|
||||
|
||||
|
||||
@@ -472,26 +472,6 @@ class TestTagValidation:
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
|
||||
|
||||
def test_rejection_message_keeps_the_suffix_token(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
"""Rich must not swallow the literal `[suffix]`.
|
||||
|
||||
Unescaped it is parsed as a style tag and dropped, so the user is told
|
||||
only "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
|
||||
accepted form, when -rc1 / .dev0 / +build.42 are all valid and are
|
||||
documented as such in docs/upgrade.md and README.md.
|
||||
"""
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "latest"])
|
||||
assert result.exit_code == 1
|
||||
assert "expected vMAJOR.MINOR.PATCH[suffix]" in strip_ansi(result.output)
|
||||
|
||||
def test_tag_option_help_keeps_the_suffix_token(self):
|
||||
"""Typer renders option help through Rich, so `--help` dropped it too."""
|
||||
result = runner.invoke(app, ["self", "upgrade", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "[suffix]" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestUnknownCurrent:
|
||||
"""'unknown' current version renders literally in notice and success message."""
|
||||
|
||||
+3
-1516
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@ from specify_cli.bundler.services.primitives import (
|
||||
_WorkflowKindManager,
|
||||
primitive_manager,
|
||||
)
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _component(kind: str, cid: str = "x") -> ComponentRef:
|
||||
@@ -216,121 +215,3 @@ def test_bundled_preset_pin_match_installs(tmp_path: Path, monkeypatch):
|
||||
manager.install(ComponentRef(kind="presets", id="my-preset", version="1.0.0"))
|
||||
manager.install(ComponentRef(kind="presets", id="my-preset", version=None))
|
||||
assert len(called) == 2
|
||||
|
||||
|
||||
def test_extension_refresh_calls_install_with_force(tmp_path: Path, monkeypatch):
|
||||
"""_ExtensionKindManager.refresh() must pass force=True to install_from_directory
|
||||
so an already-installed extension is overwritten instead of raising an error."""
|
||||
import specify_cli._assets as assets
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
||||
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
||||
force_values: list = []
|
||||
monkeypatch.setattr(
|
||||
ExtensionManager, "install_from_directory",
|
||||
lambda self, *a, **k: force_values.append(k.get("force", False)),
|
||||
)
|
||||
|
||||
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
||||
manager.refresh(ComponentRef(kind="extensions", id="my-ext"))
|
||||
assert force_values == [True], "refresh() must pass force=True"
|
||||
|
||||
|
||||
def test_preset_refresh_calls_install_with_force(tmp_path: Path, monkeypatch):
|
||||
"""_PresetKindManager.refresh() must pass force=True to install_from_directory
|
||||
so an already-installed preset is overwritten instead of raising an error."""
|
||||
import specify_cli._assets as assets
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
|
||||
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
|
||||
force_values: list = []
|
||||
monkeypatch.setattr(
|
||||
PresetManager, "install_from_directory",
|
||||
lambda self, *a, **k: force_values.append(k.get("force", False)),
|
||||
)
|
||||
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
manager.refresh(ComponentRef(kind="presets", id="my-preset"))
|
||||
assert force_values == [True], "refresh() must pass force=True"
|
||||
|
||||
|
||||
def test_default_installer_refresh_dispatches_to_kind_manager(tmp_path: Path, monkeypatch):
|
||||
"""DefaultPrimitiveInstaller.refresh() must call the kind manager's refresh(),
|
||||
which is the hook _refresh_component() will find — fixing the --force leak."""
|
||||
import specify_cli._assets as assets
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
||||
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
||||
force_values: list = []
|
||||
monkeypatch.setattr(
|
||||
ExtensionManager, "install_from_directory",
|
||||
lambda self, *a, **k: force_values.append(k.get("force", False)),
|
||||
)
|
||||
|
||||
installer = DefaultPrimitiveInstaller(allow_network=False)
|
||||
installer.refresh(tmp_path, _component("extensions", "my-ext"))
|
||||
assert force_values == [True], "DefaultPrimitiveInstaller.refresh() must use force=True"
|
||||
|
||||
|
||||
def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch):
|
||||
"""Regression: bundle update (refresh=True) of an already-installed extension
|
||||
must succeed and pass force=True to install_from_directory."""
|
||||
from specify_cli.bundler.services.installer import install_bundle
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
import specify_cli._assets as assets
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
||||
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
||||
# Simulate refresh succeeding (force=True removes the duplicate-install guard)
|
||||
force_seen: list = []
|
||||
def _fake_install_from_directory(self, *a, **k):
|
||||
force_seen.append(k.get("force", False))
|
||||
self.registry.add("my-ext", {"version": "1.0.0"})
|
||||
|
||||
monkeypatch.setattr(
|
||||
ExtensionManager, "install_from_directory", _fake_install_from_directory
|
||||
)
|
||||
|
||||
raw = valid_manifest_dict(
|
||||
bundle={
|
||||
"id": "test-bundle",
|
||||
"name": "Test",
|
||||
"version": "1.0.0",
|
||||
"role": "developer",
|
||||
"description": "Test bundle",
|
||||
"author": "Spec Kit",
|
||||
"license": "MIT",
|
||||
},
|
||||
provides={
|
||||
"extensions": [{"id": "my-ext", "version": "1.0.0"}],
|
||||
"presets": [],
|
||||
"steps": [],
|
||||
"workflows": [],
|
||||
},
|
||||
)
|
||||
manifest = BundleManifest.from_dict(raw)
|
||||
installer = DefaultPrimitiveInstaller(allow_network=False)
|
||||
# First install
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
# Refresh (bundle update) — must not raise with --force hint
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True)
|
||||
# force=True must have been passed during the refresh call
|
||||
assert True in force_seen, "refresh path should have called install_from_directory with force=True"
|
||||
|
||||
|
||||
def _plan(manifest):
|
||||
from specify_cli.bundler.services.installer import InstallPlan
|
||||
from specify_cli.bundler.models.manifest import ComponentRef as CR
|
||||
|
||||
components = [CR(kind=c.kind, id=c.id) for c in manifest.components]
|
||||
return InstallPlan(
|
||||
bundle_id=manifest.bundle.id,
|
||||
version=manifest.bundle.version,
|
||||
role=manifest.bundle.role,
|
||||
effective_integration=None,
|
||||
components=components,
|
||||
)
|
||||
|
||||
@@ -509,86 +509,6 @@ class TestOverlayCli:
|
||||
assert payload["layers"][-1]["tier"] == "base"
|
||||
assert payload["layers"][-1]["priority"] is None
|
||||
|
||||
def test_workflow_resolve_prints_tier_labels(self, project_dir, monkeypatch):
|
||||
"""Layer tiers render literally; an unescaped ``[base]`` is eaten as markup."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "resolve", "wf"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "[base]" in result.output
|
||||
assert "[project-overlay]" in result.output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"step_id",
|
||||
[
|
||||
# Balanced tag: silently swallowed, so the step vanishes from output.
|
||||
"new[stuff]",
|
||||
# Unbalanced closer: raises MarkupError -> traceback and exit 1.
|
||||
"new[/red]",
|
||||
],
|
||||
)
|
||||
def test_workflow_resolve_escapes_rich_markup_in_step_id(
|
||||
self, project_dir, monkeypatch, step_id
|
||||
):
|
||||
"""Step IDs are unvalidated for brackets, so they must be escaped."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": step_id, "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "resolve", "wf"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert step_id in result.output
|
||||
|
||||
def test_workflow_resolve_equal_priority_layers_sort_by_source(self, project_dir, monkeypatch):
|
||||
"""Equal-priority overlays are listed alphabetically by source."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-29T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
|
||||
"workflows": {
|
||||
"pipeline": {
|
||||
@@ -22,29 +22,6 @@
|
||||
],
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"yolo": {
|
||||
"id": "yolo",
|
||||
"name": "Full SDD Cycle - no gates",
|
||||
"description": "Runs specify → plan → tasks → implement without review gates",
|
||||
"author": "clintcparker",
|
||||
"version": "0.1.0",
|
||||
"url": "https://raw.githubusercontent.com/clintcparker/speckit-addons/yolo-v0.1.0/workflows/yolo/workflow.yml",
|
||||
"repository": "https://github.com/clintcparker/speckit-addons",
|
||||
"documentation": "https://github.com/clintcparker/speckit-addons/blob/yolo-v0.1.0/workflows/yolo/README.md",
|
||||
"changelog": "https://github.com/clintcparker/speckit-addons/blob/yolo-v0.1.0/workflows/yolo/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.12"
|
||||
},
|
||||
"tags": [
|
||||
"sdd",
|
||||
"full-cycle",
|
||||
"no-gates",
|
||||
"automation"
|
||||
],
|
||||
"created_at": "2026-07-29T00:00:00Z",
|
||||
"updated_at": "2026-07-29T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user