mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 21:16:02 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f70bb0924 | ||
|
|
85d59d2d70 | ||
|
|
e39cb51338 | ||
|
|
1cb935997c | ||
|
|
f63c3d7402 | ||
|
|
a4c86b3728 | ||
|
|
902f5431f9 | ||
|
|
f9c6cf83e5 | ||
|
|
f5f76160a3 | ||
|
|
487af97864 | ||
|
|
c2204871ec |
6
.gitattributes
vendored
6
.gitattributes
vendored
@@ -1,3 +1,7 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
.github/workflows/*.lock.yml linguist-generated=true merge=ours -whitespace
|
||||
.github/workflows/*.lock.yml linguist-generated=true merge=ours -whitespace
|
||||
# The project constitution is the one dogfooding artifact carried forward.
|
||||
# Keep it exempt from git's whitespace checks (git diff --check / CI) since its
|
||||
# generated formatting is not hand-edited.
|
||||
.specify/memory/constitution.md -whitespace
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,8 +10,8 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
|
||||
214
.specify/memory/constitution.md
Normal file
214
.specify/memory/constitution.md
Normal file
@@ -0,0 +1,214 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: (template/unratified) → 1.0.0
|
||||
Bump rationale: Initial ratification of a concrete constitution for the brownfield
|
||||
Spec Kit / specify-cli codebase, derived from an exhaustive multi-pass analysis of
|
||||
the source tree, test suite, CI pipelines, and project conventions (AGENTS.md,
|
||||
CONTRIBUTING.md, DEVELOPMENT.md). MAJOR baseline because it establishes binding
|
||||
governance where none previously existed.
|
||||
|
||||
Principles defined:
|
||||
I. Code Quality & Architectural Discipline
|
||||
II. Test-Backed Change (NON-NEGOTIABLE)
|
||||
III. CLI & User-Experience Consistency
|
||||
IV. Offline-First Performance & Resource Discipline
|
||||
V. Minimal Dependencies & Safe, Idempotent File Operations
|
||||
|
||||
Added sections:
|
||||
- Security & Cross-Platform Constraints
|
||||
- Development Workflow & Quality Gates
|
||||
- Governance
|
||||
|
||||
Templates reviewed for alignment:
|
||||
✅ .specify/templates/plan-template.md — generic "Constitution Check" gate (line 39)
|
||||
remains valid; gates are now concretely populated by Principles I–V at plan time.
|
||||
✅ .specify/templates/spec-template.md — no constitution-specific tokens; no change needed.
|
||||
✅ .specify/templates/tasks-template.md — task categories (setup/foundational/story/polish)
|
||||
already accommodate testing + performance + UX tasks mandated here; no change needed.
|
||||
✅ .github/agents/speckit.*.agent.md — command guidance is agent-agnostic; no change needed.
|
||||
|
||||
Follow-up TODOs: none. RATIFICATION_DATE set to first adoption date below.
|
||||
-->
|
||||
|
||||
# Spec Kit Constitution
|
||||
|
||||
Spec Kit (the `specify-cli` package and its bundled assets) is a local, offline-capable
|
||||
developer CLI that bootstraps and operates Spec-Driven Development workflows for AI coding
|
||||
agents. These principles are derived from the patterns the codebase already enforces. They
|
||||
are binding on all changes — including the `specify bundle` subcommand and any future
|
||||
command group, integration, extension, preset, or workflow.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### I. Code Quality & Architectural Discipline
|
||||
|
||||
The codebase follows a strict, registry-driven, layered architecture, and all changes MUST
|
||||
preserve it.
|
||||
|
||||
- **Separate the CLI surface from importable logic.** User-facing commands live in Typer
|
||||
sub-apps (e.g. `commands/`, `*/_commands.py`); business logic lives in plain, importable
|
||||
modules with no `@app.command()` decorators. New features MUST keep orchestration logic
|
||||
testable independently of Typer.
|
||||
- **Use the established extension pattern.** New agents/integrations MUST subclass one of the
|
||||
standard base classes (`MarkdownIntegration`, `TomlIntegration`, `YamlIntegration`,
|
||||
`SkillsIntegration`) and declare the required class attributes (`key`, `config`,
|
||||
`registrar_config`, and `context_file` where applicable). Extending `IntegrationBase`
|
||||
directly is permitted only when no base class fits, and the deviation MUST be justified.
|
||||
- **Honor the single source of truth.** Built-ins are wired through the relevant registry
|
||||
(e.g. `INTEGRATION_REGISTRY` via `_register_builtins()`), with imports and registrations
|
||||
kept in alphabetical order. Duplicate keys MUST fail loudly rather than silently override.
|
||||
- **Naming and typing are not optional.** Private modules/functions are `_`-prefixed and MUST
|
||||
NOT be imported across package boundaries. Every new module begins with
|
||||
`from __future__ import annotations` and uses modern type syntax (`dict[str, Any]`,
|
||||
`str | None`); legacy `Dict`/`List`/`Optional` forms are rejected.
|
||||
- **Package directories use underscores; keys keep their canonical (often hyphenated) form**
|
||||
(e.g. package `kiro_cli/`, `key = "kiro-cli"`). For CLI-backed integrations the `key` MUST
|
||||
match the executable name so `shutil.which(key)` resolves.
|
||||
|
||||
**Rationale:** A registry-plus-base-class architecture is what lets dozens of integrations,
|
||||
extensions, and workflows coexist with minimal coupling. Drift here multiplies maintenance
|
||||
cost and breaks the "add one subclass, register once, ship a test" contract.
|
||||
|
||||
### II. Test-Backed Change (NON-NEGOTIABLE)
|
||||
|
||||
Every behavioral change MUST be accompanied by automated tests, and the suite is a hard gate.
|
||||
|
||||
- **Tests gate merges.** CI runs `pytest` across a matrix of ubuntu + windows × Python 3.11,
|
||||
3.12, and 3.13. Changes MUST pass on every cell of that matrix.
|
||||
- **Parity invariants MUST hold.** Every integration MUST be present in the registry, have a
|
||||
`CommandRegistrar` config entry where required, and ship a dedicated
|
||||
`tests/integrations/test_integration_<key>.py` (hyphens in the key become underscores in the
|
||||
filename). These are enforced by parametrized tests (e.g. `test_registry.py`) and MUST NOT
|
||||
be weakened.
|
||||
- **Follow pytest conventions.** Test modules/classes/functions use the `test_*` / `Test*`
|
||||
naming the project configures, run under `--strict-markers`, and isolate state with
|
||||
`tmp_path`, `monkeypatch`, and the autouse auth-isolation fixture. Platform-specific tests
|
||||
MUST be guarded (e.g. `@requires_bash`) rather than left to fail.
|
||||
- **Security and idempotency tests are mandatory categories.** Path-traversal rejection,
|
||||
manifest hash integrity/symlink safety, and no-overwrite idempotency are covered by existing
|
||||
suites; changes touching file writes, path handling, or setup scripts MUST extend (never
|
||||
reduce) that coverage.
|
||||
- **Network is mocked.** No test may make a real outbound network call; HTTP MUST be stubbed
|
||||
so the suite is deterministic and offline-runnable.
|
||||
|
||||
**Rationale:** The breadth of supported agents and the offline/air-gapped guarantees can only
|
||||
be sustained by exhaustive, parametrized tests. The parity and security suites are what stop a
|
||||
single new integration from regressing the whole matrix.
|
||||
|
||||
### III. CLI & User-Experience Consistency
|
||||
|
||||
The CLI presents one coherent surface; every command group MUST feel like the others.
|
||||
|
||||
- **Reuse the shared verb vocabulary.** Consumer-facing groups use the established verbs —
|
||||
`list`, `add`/`install`, `remove`, `search`, `info`, `update`, plus `enable`/`disable` and
|
||||
`set-priority` where relevant. New verbs MUST NOT be invented when an existing one fits, and
|
||||
any genuinely new verb MUST be justified.
|
||||
- **Mirror the catalog-stack model.** Catalog-backed groups MUST expose
|
||||
`<group> catalog list|add|remove`, back it with a priority-ordered source stack (lower number
|
||||
= higher precedence) plus per-source install policy (`install-allowed` vs `discovery-only`),
|
||||
and fall back to a built-in default stack when no project config is present.
|
||||
- **Register sub-apps the standard way.** Command groups are `typer.Typer(...)` instances
|
||||
attached via `app.add_typer(child, name="...")`, preferably through a modular
|
||||
`register(app)` function imported in `__init__.py`. Nesting MUST stay within ~2–3 levels.
|
||||
- **Output is consistent and machine-friendly.** Human output uses the shared Rich
|
||||
conventions (e.g. `[green]✓[/green]` success, `[red]Error:[/red]` + non-zero exit on
|
||||
failure, actionable remediation in messages). Where a `--json` flag is offered, valid JSON
|
||||
goes to stdout and all other logging is redirected to stderr.
|
||||
- **Interactions are safe and idempotent.** Destructive actions show what will change before
|
||||
confirming; "already installed / already present" outcomes succeed (exit 0) rather than
|
||||
error. User-facing command groups MUST be documented under `docs/reference/`.
|
||||
|
||||
**Rationale:** Predictability is the product. Users learn one set of verbs, one catalog model,
|
||||
and one output grammar, then apply them to every group — including `specify bundle`.
|
||||
|
||||
### IV. Offline-First Performance & Resource Discipline
|
||||
|
||||
Spec Kit is a local CLI; responsiveness, offline operability, and graceful degradation are the
|
||||
performance contract.
|
||||
|
||||
- **`specify init` and core scaffolding MUST work fully offline** using bundled `core_pack`
|
||||
assets. Asset resolution MUST prefer bundled assets, then a source checkout, before ever
|
||||
reaching the network.
|
||||
- **Network use is lazy, bounded, and degradable.** Network calls happen only on explicit
|
||||
user commands, MUST set timeouts, MUST cache catalog results (1-hour TTL) and fall back to
|
||||
stale cache on failure, and MUST surface offline/rate-limit conditions as clear messages
|
||||
without crashing.
|
||||
- **Keep startup cheap.** Avoid adding heavyweight work to import time. New optional
|
||||
subsystems SHOULD prefer lazy loading over unconditional eager imports so that unrelated
|
||||
commands (including `--help`) stay fast.
|
||||
- **Filesystem writes are minimal and idempotent.** Installs MUST track files (SHA-256
|
||||
manifests), avoid clobbering user-modified content, only uninstall files whose hash still
|
||||
matches, and never follow symlinks out of the project root.
|
||||
|
||||
**Rationale:** Developers run this tool in air-gapped, enterprise, and flaky-network
|
||||
environments. Offline-first behavior and idempotent, hash-tracked file operations are what
|
||||
make it safe and fast to run repeatedly.
|
||||
|
||||
### V. Minimal Dependencies & Safe, Idempotent File Operations
|
||||
|
||||
The project guards its dependency surface and its on-disk footprint deliberately.
|
||||
|
||||
- **Zero new runtime dependencies by default.** The runtime dependency set is intentionally
|
||||
small and pinned to a minimum major version. Adding a dependency requires maintainer
|
||||
agreement and a justification that existing deps (typer, click, rich, pyyaml, packaging,
|
||||
platformdirs, pathspec, json5, readchar) cannot serve the need. New subsystems SHOULD reuse
|
||||
existing primitive machinery in-process rather than re-implementing or re-shipping it.
|
||||
- **All paths are validated.** Any project-relative path derived from user/manifest/catalog
|
||||
input MUST be confined to the project root (`Path.relative_to` checks) and reject traversal
|
||||
payloads; symlink escapes MUST be refused.
|
||||
- **Errors are explicit and chained.** Validate inputs up front, raise with actionable context
|
||||
(offending field/value plus a hint), and use `raise ... from exc` to preserve causes. I/O
|
||||
that can legitimately fail MUST degrade gracefully rather than emit a raw traceback.
|
||||
- **Versioning follows SemVer.** User-visible and packaged behavior changes follow
|
||||
MAJOR.MINOR.PATCH semantics; backward-incompatible changes MUST be called out and justified.
|
||||
|
||||
**Rationale:** A lean, pinned dependency set and hardened, idempotent file handling are what
|
||||
keep the tool trustworthy in enterprise and air-gapped contexts and cheap to maintain.
|
||||
|
||||
## Security & Cross-Platform Constraints
|
||||
|
||||
- **Cross-platform parity is required.** Code MUST run on Linux, macOS, and Windows and on
|
||||
Python 3.11–3.13. Windows specifics (UTF-8 stream reconfiguration, bash-dependent tests
|
||||
auto-skipping) MUST be respected; do not introduce POSIX-only assumptions without a guarded
|
||||
fallback.
|
||||
- **Security tooling is a gate.** CodeQL and the project's security test suites
|
||||
(path-traversal, manifest/symlink hardening) MUST remain green. Network access MUST default
|
||||
to off in tests and be opt-in, timeout-bounded, and credential-isolated at runtime.
|
||||
- **Formatting is enforced.** `.editorconfig` rules (LF endings, final newline, no trailing
|
||||
whitespace, 4-space Python / 2-space YAML-JSON-Markdown), `ruff check src/`, and
|
||||
`markdownlint-cli2` MUST pass.
|
||||
|
||||
## Development Workflow & Quality Gates
|
||||
|
||||
- **Branch naming** follows `<type>/<number>-<short-slug>` (or `<type>/<short-slug>` with no
|
||||
issue), with `<type>` ∈ {feat, fix, docs, community, chore}.
|
||||
- **PRs are focused** and MUST: pass `ruff`, `pytest` (full matrix), markdown lint, and CodeQL;
|
||||
add/extend tests for new behavior; update user-facing docs (`README.md`, `docs/`,
|
||||
`spec-driven.md`) when behavior changes; and disclose any AI assistance used.
|
||||
- **Slash-command-affecting changes** MUST be manually exercised through a coding agent and the
|
||||
results reported in the PR, per CONTRIBUTING.md.
|
||||
- **Large or cross-cutting changes** (new templates, arguments, command groups) MUST be agreed
|
||||
with maintainers before implementation.
|
||||
|
||||
## Governance
|
||||
|
||||
This constitution supersedes ad-hoc convention where they conflict; the existing codebase
|
||||
patterns it codifies remain authoritative references.
|
||||
|
||||
- **Authority.** Principles I–V are binding gates. The `## Constitution Check` section of the
|
||||
plan template MUST be evaluated against these principles, and `/speckit.analyze` treats
|
||||
conflicts with a MUST as CRITICAL. Violations are resolved by changing the spec, plan, or
|
||||
tasks — not by diluting a principle.
|
||||
- **Amendments.** Changes to this document require a PR with rationale, maintainer approval,
|
||||
and a version bump per the policy below. Any amendment MUST propagate to dependent templates
|
||||
and command guidance in the same change, recorded in the Sync Impact Report at the top of
|
||||
this file.
|
||||
- **Versioning policy (SemVer for governance).** MAJOR = backward-incompatible governance or
|
||||
principle removal/redefinition; MINOR = a new principle/section or materially expanded
|
||||
guidance; PATCH = clarifications and non-semantic refinements.
|
||||
- **Compliance review.** Every PR and review MUST verify compliance with these principles.
|
||||
Added complexity or any deviation MUST be justified in-PR (and, for plans, in the plan's
|
||||
Complexity Tracking section). Unjustified violations block merge.
|
||||
|
||||
**Version**: 1.0.0 | **Ratified**: 2026-06-19 | **Last Amended**: 2026-06-19
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -14,7 +14,7 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
|
||||
|
||||
Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations/<key>/`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`.
|
||||
|
||||
```
|
||||
```text
|
||||
src/specify_cli/integrations/
|
||||
├── __init__.py # INTEGRATION_REGISTRY + _register_builtins()
|
||||
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration
|
||||
@@ -340,18 +340,21 @@ Some agents require custom processing beyond the standard template transformatio
|
||||
### Copilot Integration
|
||||
|
||||
GitHub Copilot has unique requirements:
|
||||
|
||||
- Commands use `.agent.md` extension (not `.md`)
|
||||
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
|
||||
- Installs `.vscode/settings.json` with prompt file recommendations
|
||||
- Context file lives at `.github/copilot-instructions.md`
|
||||
|
||||
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
||||
|
||||
1. Processes templates with `process_template()`
|
||||
2. Generates companion `.prompt.md` files
|
||||
3. Merges VS Code settings
|
||||
|
||||
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
|
||||
via `--integration-options="--skills"`. When enabled:
|
||||
|
||||
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
|
||||
- No companion `.prompt.md` files are generated
|
||||
- No `.vscode/settings.json` merge
|
||||
@@ -371,11 +374,13 @@ specify init my-project --integration copilot --integration-options="--skills"
|
||||
### Forge Integration
|
||||
|
||||
Forge has special frontmatter and argument requirements:
|
||||
|
||||
- Uses `{{parameters}}` instead of `$ARGUMENTS`
|
||||
- Strips `handoffs` frontmatter key (Forge-specific collaboration feature)
|
||||
- Injects `name` field into frontmatter when missing
|
||||
|
||||
Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
|
||||
|
||||
1. Inherits standard template processing from `MarkdownIntegration`
|
||||
2. Adds extra `$ARGUMENTS` → `{{parameters}}` replacement after template processing
|
||||
3. Applies Forge-specific transformations via `_apply_forge_transformations()`
|
||||
@@ -385,11 +390,13 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
|
||||
### Goose Integration
|
||||
|
||||
Goose is a YAML-format agent using Block's recipe system:
|
||||
|
||||
- Uses `.goose/recipes/` directory for YAML recipe files
|
||||
- Uses `{{args}}` argument placeholder
|
||||
- Produces YAML with `prompt: |` block scalar for command content
|
||||
|
||||
Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
|
||||
|
||||
1. Processes templates through the standard placeholder pipeline
|
||||
2. Extracts title and description from frontmatter
|
||||
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
|
||||
@@ -400,7 +407,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
|
||||
|
||||
Branches follow one of two patterns depending on whether an issue exists:
|
||||
|
||||
```
|
||||
```text
|
||||
<type>/<number>-<short-slug> # when an issue is created first
|
||||
<type>/<short-slug> # when no issue exists (PR-only changes)
|
||||
```
|
||||
@@ -463,6 +470,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag
|
||||
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
|
||||
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
|
||||
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.
|
||||
6. **Running tests against the wrong environment**: Always run the suite inside this working tree's own virtualenv (`uv sync --extra test` then `.venv/bin/python -m pytest`, or activate the venv first). A bare `uv run pytest` can resolve to an ambient/global interpreter whose editable `.pth` points at a *different* worktree. The failure is sneaky: test collection still imports `specify_cli` successfully, but newly-added subpackages (e.g. a fresh `specify_cli/bundler/`) resolve as a stale namespace package and raise `ModuleNotFoundError`. If a brand-new subpackage imports under `python -c` but not under pytest, suspect environment contamination, not your code.
|
||||
|
||||
---
|
||||
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,21 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.11.4] - 2026-06-22
|
||||
|
||||
### Changed
|
||||
|
||||
- [extension] Add Tasks to GitHub Project extension to community catalog (#3090)
|
||||
- Update Linear Integration extension to v0.7.0 (#3089)
|
||||
- fix: fail loudly on an unknown workflow expression filter (#3074)
|
||||
- fix: anchor lib/ and lib64/ patterns to repo root in .gitignore (#3083)
|
||||
- fix(build): include specify_cli.bundler.lib in built distribution (#3085)
|
||||
- Harden command registration path handling (#3088)
|
||||
- fix(presets): preserve argument-hint in preset SKILL.md generation (#2978)
|
||||
- feat: surface gate detail in the workflow run/resume --json payload (#2965)
|
||||
- feat: add `specify bundle` command (#3070)
|
||||
- chore: release 0.11.3, begin 0.11.4.dev0 development (#3072)
|
||||
|
||||
## [0.11.3] - 2026-06-19
|
||||
|
||||
### Changed
|
||||
@@ -17,8 +32,6 @@
|
||||
- Add Token Economy extension to community catalog (#3049)
|
||||
- chore: release 0.11.2, begin 0.11.3.dev0 development (#3059)
|
||||
|
||||
- feat(scripts): add SPECIFY_INIT_DIR to target a member project from the repo root (#2892)
|
||||
|
||||
## [0.11.2] - 2026-06-18
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -95,6 +95,24 @@ uv run python -m pytest tests/test_agent_config_consistency.py -q
|
||||
|
||||
Run this when you change agent metadata, context update scripts, or integration wiring.
|
||||
|
||||
#### Running the full test suite
|
||||
|
||||
Install the test dependencies into the project's own virtual environment and run
|
||||
`pytest` through that interpreter:
|
||||
|
||||
```bash
|
||||
uv pip install -e ".[test]"
|
||||
.venv/bin/python -m pytest tests -q # Windows: .venv\Scripts\python -m pytest tests -q
|
||||
```
|
||||
|
||||
> **Note:** prefer `.venv/bin/python -m pytest` over a bare `uv run pytest`.
|
||||
> If another Spec Kit checkout has an editable (`-e`) install registered in a
|
||||
> shared/global environment, `uv run pytest` can resolve `specify_cli` to that
|
||||
> *other* worktree, turning it into a partial namespace package that fails to
|
||||
> import newly added subpackages. Running through the project `.venv` resolves
|
||||
> `specify_cli` to this checkout's `src/`. This matches the gotcha documented in
|
||||
> `AGENTS.md` (Common Pitfalls).
|
||||
|
||||
### Manual testing
|
||||
|
||||
#### Testing setup
|
||||
|
||||
52
README.md
52
README.md
@@ -26,6 +26,7 @@
|
||||
- [🤖 Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations)
|
||||
- [🔧 Specify CLI Reference](#-specify-cli-reference)
|
||||
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
|
||||
- [📦 Bundles: Role-Based Setups](#-bundles-role-based-setups)
|
||||
- [📚 Core Philosophy](#-core-philosophy)
|
||||
- [🌟 Development Phases](#-development-phases)
|
||||
- [🎯 Experimental Goals](#-experimental-goals)
|
||||
@@ -228,6 +229,56 @@ For example, presets could restructure spec templates to require regulatory trac
|
||||
|
||||
See the [Presets reference](https://github.github.io/spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking.
|
||||
|
||||
## 📦 Bundles: Role-Based Setups
|
||||
|
||||
Extensions and presets are individual building blocks. A **bundle** packages a
|
||||
curated set of them — extensions, presets, steps, and workflows — into a single,
|
||||
versioned, role-oriented setup so a whole team persona (product manager, business
|
||||
analyst, security researcher, developer, …) can be provisioned with one command.
|
||||
|
||||
A bundle is described by a hand-written `bundle.yml` manifest. It pins each
|
||||
component to a version and, optionally, targets a specific integration; a bundle
|
||||
with no `integration` is **agnostic** and inherits whatever integration the
|
||||
project already uses.
|
||||
|
||||
```bash
|
||||
# Discover bundles in the active catalog stack
|
||||
specify bundle search [<query>]
|
||||
|
||||
# Inspect the exact component set a bundle will add (equals what install does)
|
||||
specify bundle info <bundle-id>
|
||||
|
||||
# Install a bundle's full component set in one operation
|
||||
specify bundle install <bundle-id>
|
||||
|
||||
# See what's installed, then update or remove non-destructively
|
||||
specify bundle list
|
||||
specify bundle update <bundle-id> # or --all
|
||||
specify bundle remove <bundle-id> # removes only this bundle's components
|
||||
```
|
||||
|
||||
Bundles resolve from a **priority-ordered catalog stack** (project > user >
|
||||
built-in). Each source carries an install policy: `install-allowed` sources can
|
||||
be installed from, while `discovery-only` sources are visible in `search`/`info`
|
||||
but refuse installation. Manage the stack with `specify bundle catalog list|add|remove`.
|
||||
|
||||
Authors validate and package bundles locally — there is no first-class publish;
|
||||
distribution is hosting the built artifact and adding a catalog entry:
|
||||
|
||||
```bash
|
||||
specify bundle validate --path ./my-bundle # structural + reference checks
|
||||
specify bundle build --path ./my-bundle # produce a versioned .zip artifact
|
||||
```
|
||||
|
||||
Four ready-to-read example manifests live under
|
||||
[`examples/bundles/`](examples/bundles/) (product manager, business analyst,
|
||||
security researcher, developer).
|
||||
|
||||
Key guarantees: `info` shows exactly what `install` adds (transparency);
|
||||
installs are idempotent and confined to the project root; `remove` never touches
|
||||
components another installed bundle still needs; and all consume/author commands
|
||||
work **offline** against local or pinned sources.
|
||||
|
||||
### When to Use Which
|
||||
|
||||
| Goal | Use |
|
||||
@@ -237,6 +288,7 @@ See the [Presets reference](https://github.github.io/spec-kit/reference/presets.
|
||||
| Integrate an external tool or service | Extension |
|
||||
| Enforce organizational or regulatory standards | Preset |
|
||||
| Ship reusable domain-specific templates | Either — presets for template overrides, extensions for templates bundled with new commands |
|
||||
| Provision a complete role-based setup in one command | Bundle |
|
||||
|
||||
## 📚 Core Philosophy
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
|
||||
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
|
||||
156
docs/reference/bundles.md
Normal file
156
docs/reference/bundles.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Bundles
|
||||
|
||||
Bundles compose existing Spec Kit components — extensions, presets, workflows, and steps — into a single, versioned, installable unit. Where extensions and presets are primitives, a bundle is a curated stack that declares everything a team or role needs and installs it in one step through each component's own machinery. Bundles add no new runtime behavior of their own: they are a distribution and composition layer over the primitives you already use.
|
||||
|
||||
A bundle is described by a `bundle.yml` manifest and is discovered through the same catalog stack as other components. Installing a bundle resolves its declared components against pinned versions, checks for the single cross-bundle conflict point (the active integration), and applies each component idempotently with full provenance tracking so it can be cleanly removed or refreshed later.
|
||||
|
||||
## Search Available Bundles
|
||||
|
||||
```bash
|
||||
specify bundle search [query]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ---------------------------- |
|
||||
| `--offline` | Do not access the network |
|
||||
| `--json` | Emit machine-readable JSON |
|
||||
|
||||
Searches all active catalogs for bundles matching the query. Without a query, lists every available bundle with its version, role, source, and a trust indicator (`verified` for org-curated catalog entries, `community` otherwise) so you can judge trust before installing.
|
||||
|
||||
## Bundle Info
|
||||
|
||||
```bash
|
||||
specify bundle info <bundle_id>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------ | --------------------------------- |
|
||||
| `--offline` | Do not access the network |
|
||||
| `--json` | Emit machine-readable JSON |
|
||||
|
||||
Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps with components already provided by installed bundles are surfaced here as well.
|
||||
|
||||
## Install a Bundle
|
||||
|
||||
```bash
|
||||
specify bundle install <bundle_id | path>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | ------------------------------------------------------------------ |
|
||||
| `--integration` | Override the integration used when initializing/installing |
|
||||
| `--offline` | Do not access the network |
|
||||
|
||||
Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack.
|
||||
|
||||
If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Installation is idempotent — components already present are skipped. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain.
|
||||
|
||||
## Update Bundles
|
||||
|
||||
```bash
|
||||
specify bundle update [<bundle_id>]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------ | ------------------------------------ |
|
||||
| `--all` | Update every installed bundle |
|
||||
| `--offline` | Do not access the network |
|
||||
|
||||
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.
|
||||
|
||||
> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component that is already present is skipped during `install` without comparing its on-disk version to the manifest pin. Version pins are therefore guaranteed to be applied only when the bundler actually installs a component for the first time or refreshes it. Run `specify bundle update` to re-apply every owned component at its pinned version.
|
||||
|
||||
## Remove a Bundle
|
||||
|
||||
```bash
|
||||
specify bundle remove <bundle_id>
|
||||
```
|
||||
|
||||
Uninstalls only the components this bundle contributed, leaving any component that another installed bundle still needs in place (no collateral removals).
|
||||
|
||||
## List Installed Bundles
|
||||
|
||||
```bash
|
||||
specify bundle list
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| -------- | ---------------------------- |
|
||||
| `--json` | Emit machine-readable JSON |
|
||||
|
||||
Lists the bundles installed in the project with their versions, component counts, and install timestamps.
|
||||
|
||||
## Initialize a Project with a Bundle
|
||||
|
||||
```bash
|
||||
specify bundle init [<bundle_id>]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| `--integration` | Integration override |
|
||||
| `--offline` | Do not access the network |
|
||||
|
||||
Ensures the current directory is a Spec Kit project (initializing it idempotently if needed), then optionally installs the given bundle. Useful as an explicit one-step bootstrap for a new checkout.
|
||||
|
||||
## Validate a Bundle
|
||||
|
||||
```bash
|
||||
specify bundle validate
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------ | ------------------------------------------------------------------- |
|
||||
| `--path` | Bundle directory or `bundle.yml` (default: current directory) |
|
||||
| `--offline` | Verify references against bundled/installed components only |
|
||||
|
||||
Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves. References are checked against bundled components, the project's installed components, and — when online — the active catalogs. Validation fails only when a reference is definitively absent everywhere it could be checked: that is, when an active catalog is reachable and confirms the component is missing. References that cannot be verified — because validation is offline, or because a catalog is unreachable — are downgraded to warnings so authoring can continue, rather than failing the run.
|
||||
|
||||
## Build a Bundle Artifact
|
||||
|
||||
```bash
|
||||
specify bundle build
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ------------------------------------------------------- |
|
||||
| `--path` | Bundle directory (default: current directory) |
|
||||
| `--output` | Output directory for the artifact |
|
||||
|
||||
Produces a single versioned, distributable `.zip` artifact from a bundle directory. The artifact embeds the manifest and can be installed directly with `specify bundle install <artifact.zip>`.
|
||||
|
||||
## Manage Catalog Sources
|
||||
|
||||
Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes).
|
||||
|
||||
### List the Catalog Stack
|
||||
|
||||
```bash
|
||||
specify bundle catalog list
|
||||
```
|
||||
|
||||
Prints the active, priority-ordered catalog stack with each source's scope and install policy.
|
||||
|
||||
### Add a Catalog Source
|
||||
|
||||
```bash
|
||||
specify bundle catalog add <url>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------- | ------------------------------------------------------- |
|
||||
| `--policy` | `install-allowed` or `discovery-only` |
|
||||
| `--priority` | Source priority (lower = higher precedence; default 10) |
|
||||
| `--id` | Explicit source id |
|
||||
|
||||
Registers a project-scoped catalog source and persists it.
|
||||
|
||||
### Remove a Catalog Source
|
||||
|
||||
```bash
|
||||
specify bundle catalog remove <id_or_url>
|
||||
```
|
||||
|
||||
Removes a project-scoped catalog source. Built-in default sources cannot be deleted.
|
||||
|
||||
> **Note:** `search` and `info` work anywhere — with no project they fall back to the built-in/user catalog stack. The remaining state-changing commands (`list`, `update`, `remove`, `catalog`) require a project already initialized with `specify init`. `install` and `init` will initialize a project on demand when run in an uninitialized directory.
|
||||
@@ -31,3 +31,9 @@ Presets customize how Spec Kit works — overriding command files, template file
|
||||
Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption.
|
||||
|
||||
[Workflows reference →](workflows.md)
|
||||
|
||||
## Bundles
|
||||
|
||||
Bundles compose existing extensions, presets, workflows, and steps into a single, versioned, installable unit. Rather than adding new behavior, a bundle curates a stack of primitives — everything a team or role needs — and installs it in one step through each component's own machinery, with version pinning, conflict checks, and provenance tracking for clean updates and removal.
|
||||
|
||||
[Bundles reference →](bundles.md)
|
||||
|
||||
22
examples/bundles/business-analyst/README.md
Normal file
22
examples/bundles/business-analyst/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Business Analyst bundle
|
||||
|
||||
A role bundle for business analysts working in a Spec-Driven Development flow:
|
||||
requirements elicitation, traceability, and acceptance criteria.
|
||||
|
||||
## What it installs
|
||||
|
||||
- **Extension** `agent-context` — keeps the agent context file in sync.
|
||||
- **Preset** `requirements-elicitation` (priority 10, append) — elicitation and
|
||||
analysis command set.
|
||||
- **Steps** `capture-requirements`, `trace-acceptance-criteria`.
|
||||
- **Workflow** `requirements-to-spec` — turns captured requirements into a spec.
|
||||
|
||||
This bundle is **integration-agnostic**: it inherits the project's active
|
||||
integration.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
specify bundle validate --path examples/bundles/business-analyst
|
||||
specify bundle build --path examples/bundles/business-analyst --output dist/
|
||||
```
|
||||
33
examples/bundles/business-analyst/bundle.yml
Normal file
33
examples/bundles/business-analyst/bundle.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
bundle:
|
||||
id: "business-analyst"
|
||||
name: "Business Analyst"
|
||||
version: "1.0.0"
|
||||
role: "business-analyst"
|
||||
description: "Spec-Driven Development setup for business analysts: requirements elicitation, traceability, and acceptance criteria."
|
||||
author: "spec-kit-examples"
|
||||
license: "MIT"
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.9.0"
|
||||
tools: []
|
||||
mcp: []
|
||||
|
||||
provides:
|
||||
extensions:
|
||||
- id: "agent-context"
|
||||
version: "1.0.0"
|
||||
presets:
|
||||
- id: "requirements-elicitation"
|
||||
version: "1.0.0"
|
||||
priority: 10
|
||||
strategy: "append"
|
||||
steps:
|
||||
- id: "capture-requirements"
|
||||
- id: "trace-acceptance-criteria"
|
||||
workflows:
|
||||
- id: "requirements-to-spec"
|
||||
version: "1.0.0"
|
||||
|
||||
tags: ["requirements", "traceability", "analysis"]
|
||||
22
examples/bundles/developer/README.md
Normal file
22
examples/bundles/developer/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Developer bundle
|
||||
|
||||
A role bundle for developers practicing Spec-Driven Development: implementation
|
||||
planning, task breakdown, and code review.
|
||||
|
||||
## What it installs
|
||||
|
||||
- **Extension** `agent-context` — keeps the agent context file in sync.
|
||||
- **Preset** `implementation-planning` (priority 10, append) — implementation
|
||||
planning command set.
|
||||
- **Steps** `plan-implementation`, `break-down-tasks`.
|
||||
- **Workflow** `spec-to-implementation` — drives a spec through to code.
|
||||
|
||||
This bundle is **integration-agnostic**: it inherits the project's active
|
||||
integration.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
specify bundle validate --path examples/bundles/developer
|
||||
specify bundle build --path examples/bundles/developer --output dist/
|
||||
```
|
||||
33
examples/bundles/developer/bundle.yml
Normal file
33
examples/bundles/developer/bundle.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
bundle:
|
||||
id: "developer"
|
||||
name: "Developer"
|
||||
version: "1.0.0"
|
||||
role: "developer"
|
||||
description: "Spec-Driven Development setup for developers: implementation planning, task breakdown, and code review."
|
||||
author: "spec-kit-examples"
|
||||
license: "MIT"
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.9.0"
|
||||
tools: []
|
||||
mcp: []
|
||||
|
||||
provides:
|
||||
extensions:
|
||||
- id: "agent-context"
|
||||
version: "1.0.0"
|
||||
presets:
|
||||
- id: "implementation-planning"
|
||||
version: "1.0.0"
|
||||
priority: 10
|
||||
strategy: "append"
|
||||
steps:
|
||||
- id: "plan-implementation"
|
||||
- id: "break-down-tasks"
|
||||
workflows:
|
||||
- id: "spec-to-implementation"
|
||||
version: "1.0.0"
|
||||
|
||||
tags: ["development", "implementation", "code-review"]
|
||||
22
examples/bundles/product-manager/README.md
Normal file
22
examples/bundles/product-manager/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Product Manager bundle
|
||||
|
||||
A role bundle that prepares a Spec Kit project for product managers driving
|
||||
Spec-Driven Development: discovery, specification, and roadmap planning.
|
||||
|
||||
## What it installs
|
||||
|
||||
- **Extension** `agent-context` — keeps the agent context file in sync.
|
||||
- **Preset** `product-discovery` (priority 10, append) — discovery-oriented
|
||||
command set.
|
||||
- **Steps** `draft-spec`, `review-spec` — specification authoring steps.
|
||||
- **Workflow** `spec-to-roadmap` — turns an approved spec into a roadmap.
|
||||
|
||||
This bundle is **integration-agnostic**: it inherits whatever integration the
|
||||
project already uses (e.g. `copilot`, `claude`).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
specify bundle validate --path examples/bundles/product-manager
|
||||
specify bundle build --path examples/bundles/product-manager --output dist/
|
||||
```
|
||||
35
examples/bundles/product-manager/bundle.yml
Normal file
35
examples/bundles/product-manager/bundle.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
bundle:
|
||||
id: "product-manager"
|
||||
name: "Product Manager"
|
||||
version: "1.0.0"
|
||||
role: "product-manager"
|
||||
description: "Spec-Driven Development setup for product managers: discovery, specification, and roadmap workflows."
|
||||
author: "spec-kit-examples"
|
||||
license: "MIT"
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.9.0"
|
||||
tools: []
|
||||
mcp: []
|
||||
|
||||
# Agnostic bundle: inherits the project's active integration.
|
||||
|
||||
provides:
|
||||
extensions:
|
||||
- id: "agent-context"
|
||||
version: "1.0.0"
|
||||
presets:
|
||||
- id: "product-discovery"
|
||||
version: "1.0.0"
|
||||
priority: 10
|
||||
strategy: "append"
|
||||
steps:
|
||||
- id: "draft-spec"
|
||||
- id: "review-spec"
|
||||
workflows:
|
||||
- id: "spec-to-roadmap"
|
||||
version: "1.0.0"
|
||||
|
||||
tags: ["product", "discovery", "roadmap"]
|
||||
23
examples/bundles/security-researcher/README.md
Normal file
23
examples/bundles/security-researcher/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Security Researcher bundle
|
||||
|
||||
A role bundle for security researchers practicing Spec-Driven Development:
|
||||
threat modeling, security review, and compliance.
|
||||
|
||||
## What it installs
|
||||
|
||||
- **Extension** `agent-context` — keeps the agent context file in sync.
|
||||
- **Preset** `security-compliance` (priority 5, append) — security and
|
||||
compliance command set; presets apply in ascending priority order, so this
|
||||
low number (5) places it ahead of higher-numbered presets in the stack.
|
||||
- **Steps** `threat-model`, `security-review`.
|
||||
- **Workflow** `secure-sdd` — a security-first SDD workflow.
|
||||
|
||||
This bundle is **integration-agnostic**: it inherits the project's active
|
||||
integration.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
specify bundle validate --path examples/bundles/security-researcher
|
||||
specify bundle build --path examples/bundles/security-researcher --output dist/
|
||||
```
|
||||
33
examples/bundles/security-researcher/bundle.yml
Normal file
33
examples/bundles/security-researcher/bundle.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
bundle:
|
||||
id: "security-researcher"
|
||||
name: "Security Researcher"
|
||||
version: "1.0.0"
|
||||
role: "security-researcher"
|
||||
description: "Spec-Driven Development setup for security researchers: threat modeling, security review, and compliance checks."
|
||||
author: "spec-kit-examples"
|
||||
license: "MIT"
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.9.0"
|
||||
tools: []
|
||||
mcp: []
|
||||
|
||||
provides:
|
||||
extensions:
|
||||
- id: "agent-context"
|
||||
version: "1.0.0"
|
||||
presets:
|
||||
- id: "security-compliance"
|
||||
version: "1.0.0"
|
||||
priority: 5
|
||||
strategy: "append"
|
||||
steps:
|
||||
- id: "threat-model"
|
||||
- id: "security-review"
|
||||
workflows:
|
||||
- id: "secure-sdd"
|
||||
version: "1.0.0"
|
||||
|
||||
tags: ["security", "compliance", "threat-modeling"]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-18T00:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1540,8 +1540,8 @@
|
||||
"id": "linear",
|
||||
"description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).",
|
||||
"author": "Ash Brener",
|
||||
"version": "0.6.0",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.6.0.zip",
|
||||
"version": "0.7.0",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-linear-sync/archive/refs/tags/v0.7.0.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-linear-sync",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-linear-sync",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-linear-sync/blob/main/README.md",
|
||||
@@ -1568,7 +1568,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-01T00:00:00Z",
|
||||
"updated_at": "2026-06-17T00:00:00Z"
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"loop": {
|
||||
"name": "Loop Engineering",
|
||||
@@ -3541,6 +3541,44 @@
|
||||
"created_at": "2026-03-02T00:00:00Z",
|
||||
"updated_at": "2026-03-02T00:00:00Z"
|
||||
},
|
||||
"tasks-to-project": {
|
||||
"name": "Tasks to GitHub Project",
|
||||
"id": "tasks-to-project",
|
||||
"description": "Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board.",
|
||||
"author": "Alessandro Mancini",
|
||||
"version": "0.2.0",
|
||||
"download_url": "https://github.com/mancioshell/spec-kit-tasks-to-project/archive/refs/tags/v0.2.0.zip",
|
||||
"repository": "https://github.com/mancioshell/spec-kit-tasks-to-project",
|
||||
"homepage": "https://github.com/mancioshell/spec-kit-tasks-to-project",
|
||||
"documentation": "https://github.com/mancioshell/spec-kit-tasks-to-project/blob/main/README.md",
|
||||
"changelog": "https://github.com/mancioshell/spec-kit-tasks-to-project/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "integration",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{ "name": "gh", "required": true },
|
||||
{ "name": "python3", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"github",
|
||||
"project",
|
||||
"kanban",
|
||||
"automation",
|
||||
"tasks"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-22T00:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"team-assign": {
|
||||
"name": "Team Assign",
|
||||
"id": "team-assign",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.11.3"
|
||||
version = "0.11.4"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -609,6 +609,13 @@ from .presets._commands import register as _register_preset_cmds # noqa: E402
|
||||
_register_preset_cmds(app)
|
||||
|
||||
|
||||
# ===== Bundle Commands =====
|
||||
|
||||
# Bundler subcommand group (specify bundle ...) — see commands/bundle/.
|
||||
from .commands.bundle import register as _register_bundle_cmds # noqa: E402
|
||||
_register_bundle_cmds(app)
|
||||
|
||||
|
||||
# ===== Extension Commands =====
|
||||
|
||||
|
||||
@@ -2092,13 +2099,85 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
||||
|
||||
def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
||||
"""Machine-readable summary of a run/resume outcome."""
|
||||
return {
|
||||
payload = {
|
||||
"run_id": state.run_id,
|
||||
"workflow_id": state.workflow_id,
|
||||
"status": state.status.value,
|
||||
"current_step_id": state.current_step_id,
|
||||
"current_step_index": state.current_step_index,
|
||||
}
|
||||
gate = _gate_outcome(state)
|
||||
if gate is not None:
|
||||
payload["gate"] = gate
|
||||
return payload
|
||||
|
||||
|
||||
def _is_gate_step(step: dict[str, Any]) -> bool:
|
||||
"""Whether a recorded step result is a gate.
|
||||
|
||||
Prefers the persisted ``type`` field, but when it is absent — a run paused
|
||||
by an older version, whose step record predates ``type`` being stored —
|
||||
falls back to the gate's unique output signature: only ``GateStep`` writes
|
||||
an ``on_reject`` key. A record carrying a *different* known ``type`` is not
|
||||
a gate, so the fallback applies only when ``type`` is missing entirely.
|
||||
"""
|
||||
step_type = step.get("type")
|
||||
if step_type == "gate":
|
||||
return True
|
||||
if step_type:
|
||||
return False
|
||||
output = step.get("output")
|
||||
return isinstance(output, dict) and "on_reject" in output
|
||||
|
||||
|
||||
def _gate_outcome(state: Any) -> dict[str, Any] | None:
|
||||
"""Gate detail for the structured outcome, when the run rests at a gate.
|
||||
|
||||
A paused or gate-aborted run is otherwise indistinguishable from any
|
||||
other pause/abort in the machine-readable payload; surfacing the gate's
|
||||
prompt, options, and (after an interactive choice) the decision lets
|
||||
orchestrators drive review gates without parsing the human-facing stream.
|
||||
"""
|
||||
# Two run states rest *on* a gate: `paused` (awaiting a decision) and
|
||||
# `aborted` (a gate rejected with `on_reject: abort` — the only path that
|
||||
# sets ABORTED, leaving current_step_id on that gate). Any other status —
|
||||
# notably `completed`/`failed` — must be suppressed: current_step_id is
|
||||
# not cleared when a run whose last executed step was a gate moves on, so
|
||||
# without this guard it would surface stale detail (run/resume/status).
|
||||
if getattr(state.status, "value", state.status) not in ("paused", "aborted"):
|
||||
return None
|
||||
step = (getattr(state, "step_results", None) or {}).get(state.current_step_id)
|
||||
if not isinstance(step, dict) or not _is_gate_step(step):
|
||||
return None
|
||||
output = step.get("output") or {}
|
||||
# `message`, `options`, and `choice` may be non-string YAML literals in an
|
||||
# unvalidated workflow (GateStep coerces none of them for the payload), so
|
||||
# normalise all three for a stable JSON schema: message → str, options →
|
||||
# list[str] | None, choice → str | None (None means no decision yet).
|
||||
message = output.get("message")
|
||||
choice = output.get("choice")
|
||||
return {
|
||||
"step_id": state.current_step_id,
|
||||
"message": None if message is None else str(message),
|
||||
"options": _normalize_gate_options(output.get("options")),
|
||||
"choice": None if choice is None else str(choice),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_gate_options(options: Any) -> list[str] | None:
|
||||
"""Normalise a gate's ``options`` to a stable ``list[str]`` (or ``None``).
|
||||
|
||||
A valid gate stores a list, but an unvalidated workflow could leave a
|
||||
scalar or tuple. ``None`` stays ``None`` (no options); a list/tuple maps
|
||||
each element through ``str``; any other scalar becomes a single-element
|
||||
list — so the emitted JSON schema is always ``list[str] | None``. A bare
|
||||
string is treated as one option, never iterated character-by-character.
|
||||
"""
|
||||
if options is None:
|
||||
return None
|
||||
if isinstance(options, (list, tuple)):
|
||||
return [str(o) for o in options]
|
||||
return [str(options)]
|
||||
|
||||
|
||||
def _run_outcome_exit_code(status_value: str) -> int:
|
||||
|
||||
@@ -9,7 +9,7 @@ import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
from ._console import console
|
||||
|
||||
@@ -17,6 +17,44 @@ CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
|
||||
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
|
||||
|
||||
|
||||
def relative_extension_path_violation(value: Any) -> str | None:
|
||||
"""Return why ``value`` is unsafe as an extension-relative ``file`` path.
|
||||
|
||||
Single source of truth for the path-safety policy shared by
|
||||
``ExtensionManifest._validate()`` (manifest-load validation) and
|
||||
``CommandRegistrar.register_commands()`` (runtime guard), so the two cannot
|
||||
drift. Returns a human-readable reason string when ``value`` is unsafe, or
|
||||
``None`` when it is an acceptable relative path within the extension
|
||||
directory.
|
||||
|
||||
Policy: the value must be a non-empty string with no leading/trailing
|
||||
whitespace, no absolute/anchored form, and no ``..`` traversal. The value is
|
||||
evaluated under both POSIX and Windows path semantics because a native
|
||||
``Path`` is OS-dependent (a ``PurePosixPath`` on POSIX does not interpret
|
||||
Windows drive/UNC forms, and ``C:foo`` is anchored but not ``is_absolute()``
|
||||
yet resolves against the CWD on its drive). Rejecting any non-empty anchor
|
||||
covers POSIX-absolute (``/abs``), Windows drive-relative (``C:foo``), Windows
|
||||
absolute (``C:\\foo``), and UNC/rooted forms.
|
||||
"""
|
||||
if not isinstance(value, str) or not value:
|
||||
return "must be a non-empty string"
|
||||
if value.strip() != value:
|
||||
return "must not have leading or trailing whitespace"
|
||||
posix_path = PurePosixPath(value)
|
||||
win_path = PureWindowsPath(value)
|
||||
if (
|
||||
posix_path.anchor
|
||||
or win_path.anchor
|
||||
or ".." in posix_path.parts
|
||||
or ".." in win_path.parts
|
||||
):
|
||||
return (
|
||||
"must be a relative path within the extension directory "
|
||||
"(no absolute paths, drive letters, or '..' segments)"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def dump_frontmatter(data: dict[str, Any]) -> str:
|
||||
"""Serialize skill/command frontmatter to a YAML string.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Any, Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
from ._init_options import is_ai_skills_enabled, load_init_options
|
||||
from ._utils import relative_extension_path_violation
|
||||
|
||||
|
||||
def _build_agent_configs() -> dict[str, Any]:
|
||||
@@ -356,6 +357,33 @@ class CommandRegistrar:
|
||||
}
|
||||
return skill_frontmatter
|
||||
|
||||
@staticmethod
|
||||
def apply_argument_hint(
|
||||
source_frontmatter: Dict[str, Any],
|
||||
skill_frontmatter: Dict[str, Any],
|
||||
integration: Optional[object] = None,
|
||||
) -> None:
|
||||
"""Carry a command's ``argument-hint`` into its generated skill frontmatter.
|
||||
|
||||
Copies ``argument-hint`` from the parsed source command frontmatter into
|
||||
*skill_frontmatter* (mutated in place) before serialization, so that a
|
||||
folded multi-line ``description`` cannot be split into invalid YAML. Only
|
||||
integrations that support the field — those exposing
|
||||
``inject_argument_hint`` (currently Claude) — receive the key, leaving
|
||||
:meth:`build_skill_frontmatter`'s shared shape unchanged for every other
|
||||
agent. Built-in templates carry no ``argument-hint``, so this is a no-op
|
||||
for the core path.
|
||||
"""
|
||||
if not isinstance(source_frontmatter, dict) or not isinstance(skill_frontmatter, dict):
|
||||
return
|
||||
argument_hint = source_frontmatter.get("argument-hint")
|
||||
if (
|
||||
argument_hint
|
||||
and integration is not None
|
||||
and hasattr(integration, "inject_argument_hint")
|
||||
):
|
||||
skill_frontmatter["argument-hint"] = str(argument_hint)
|
||||
|
||||
@staticmethod
|
||||
def resolve_skill_placeholders(
|
||||
agent_name: str, frontmatter: dict, body: str, project_root: Path
|
||||
@@ -540,17 +568,42 @@ class CommandRegistrar:
|
||||
|
||||
registered = []
|
||||
is_cline_ext = agent_name == "cline" and source_id != "core"
|
||||
source_root = source_dir.resolve()
|
||||
|
||||
for cmd_info in commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
aliases = cmd_info.get("aliases", [])
|
||||
cmd_file = cmd_info["file"]
|
||||
|
||||
source_file = source_dir / cmd_file
|
||||
if not source_file.exists():
|
||||
# Guard against path traversal using the single shared policy in
|
||||
# relative_extension_path_violation(), so the runtime guard stays
|
||||
# aligned with ExtensionManifest._validate() and the skill/preset
|
||||
# readers. Skip a malformed/unsafe ``file`` (non-string, empty,
|
||||
# whitespace, absolute/anchored, or ``..`` traversal); the
|
||||
# resolve()/relative_to() check below is the final containment
|
||||
# backstop.
|
||||
if relative_extension_path_violation(cmd_file):
|
||||
continue
|
||||
try:
|
||||
source_file = (source_root / cmd_file).resolve()
|
||||
source_file.relative_to(source_root) # raises ValueError if outside
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
if not source_file.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"Skipping command '{cmd_name}': could not read source file "
|
||||
f"'{cmd_file}' ({exc.__class__.__name__}: {exc}).",
|
||||
stacklevel=2,
|
||||
)
|
||||
continue
|
||||
frontmatter, body = self.parse_frontmatter(content)
|
||||
|
||||
if frontmatter.get("strategy") == "wrap":
|
||||
|
||||
19
src/specify_cli/bundler/__init__.py
Normal file
19
src/specify_cli/bundler/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Spec Kit bundler — importable, Typer-free logic for the ``specify bundle`` group.
|
||||
|
||||
This package holds the models, services, and helpers behind the ``specify bundle``
|
||||
subcommand. It is intentionally free of any Typer/CLI imports so the orchestration
|
||||
logic can be unit-tested independently of the command surface (Constitution
|
||||
Principle I). The CLI wiring lives in ``specify_cli.commands.bundle``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["BundlerError"]
|
||||
|
||||
|
||||
class BundlerError(Exception):
|
||||
"""Base class for all actionable bundler errors.
|
||||
|
||||
Carrying a clean message lets the CLI layer print a single, user-facing line
|
||||
on stderr and exit non-zero without leaking a traceback (Constitution
|
||||
Principle V — explicit, actionable errors).
|
||||
"""
|
||||
2
src/specify_cli/bundler/commands_impl/__init__.py
Normal file
2
src/specify_cli/bundler/commands_impl/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Bundler command-implementation helpers (kept thin; logic lives in services)."""
|
||||
from __future__ import annotations
|
||||
191
src/specify_cli/bundler/commands_impl/catalog_config.py
Normal file
191
src/specify_cli/bundler/commands_impl/catalog_config.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Persistence for the project-scoped catalog config (``.specify/bundle-catalogs.yml``).
|
||||
|
||||
Only project scope is writable; built-in defaults are never deleted (they can be
|
||||
overridden by adding a same-id source). The on-disk shape mirrors
|
||||
``bundle-catalog.schema.md``: ``{schema_version, catalogs: [{id,url,priority,install_policy}]}``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
import re
|
||||
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import dump_yaml, ensure_within, load_yaml
|
||||
from ..models.catalog import (
|
||||
CONFIG_FILENAME,
|
||||
BUILTIN_DEFAULT_STACK,
|
||||
CatalogSource,
|
||||
InstallPolicy,
|
||||
Scope,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA_VERSION = "1.0"
|
||||
|
||||
_BUILTIN_IDS = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
|
||||
|
||||
# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter
|
||||
# ``scheme`` under urlparse; treat them as local files rather than URLs.
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def _config_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / ".specify" / CONFIG_FILENAME
|
||||
|
||||
|
||||
def _read(project_root: Path) -> list[dict]:
|
||||
# Confine the read (parity with the write path's within= guard): refuse to
|
||||
# follow a symlinked or traversal-escaping .specify that resolves outside
|
||||
# project_root.
|
||||
path = ensure_within(project_root, _config_path(project_root))
|
||||
if not path.exists():
|
||||
return []
|
||||
data = load_yaml(path)
|
||||
if data is None:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {path}: expected a mapping at the top "
|
||||
f"level, got {type(data).__name__}."
|
||||
)
|
||||
schema_version = data.get("schema_version")
|
||||
if schema_version is not None and (
|
||||
str(schema_version).strip().split(".")[0]
|
||||
!= CONFIG_SCHEMA_VERSION.split(".")[0]
|
||||
):
|
||||
raise BundlerError(
|
||||
f"Unsupported catalog config schema version "
|
||||
f"'{str(schema_version).strip()}' at {path}; this Spec Kit "
|
||||
f"understands version {CONFIG_SCHEMA_VERSION}. The file may have been "
|
||||
"written by a newer version or is corrupt."
|
||||
)
|
||||
catalogs = data.get("catalogs")
|
||||
if catalogs is None:
|
||||
return []
|
||||
if not isinstance(catalogs, list):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {path}: 'catalogs' must be a list, "
|
||||
f"got {type(catalogs).__name__}."
|
||||
)
|
||||
for entry in catalogs:
|
||||
if not isinstance(entry, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {path}: each catalog entry must be "
|
||||
f"a mapping, got {type(entry).__name__}."
|
||||
)
|
||||
return list(catalogs)
|
||||
|
||||
|
||||
def _write(project_root: Path, catalogs: list[dict]) -> None:
|
||||
payload = {"schema_version": CONFIG_SCHEMA_VERSION, "catalogs": catalogs}
|
||||
dump_yaml(_config_path(project_root), payload, within=project_root)
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
# Lowercase so derived ids are deterministic and case-insensitive across
|
||||
# platforms (e.g. 'Team-A.json' and 'team-a.json' yield the same id),
|
||||
# keeping the case-sensitive duplicate check from admitting logical dupes.
|
||||
return "".join(ch if ch.isalnum() else "-" for ch in value.lower()).strip("-")
|
||||
|
||||
|
||||
_REMOTE_SCHEMES = {"http", "https", "file", "builtin"}
|
||||
|
||||
|
||||
def _is_local_path(url: str) -> bool:
|
||||
"""True when *url* denotes a local filesystem path rather than a URL."""
|
||||
if _WINDOWS_DRIVE_RE.match(url):
|
||||
return True
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
return scheme not in _REMOTE_SCHEMES
|
||||
|
||||
|
||||
def _canonicalize_url(url: str) -> str:
|
||||
"""Make local file paths absolute so config is independent of the caller's cwd.
|
||||
|
||||
Remote URLs (``http(s)://``, ``file://``, ``builtin://``) are returned
|
||||
unchanged; only bare/relative local paths are resolved to an absolute path.
|
||||
"""
|
||||
if _is_local_path(url):
|
||||
return str(Path(url).expanduser().resolve())
|
||||
return url
|
||||
|
||||
|
||||
def _derive_id(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.netloc:
|
||||
# Use .hostname (not netloc.split(':')) so credentials, ports, and IPv6
|
||||
# literals (e.g. https://[2001:db8::1]/x) are handled correctly. Use the
|
||||
# full host (TLD included) so different domains sharing a second-level
|
||||
# label (example.com vs example.net) don't collide. _slug() lowercases
|
||||
# and turns separators into dashes, so 'Example.com' -> 'example-com'.
|
||||
host = parsed.hostname or ""
|
||||
path_stem = Path(parsed.path).stem if parsed.path else ""
|
||||
parts = [p for p in (_slug(host), _slug(path_stem)) if p]
|
||||
return "-".join(parts) or "catalog"
|
||||
stem = Path(parsed.path or url).stem
|
||||
return _slug(stem) or "catalog"
|
||||
|
||||
|
||||
def add_source(
|
||||
project_root: Path,
|
||||
url: str,
|
||||
*,
|
||||
policy: str,
|
||||
priority: int,
|
||||
source_id: str | None = None,
|
||||
) -> CatalogSource:
|
||||
url = url.strip()
|
||||
if not url:
|
||||
raise BundlerError("A catalog url is required.")
|
||||
parsed = urlparse(url)
|
||||
if not (parsed.scheme or parsed.path):
|
||||
raise BundlerError(f"Invalid catalog url: '{url}'.")
|
||||
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
|
||||
# never silently canonicalized as local filesystem paths. Local paths that
|
||||
# merely contain a ':' but no '://' (e.g. Windows drives) are still allowed.
|
||||
if "://" in url and parsed.scheme.lower() not in _REMOTE_SCHEMES:
|
||||
raise BundlerError(
|
||||
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
|
||||
"Use http(s)://, file://, builtin://, or a local path."
|
||||
)
|
||||
|
||||
url = _canonicalize_url(url)
|
||||
install_policy = InstallPolicy.parse(policy)
|
||||
resolved_id = (source_id or _derive_id(url)).strip()
|
||||
|
||||
catalogs = _read(project_root)
|
||||
for existing in catalogs:
|
||||
if existing.get("id") == resolved_id or existing.get("url") == url:
|
||||
raise BundlerError(
|
||||
f"Catalog source '{resolved_id}' (or url) already exists in this project."
|
||||
)
|
||||
|
||||
entry = {
|
||||
"id": resolved_id,
|
||||
"url": url,
|
||||
"priority": int(priority),
|
||||
"install_policy": install_policy.value,
|
||||
}
|
||||
catalogs.append(entry)
|
||||
_write(project_root, catalogs)
|
||||
return CatalogSource.from_dict(entry, Scope.PROJECT)
|
||||
|
||||
|
||||
def remove_source(project_root: Path, id_or_url: str) -> str:
|
||||
target = id_or_url.strip()
|
||||
if target in _BUILTIN_IDS:
|
||||
raise BundlerError(
|
||||
f"'{target}' is a built-in default source and cannot be deleted "
|
||||
"(add a same-id source to override it instead)."
|
||||
)
|
||||
|
||||
catalogs = _read(project_root)
|
||||
remaining = [
|
||||
c for c in catalogs if c.get("id") != target and c.get("url") != target
|
||||
]
|
||||
if len(remaining) == len(catalogs):
|
||||
raise BundlerError(
|
||||
f"No project-scoped catalog source matching '{target}' was found."
|
||||
)
|
||||
_write(project_root, remaining)
|
||||
return target
|
||||
2
src/specify_cli/bundler/lib/__init__.py
Normal file
2
src/specify_cli/bundler/lib/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Shared, dependency-light helpers for the bundler (YAML/JSON IO, versioning, project detection)."""
|
||||
from __future__ import annotations
|
||||
62
src/specify_cli/bundler/lib/project.py
Normal file
62
src/specify_cli/bundler/lib/project.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Spec Kit project detection and active-integration resolution."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .. import BundlerError
|
||||
from .yamlio import ensure_within, load_json
|
||||
|
||||
DEFAULT_INTEGRATION = "copilot"
|
||||
|
||||
|
||||
def find_project_root(start: Path | None = None) -> Path | None:
|
||||
"""Return the nearest ancestor (incl. *start*) containing a ``.specify/`` dir, or None.
|
||||
|
||||
A symlinked ``.specify`` is not accepted as a project root: following it
|
||||
could read/write outside the intended tree, and other CLI surfaces refuse
|
||||
it for the same reason.
|
||||
"""
|
||||
current = Path(start or Path.cwd()).resolve()
|
||||
for candidate in (current, *current.parents):
|
||||
marker = candidate / ".specify"
|
||||
if marker.is_dir() and not marker.is_symlink():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def require_project_root(start: Path | None = None) -> Path:
|
||||
"""Return the Spec Kit project root or raise an actionable error."""
|
||||
root = find_project_root(start)
|
||||
if root is None:
|
||||
raise BundlerError(
|
||||
"Not a Spec Kit project (no .specify/ directory). "
|
||||
"Run 'specify bundle init' or 'specify init' first."
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def active_integration(project_root: Path) -> str | None:
|
||||
"""Return the project's active integration id, if recorded.
|
||||
|
||||
Spec Kit records the chosen integration in ``.specify/integration.json``
|
||||
during init. Returns None when it cannot be determined (e.g. agnostic).
|
||||
"""
|
||||
marker = Path(project_root) / ".specify" / "integration.json"
|
||||
# Confine the read (mirrors records/catalog IO): refuse to follow a
|
||||
# symlinked or traversal-escaping .specify that resolves outside
|
||||
# project_root. An escape is treated as "not determinable".
|
||||
try:
|
||||
marker = ensure_within(project_root, marker)
|
||||
except BundlerError:
|
||||
return None
|
||||
if not marker.exists():
|
||||
return None
|
||||
try:
|
||||
data = load_json(marker)
|
||||
except BundlerError:
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
value = data.get("integration") or data.get("id") or data.get("active")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
99
src/specify_cli/bundler/lib/versioning.py
Normal file
99
src/specify_cli/bundler/lib/versioning.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""SemVer parsing and constraint evaluation, built on ``packaging`` (already a dependency)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from .. import BundlerError
|
||||
|
||||
# Common SemVer prerelease spellings (``1.2.3-rc1``, ``1.2.3-alpha.1``) that
|
||||
# PEP 440 / ``packaging`` rejects verbatim. Normalized to PEP 440 before
|
||||
# parsing so prerelease versions validate consistently (mirrors
|
||||
# ``specify_cli._version._normalize_tag``).
|
||||
_PRERELEASE_PATTERN = re.compile(
|
||||
r"^([0-9]+\.[0-9]+\.[0-9]+)[-.]?(alpha|beta|a|b|rc)[-.]?([0-9]+)(.*)$",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_semver(value: str) -> str:
|
||||
"""Normalize common SemVer prerelease spellings into PEP 440 text."""
|
||||
text = str(value)
|
||||
normalized = text[1:] if text[:1] in ("v", "V") else text
|
||||
match = _PRERELEASE_PATTERN.match(normalized)
|
||||
if match is None:
|
||||
return normalized
|
||||
base, label, number, rest = match.groups()
|
||||
pep440_label = {"alpha": "a", "beta": "b"}.get(label.lower(), label.lower())
|
||||
return f"{base}{pep440_label}{number}{rest}"
|
||||
|
||||
|
||||
def parse_version(value: str) -> Version:
|
||||
"""Parse a version string into a comparable :class:`Version`."""
|
||||
try:
|
||||
return Version(_normalize_semver(value))
|
||||
except InvalidVersion as exc:
|
||||
raise BundlerError(f"Invalid version '{value}': {exc}") from exc
|
||||
|
||||
|
||||
_SPECIFIER_CLAUSE = re.compile(r"^\s*(===|==|~=|!=|<=|>=|<|>)?\s*(.*?)\s*$")
|
||||
|
||||
|
||||
def _normalize_constraint(value: str) -> str:
|
||||
"""Normalize the version portion of each clause in a constraint string.
|
||||
|
||||
``packaging.SpecifierSet`` rejects SemVer prerelease spellings like
|
||||
``>=1.2.3-rc1`` verbatim, even though :func:`parse_version` accepts the same
|
||||
spelling for installed versions. Normalize each comma-separated clause's
|
||||
version so prerelease handling is consistent across versions and constraints.
|
||||
"""
|
||||
clauses = []
|
||||
for raw in str(value).split(","):
|
||||
if not raw.strip():
|
||||
continue
|
||||
match = _SPECIFIER_CLAUSE.match(raw)
|
||||
operator, version = match.groups()
|
||||
clauses.append(f"{operator or ''}{_normalize_semver(version)}")
|
||||
return ",".join(clauses)
|
||||
|
||||
|
||||
def parse_constraint(value: str) -> SpecifierSet:
|
||||
"""Parse a version constraint such as ``>=0.9.0`` into a :class:`SpecifierSet`."""
|
||||
try:
|
||||
return SpecifierSet(_normalize_constraint(value))
|
||||
except InvalidSpecifier as exc:
|
||||
raise BundlerError(
|
||||
f"Invalid version constraint '{value}': {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def satisfies(installed: str, constraint: str) -> bool:
|
||||
"""Return True if *installed* satisfies *constraint* (e.g. ``">=0.9.0"``).
|
||||
|
||||
Pre-releases are allowed so a dev/pre build of Spec Kit still counts.
|
||||
"""
|
||||
spec = parse_constraint(constraint)
|
||||
version = parse_version(installed)
|
||||
return spec.contains(version, prereleases=True)
|
||||
|
||||
|
||||
_SEMVER_RE = re.compile(
|
||||
r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
|
||||
r"(?:-(?:(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
|
||||
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
||||
r"(?:\+(?:[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
|
||||
)
|
||||
|
||||
|
||||
def is_semver(value: str) -> bool:
|
||||
"""Return True only for a full ``MAJOR.MINOR.PATCH`` SemVer string.
|
||||
|
||||
Stricter than ``packaging.version.Version``, which also accepts partial
|
||||
versions like ``"1"`` or ``"1.0"``. An optional leading ``v`` or ``V`` is
|
||||
tolerated (mirrors ``_normalize_semver``).
|
||||
"""
|
||||
text = str(value)
|
||||
core = text[1:] if text[:1] in ("v", "V") else text
|
||||
return bool(_SEMVER_RE.match(core))
|
||||
119
src/specify_cli/bundler/lib/yamlio.py
Normal file
119
src/specify_cli/bundler/lib/yamlio.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""YAML/JSON read-write helpers with path confinement (Constitution Principles IV & V).
|
||||
|
||||
All reads/writes go through these functions so that:
|
||||
- IO failures degrade into actionable :class:`~specify_cli.bundler.BundlerError`s
|
||||
rather than raw tracebacks, and
|
||||
- every path can be confined to an allowed root via :func:`ensure_within`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .. import BundlerError
|
||||
|
||||
|
||||
def ensure_within(root: Path, candidate: Path) -> Path:
|
||||
"""Resolve *candidate* and guarantee it stays within *root*.
|
||||
|
||||
Refuses path-traversal payloads and symlink escapes. Returns the resolved,
|
||||
confined path. Raises :class:`BundlerError` if the path escapes *root*.
|
||||
"""
|
||||
root_resolved = Path(root).resolve()
|
||||
# Resolve symlinks so a symlinked component cannot point outside the root.
|
||||
candidate_resolved = Path(candidate).resolve()
|
||||
try:
|
||||
candidate_resolved.relative_to(root_resolved)
|
||||
except ValueError as exc:
|
||||
raise BundlerError(
|
||||
f"Refusing path '{candidate}' — it escapes the allowed root '{root}'."
|
||||
) from exc
|
||||
return candidate_resolved
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
"""Parse a YAML file, returning ``{}`` for an empty document."""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"File not found: {path}")
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
|
||||
|
||||
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
"""Write *data* as YAML to *path* (optionally confined to *within*)."""
|
||||
path = Path(path)
|
||||
if within is not None:
|
||||
path = ensure_within(within, path)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
yaml.safe_dump(data, handle, sort_keys=False, default_flow_style=False)
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not write {path}: {exc}") from exc
|
||||
return path
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
"""Parse a JSON file."""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"File not found: {path}")
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BundlerError(f"Invalid JSON in {path}: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
|
||||
|
||||
def loads_json(text: str, *, origin: str = "<string>") -> Any:
|
||||
"""Parse JSON from a string (used for catalog payloads fetched as text)."""
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BundlerError(f"Invalid JSON from {origin}: {exc}") from exc
|
||||
|
||||
|
||||
def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
"""Write *data* as pretty JSON to *path* (optionally confined to *within*)."""
|
||||
path = Path(path)
|
||||
if within is not None:
|
||||
path = ensure_within(within, path)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2, sort_keys=False)
|
||||
handle.write("\n")
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not write {path}: {exc}") from exc
|
||||
return path
|
||||
|
||||
|
||||
def is_safe_relpath(rel: str) -> bool:
|
||||
"""Return True if *rel* is a project-relative path with no traversal/absolute parts.
|
||||
|
||||
Platform-independent: a POSIX-absolute path (``/abs``) or a Windows
|
||||
drive-absolute path (``C:\\x``) is rejected on every OS, since these strings
|
||||
can appear in untrusted catalog/manifest data regardless of the host.
|
||||
"""
|
||||
if not rel:
|
||||
return False
|
||||
normalized = rel.replace("\\", "/")
|
||||
if os.path.isabs(rel) or normalized.startswith("/"):
|
||||
return False
|
||||
if re.match(r"^[A-Za-z]:", normalized): # Windows drive-absolute (C:/...)
|
||||
return False
|
||||
parts = PurePosixPath(normalized).parts
|
||||
return ".." not in parts
|
||||
2
src/specify_cli/bundler/models/__init__.py
Normal file
2
src/specify_cli/bundler/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Bundler data models (manifest, catalog, records)."""
|
||||
from __future__ import annotations
|
||||
258
src/specify_cli/bundler/models/catalog.py
Normal file
258
src/specify_cli/bundler/models/catalog.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Catalog models: source stack (priority + install policy) and catalog entries.
|
||||
|
||||
Mirrors ``contracts/bundle-catalog.schema.md``. The stack precedence is
|
||||
project > user > built-in; install is permitted only from ``install-allowed``
|
||||
sources.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import ensure_within, load_yaml
|
||||
|
||||
CONFIG_FILENAME = "bundle-catalogs.yml"
|
||||
|
||||
|
||||
class InstallPolicy(str, Enum):
|
||||
INSTALL_ALLOWED = "install-allowed"
|
||||
DISCOVERY_ONLY = "discovery-only"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, value: Any) -> "InstallPolicy":
|
||||
text = str(value or "").strip()
|
||||
for policy in cls:
|
||||
if policy.value == text:
|
||||
return policy
|
||||
raise BundlerError(
|
||||
f"Invalid install_policy '{value}' "
|
||||
f"(must be one of {[p.value for p in cls]})."
|
||||
)
|
||||
|
||||
|
||||
class Scope(str, Enum):
|
||||
PROJECT = "project"
|
||||
USER = "user"
|
||||
BUILTIN = "built-in"
|
||||
|
||||
|
||||
# Built-in default stack (used when no project/user config overrides it).
|
||||
BUILTIN_DEFAULT_STACK: tuple[dict[str, Any], ...] = (
|
||||
{"id": "default", "url": "builtin://default", "priority": 1,
|
||||
"install_policy": InstallPolicy.INSTALL_ALLOWED.value},
|
||||
{"id": "community", "url": "builtin://community", "priority": 2,
|
||||
"install_policy": InstallPolicy.DISCOVERY_ONLY.value},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogSource:
|
||||
id: str
|
||||
url: str
|
||||
priority: int
|
||||
install_policy: InstallPolicy
|
||||
scope: Scope = Scope.PROJECT
|
||||
|
||||
@property
|
||||
def install_allowed(self) -> bool:
|
||||
return self.install_policy is InstallPolicy.INSTALL_ALLOWED
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Any, scope: Scope) -> "CatalogSource":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each catalog source must be a mapping.")
|
||||
source_id = str(data.get("id", "")).strip()
|
||||
url = str(data.get("url", "")).strip()
|
||||
if not source_id:
|
||||
raise BundlerError("A catalog source is missing its 'id'.")
|
||||
if not url:
|
||||
raise BundlerError(f"Catalog source '{source_id}' is missing its 'url'.")
|
||||
priority = data.get("priority")
|
||||
if priority is None:
|
||||
raise BundlerError(f"Catalog source '{source_id}' is missing its 'priority'.")
|
||||
if isinstance(priority, bool) or not isinstance(priority, (int, str)):
|
||||
raise BundlerError(
|
||||
f"Catalog source '{source_id}' has a non-integer priority: {priority!r}."
|
||||
)
|
||||
try:
|
||||
priority_int = int(priority)
|
||||
except (TypeError, ValueError):
|
||||
raise BundlerError(
|
||||
f"Catalog source '{source_id}' has a non-integer priority: {priority!r}."
|
||||
) from None
|
||||
return cls(
|
||||
id=source_id,
|
||||
url=url,
|
||||
priority=priority_int,
|
||||
install_policy=InstallPolicy.parse(data.get("install_policy")),
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"url": self.url,
|
||||
"priority": self.priority,
|
||||
"install_policy": self.install_policy.value,
|
||||
}
|
||||
|
||||
|
||||
def _parse_tags(value: Any, entry_id: str) -> tuple[str, ...]:
|
||||
"""Coerce a catalog entry's ``tags`` into a tuple of strings.
|
||||
|
||||
Catalogs are untrusted input: a bare string would otherwise be iterated
|
||||
character-by-character, so reject anything that is not a list/tuple.
|
||||
"""
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, (list, tuple)):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id}': 'tags' must be a list of strings."
|
||||
)
|
||||
return tuple(str(t) for t in value)
|
||||
|
||||
|
||||
def _parse_verified(value: Any, entry_id: str) -> bool:
|
||||
"""Validate a catalog entry's ``verified`` flag is a real boolean.
|
||||
|
||||
``bool("false")`` is truthy, so coercing arbitrary strings would silently
|
||||
mark untrusted entries as verified; require an actual boolean instead.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id}': 'verified' must be a boolean (true/false)."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
role: str
|
||||
description: str
|
||||
author: str
|
||||
license: str
|
||||
download_url: str
|
||||
requires_speckit_version: str
|
||||
provides: dict[str, int] = field(default_factory=dict)
|
||||
repository: str | None = None
|
||||
tags: tuple[str, ...] = ()
|
||||
verified: bool = False
|
||||
# Resolution provenance (filled in by the catalog stack at lookup time):
|
||||
source_id: str | None = None
|
||||
source_policy: InstallPolicy | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Any) -> "CatalogEntry":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each catalog entry must be a mapping.")
|
||||
entry_id = str(data.get("id", "")).strip()
|
||||
requires = data.get("requires") or {}
|
||||
if not isinstance(requires, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'requires' must be a "
|
||||
"mapping when present."
|
||||
)
|
||||
provides_raw = data.get("provides") or {}
|
||||
if not isinstance(provides_raw, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'provides' must be a "
|
||||
"mapping when present."
|
||||
)
|
||||
return cls(
|
||||
id=entry_id,
|
||||
name=str(data.get("name", "")).strip(),
|
||||
version=str(data.get("version", "")).strip(),
|
||||
role=str(data.get("role", "")).strip(),
|
||||
description=str(data.get("description", "")).strip(),
|
||||
author=str(data.get("author", "")).strip(),
|
||||
license=str(data.get("license", "")).strip(),
|
||||
download_url=str(data.get("download_url", "")).strip(),
|
||||
requires_speckit_version=str(requires.get("speckit_version", "")).strip(),
|
||||
provides=dict(provides_raw),
|
||||
repository=(str(data["repository"]) if data.get("repository") else None),
|
||||
tags=_parse_tags(data.get("tags"), entry_id),
|
||||
verified=_parse_verified(data.get("verified", False), entry_id),
|
||||
)
|
||||
|
||||
def with_provenance(self, source: CatalogSource) -> "CatalogEntry":
|
||||
return CatalogEntry(
|
||||
id=self.id, name=self.name, version=self.version, role=self.role,
|
||||
description=self.description, author=self.author, license=self.license,
|
||||
download_url=self.download_url,
|
||||
requires_speckit_version=self.requires_speckit_version,
|
||||
provides=self.provides, repository=self.repository, tags=self.tags,
|
||||
verified=self.verified, source_id=source.id,
|
||||
source_policy=source.install_policy,
|
||||
)
|
||||
|
||||
|
||||
def load_catalog_payload(data: Any) -> dict[str, CatalogEntry]:
|
||||
"""Parse a catalog JSON payload into ``{bundle_id: CatalogEntry}``."""
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Catalog payload must be a JSON object.")
|
||||
bundles_raw = data.get("bundles")
|
||||
if not isinstance(bundles_raw, dict):
|
||||
raise BundlerError("Catalog payload is missing a 'bundles' object.")
|
||||
entries: dict[str, CatalogEntry] = {}
|
||||
for bundle_id, entry_raw in bundles_raw.items():
|
||||
key = str(bundle_id)
|
||||
entry = CatalogEntry.from_dict(entry_raw)
|
||||
# The enclosing key is the authoritative bundle id used by
|
||||
# search/resolve/install. Reject entries whose own ``id`` is missing or
|
||||
# disagrees with the key, so a malformed or malicious catalog can't list
|
||||
# an id that resolves to a different (or no) bundle.
|
||||
if not entry.id:
|
||||
raise BundlerError(
|
||||
f"Catalog entry for '{key}' is missing its 'id' field."
|
||||
)
|
||||
if entry.id != key:
|
||||
raise BundlerError(
|
||||
f"Catalog entry id mismatch: key '{key}' != entry id "
|
||||
f"'{entry.id}'."
|
||||
)
|
||||
entries[key] = entry
|
||||
return entries
|
||||
|
||||
|
||||
def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -> list[CatalogSource]:
|
||||
"""Build the effective, priority-sorted source stack (project > user > built-in).
|
||||
|
||||
A source id present at a higher-precedence scope overrides the same id at a
|
||||
lower scope. The built-in default stack is always the fallback.
|
||||
"""
|
||||
by_id: dict[str, CatalogSource] = {}
|
||||
|
||||
# Lowest precedence first; later writes override earlier ones for the same id.
|
||||
for raw in BUILTIN_DEFAULT_STACK:
|
||||
src = CatalogSource.from_dict(raw, Scope.BUILTIN)
|
||||
by_id[src.id] = src
|
||||
|
||||
if user_config_dir is not None:
|
||||
_merge_config(by_id, Path(user_config_dir) / CONFIG_FILENAME, Scope.USER)
|
||||
|
||||
# Confine the project-scoped read: refuse a symlinked .specify/ that
|
||||
# resolves outside the project root (consistent with other guarded reads).
|
||||
project_config = Path(project_root) / ".specify" / CONFIG_FILENAME
|
||||
if project_config.exists():
|
||||
ensure_within(project_root, project_config)
|
||||
_merge_config(by_id, project_config, Scope.PROJECT)
|
||||
|
||||
return sorted(by_id.values(), key=lambda s: (s.priority, s.id))
|
||||
|
||||
|
||||
def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None:
|
||||
if not config_path.exists():
|
||||
return
|
||||
data = load_yaml(config_path)
|
||||
catalogs = data.get("catalogs") if isinstance(data, dict) else None
|
||||
if not catalogs:
|
||||
return
|
||||
for raw in catalogs:
|
||||
src = CatalogSource.from_dict(raw, scope)
|
||||
by_id[src.id] = src
|
||||
263
src/specify_cli/bundler/models/manifest.py
Normal file
263
src/specify_cli/bundler/models/manifest.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Bundle manifest model (``bundle.yml``) — parsing and structural normalization.
|
||||
|
||||
Mirrors ``contracts/bundle-manifest.schema.md``. Structural validation (shape,
|
||||
required fields, enum/semver checks) lives here; *reference* resolution against a
|
||||
catalog stack lives in the validator/resolver services.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import BundlerError
|
||||
from ..lib.versioning import is_semver
|
||||
from ..lib.yamlio import load_yaml
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = {"1.0"}
|
||||
PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"}
|
||||
|
||||
COMPONENT_KINDS = ("extensions", "presets", "steps", "workflows")
|
||||
|
||||
# A bundle id must be a filesystem-safe slug: it is interpolated into artifact
|
||||
# filenames (e.g. ``<id>-<version>.zip``), so path separators or traversal
|
||||
# segments must never appear.
|
||||
_SAFE_BUNDLE_ID = re.compile(r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComponentRef:
|
||||
"""A pointer to an existing Spec Kit primitive a bundle installs."""
|
||||
|
||||
kind: str # one of COMPONENT_KINDS (singularized concept), stored plural-of-origin
|
||||
id: str
|
||||
version: str | None = None
|
||||
source: str | None = None
|
||||
priority: int | None = None # presets only
|
||||
strategy: str | None = None # presets only
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.kind[:-1]}:{self.id}@{self.version or 'unpinned'}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationRef:
|
||||
id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Requires:
|
||||
speckit_version: str
|
||||
tools: tuple[str, ...] = ()
|
||||
mcp: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleMeta:
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
role: str
|
||||
description: str
|
||||
author: str
|
||||
license: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleManifest:
|
||||
schema_version: str
|
||||
bundle: BundleMeta
|
||||
requires: Requires
|
||||
integration: IntegrationRef | None = None
|
||||
extensions: list[ComponentRef] = field(default_factory=list)
|
||||
presets: list[ComponentRef] = field(default_factory=list)
|
||||
steps: list[ComponentRef] = field(default_factory=list)
|
||||
workflows: list[ComponentRef] = field(default_factory=list)
|
||||
tags: tuple[str, ...] = ()
|
||||
source_path: Path | None = None
|
||||
|
||||
@property
|
||||
def components(self) -> list[ComponentRef]:
|
||||
"""All installable component references in deterministic order."""
|
||||
return [*self.extensions, *self.presets, *self.steps, *self.workflows]
|
||||
|
||||
# -- construction ---------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "BundleManifest":
|
||||
data = load_yaml(path)
|
||||
manifest = cls.from_dict(data)
|
||||
manifest.source_path = Path(path)
|
||||
return manifest
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Any) -> "BundleManifest":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Manifest must be a YAML mapping at the top level.")
|
||||
|
||||
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=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") or {}
|
||||
if not isinstance(requires_raw, dict):
|
||||
raise BundlerError("'requires' must be a mapping when present.")
|
||||
requires = Requires(
|
||||
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"),
|
||||
)
|
||||
|
||||
integration = None
|
||||
integration_raw = data.get("integration")
|
||||
if isinstance(integration_raw, dict) and integration_raw.get("id"):
|
||||
integration = IntegrationRef(id=str(integration_raw["id"]).strip())
|
||||
|
||||
provides = data.get("provides") or {}
|
||||
if not isinstance(provides, dict):
|
||||
raise BundlerError("'provides' must be a mapping when present.")
|
||||
|
||||
tags_raw = data.get("tags")
|
||||
if tags_raw is None:
|
||||
tags_raw = []
|
||||
else:
|
||||
tags_raw = _parse_str_list(tags_raw, "tags")
|
||||
|
||||
manifest = cls(
|
||||
schema_version=schema_version,
|
||||
bundle=meta,
|
||||
requires=requires,
|
||||
integration=integration,
|
||||
extensions=_parse_refs("extensions", provides.get("extensions")),
|
||||
presets=_parse_refs("presets", provides.get("presets")),
|
||||
steps=_parse_refs("steps", provides.get("steps")),
|
||||
workflows=_parse_refs("workflows", provides.get("workflows")),
|
||||
tags=tuple(str(t) for t in tags_raw),
|
||||
)
|
||||
return manifest
|
||||
|
||||
# -- structural validation ------------------------------------------------
|
||||
|
||||
def structural_errors(self) -> list[str]:
|
||||
"""Return a list of human-readable structural problems (empty == valid)."""
|
||||
errors: list[str] = []
|
||||
|
||||
if self.schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
errors.append(
|
||||
f"schema_version '{self.schema_version or '<missing>'}' is not supported "
|
||||
f"(supported: {sorted(SUPPORTED_SCHEMA_VERSIONS)})."
|
||||
)
|
||||
|
||||
required = {
|
||||
"bundle.id": self.bundle.id,
|
||||
"bundle.name": self.bundle.name,
|
||||
"bundle.version": self.bundle.version,
|
||||
"bundle.role": self.bundle.role,
|
||||
"bundle.description": self.bundle.description,
|
||||
"bundle.author": self.bundle.author,
|
||||
"bundle.license": self.bundle.license,
|
||||
"requires.speckit_version": self.requires.speckit_version,
|
||||
}
|
||||
for field_path, value in required.items():
|
||||
if not value:
|
||||
errors.append(f"Missing required field: {field_path}.")
|
||||
|
||||
if self.bundle.version and not is_semver(self.bundle.version):
|
||||
errors.append(f"bundle.version '{self.bundle.version}' is not valid semver.")
|
||||
|
||||
if self.bundle.id and not _SAFE_BUNDLE_ID.match(self.bundle.id):
|
||||
errors.append(
|
||||
f"bundle.id '{self.bundle.id}' must be a slug "
|
||||
"(lowercase letters, digits, '.', '_', '-'; no path separators)."
|
||||
)
|
||||
|
||||
for ref in self.components:
|
||||
if not ref.id:
|
||||
errors.append(f"A {ref.kind[:-1]} entry is missing its 'id'.")
|
||||
if ref.kind != "steps" and not ref.version:
|
||||
errors.append(
|
||||
f"{ref.kind[:-1]} '{ref.id or '<unknown>'}' must be pinned to a 'version'."
|
||||
)
|
||||
if ref.version and not is_semver(ref.version):
|
||||
errors.append(
|
||||
f"{ref.kind[:-1]} '{ref.id}' has invalid version '{ref.version}'."
|
||||
)
|
||||
|
||||
for ref in self.presets:
|
||||
if ref.priority is None:
|
||||
errors.append(f"preset '{ref.id}' must declare an integer 'priority'.")
|
||||
if ref.strategy is None or ref.strategy not in PRESET_STRATEGIES:
|
||||
errors.append(
|
||||
f"preset '{ref.id}' has invalid strategy '{ref.strategy}' "
|
||||
f"(must be one of {sorted(PRESET_STRATEGIES)})."
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
def is_agnostic(self) -> bool:
|
||||
"""True when the bundle declares no integration (inherits the active one)."""
|
||||
return self.integration is None
|
||||
|
||||
|
||||
def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]:
|
||||
"""Coerce a manifest list-of-strings field into a tuple of strings.
|
||||
|
||||
Rejects a bare string/bytes (which would otherwise be iterated
|
||||
character-by-character) and any non-list/tuple, matching the manifest
|
||||
contract (``string[]``).
|
||||
"""
|
||||
if raw is None:
|
||||
return ()
|
||||
if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)):
|
||||
raise BundlerError(f"'{field_name}' must be a list of strings when present.")
|
||||
return tuple(str(item) for item in raw)
|
||||
|
||||
|
||||
def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise BundlerError(f"provides.{kind} must be a list when present.")
|
||||
refs: list[ComponentRef] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
raise BundlerError(f"Each provides.{kind} entry must be a mapping.")
|
||||
priority = _parse_priority(kind, item.get("priority"))
|
||||
refs.append(
|
||||
ComponentRef(
|
||||
kind=kind,
|
||||
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,
|
||||
strategy=(str(item["strategy"]).strip() if item.get("strategy") else None),
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
def _parse_priority(kind: str, raw: Any) -> int | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
|
||||
raise BundlerError(
|
||||
f"provides.{kind} priority must be an integer, got {raw!r}."
|
||||
)
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise BundlerError(
|
||||
f"provides.{kind} priority must be an integer, got {raw!r}."
|
||||
) from None
|
||||
229
src/specify_cli/bundler/models/records.py
Normal file
229
src/specify_cli/bundler/models/records.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Installed-bundle records — provenance for precise list/remove/update.
|
||||
|
||||
Records are stored as JSON at ``.specify/bundle-records.json``. Each record
|
||||
captures exactly which components a bundle contributed so removal touches only
|
||||
that bundle's components and never collateral (FR-022, SC-004).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import dump_json, ensure_within, load_json
|
||||
from .manifest import COMPONENT_KINDS, ComponentRef
|
||||
|
||||
RECORDS_FILENAME = "bundle-records.json"
|
||||
RECORDS_SCHEMA_VERSION = "1.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstalledBundleRecord:
|
||||
bundle_id: str
|
||||
version: str
|
||||
contributed_components: tuple[ComponentRef, ...]
|
||||
installed_at: str
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
bundle_id: str,
|
||||
version: str,
|
||||
components: list[ComponentRef],
|
||||
installed_at: str | None = None,
|
||||
) -> "InstalledBundleRecord":
|
||||
return cls(
|
||||
bundle_id=bundle_id,
|
||||
version=version,
|
||||
contributed_components=tuple(components),
|
||||
installed_at=installed_at or _utc_now(),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"bundle_id": self.bundle_id,
|
||||
"version": self.version,
|
||||
"installed_at": self.installed_at,
|
||||
"contributed_components": [
|
||||
_component_to_dict(c) for c in self.contributed_components
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each installed-bundle record must be a mapping.")
|
||||
components_raw = data.get("contributed_components") or []
|
||||
if not isinstance(components_raw, list):
|
||||
raise BundlerError(
|
||||
"Corrupt record: 'contributed_components' must be a list."
|
||||
)
|
||||
bundle_id = str(data.get("bundle_id", "")).strip()
|
||||
version = str(data.get("version", "")).strip()
|
||||
if not bundle_id:
|
||||
raise BundlerError(
|
||||
"Corrupt records file: an installed-bundle record is missing "
|
||||
"its 'bundle_id'."
|
||||
)
|
||||
if not version:
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: record for bundle '{bundle_id}' is "
|
||||
"missing its 'version'."
|
||||
)
|
||||
return cls(
|
||||
bundle_id=bundle_id,
|
||||
version=version,
|
||||
installed_at=str(data.get("installed_at", "")).strip(),
|
||||
contributed_components=tuple(
|
||||
_component_from_dict(c) for c in components_raw
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def records_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / ".specify" / RECORDS_FILENAME
|
||||
|
||||
|
||||
def _check_schema_version(value: Any, *, path: Path, required: bool) -> None:
|
||||
"""Reject a records file whose schema version we cannot safely parse.
|
||||
|
||||
A future incompatible format (or a corrupted file) must fail fast with an
|
||||
actionable error rather than being silently mis-parsed, which could lead to
|
||||
incorrect bundle attribution or removal. Forward-compatible minor bumps that
|
||||
keep the same major version are accepted.
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: {path} — missing 'schema_version'. "
|
||||
f"Expected version {RECORDS_SCHEMA_VERSION}."
|
||||
)
|
||||
return
|
||||
seen = str(value).strip()
|
||||
if seen.split(".")[0] != RECORDS_SCHEMA_VERSION.split(".")[0]:
|
||||
raise BundlerError(
|
||||
f"Unsupported records schema version '{seen}' at {path}; this "
|
||||
f"Spec Kit understands version {RECORDS_SCHEMA_VERSION}. The file may "
|
||||
"have been written by a newer version or is corrupt."
|
||||
)
|
||||
|
||||
|
||||
def load_records(project_root: Path) -> list[InstalledBundleRecord]:
|
||||
# Defense in depth (mirrors the write path's within= confinement): refuse to
|
||||
# read through a symlinked or traversal-escaping ``.specify`` that resolves
|
||||
# outside project_root.
|
||||
path = ensure_within(project_root, records_path(project_root))
|
||||
if not path.exists():
|
||||
return []
|
||||
data = load_json(path)
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(f"Corrupt records file: {path}")
|
||||
_check_schema_version(data.get("schema_version"), path=path, required=True)
|
||||
bundles = data.get("bundles") or []
|
||||
if not isinstance(bundles, list):
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: {path} — 'bundles' must be a list."
|
||||
)
|
||||
return [InstalledBundleRecord.from_dict(item) for item in bundles]
|
||||
|
||||
|
||||
def save_records(project_root: Path, records: list[InstalledBundleRecord]) -> None:
|
||||
payload = {
|
||||
"schema_version": RECORDS_SCHEMA_VERSION,
|
||||
"updated_at": _utc_now(),
|
||||
"bundles": [r.to_dict() for r in records],
|
||||
}
|
||||
dump_json(records_path(project_root), payload, within=project_root)
|
||||
|
||||
|
||||
def find_record(
|
||||
records: list[InstalledBundleRecord], bundle_id: str
|
||||
) -> InstalledBundleRecord | None:
|
||||
for record in records:
|
||||
if record.bundle_id == bundle_id:
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def upsert_record(
|
||||
records: list[InstalledBundleRecord], record: InstalledBundleRecord
|
||||
) -> list[InstalledBundleRecord]:
|
||||
"""Return a new list with *record* replacing any same-id record (append otherwise)."""
|
||||
updated = [r for r in records if r.bundle_id != record.bundle_id]
|
||||
updated.append(record)
|
||||
return updated
|
||||
|
||||
|
||||
def remove_record(
|
||||
records: list[InstalledBundleRecord], bundle_id: str
|
||||
) -> list[InstalledBundleRecord]:
|
||||
return [r for r in records if r.bundle_id != bundle_id]
|
||||
|
||||
|
||||
def components_still_needed(
|
||||
records: list[InstalledBundleRecord], exclude_bundle_id: str
|
||||
) -> set[tuple[str, str]]:
|
||||
"""Set of ``(kind, id)`` component keys required by bundles other than the excluded one."""
|
||||
needed: set[tuple[str, str]] = set()
|
||||
for record in records:
|
||||
if record.bundle_id == exclude_bundle_id:
|
||||
continue
|
||||
for component in record.contributed_components:
|
||||
needed.add((component.kind, component.id))
|
||||
return needed
|
||||
|
||||
|
||||
def _component_to_dict(ref: ComponentRef) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {"kind": ref.kind, "id": ref.id}
|
||||
if ref.version is not None:
|
||||
data["version"] = ref.version
|
||||
if ref.source is not None:
|
||||
data["source"] = ref.source
|
||||
if ref.priority is not None:
|
||||
data["priority"] = ref.priority
|
||||
if ref.strategy is not None:
|
||||
data["strategy"] = ref.strategy
|
||||
return data
|
||||
|
||||
|
||||
def _component_from_dict(data: Any) -> ComponentRef:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each contributed component must be a mapping.")
|
||||
kind = str(data.get("kind", "")).strip()
|
||||
cid = str(data.get("id", "")).strip()
|
||||
if kind not in COMPONENT_KINDS:
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: component 'kind' must be one of "
|
||||
f"{list(COMPONENT_KINDS)}, got {kind or '<missing>'!r}."
|
||||
)
|
||||
if not cid:
|
||||
raise BundlerError(
|
||||
"Corrupt records file: a contributed component is missing its 'id'."
|
||||
)
|
||||
return ComponentRef(
|
||||
kind=kind,
|
||||
id=cid,
|
||||
version=(str(data["version"]) if data.get("version") else None),
|
||||
source=(str(data["source"]) if data.get("source") else None),
|
||||
priority=_parse_priority(data.get("priority")),
|
||||
strategy=(str(data["strategy"]) if data.get("strategy") else None),
|
||||
)
|
||||
|
||||
|
||||
def _parse_priority(raw: Any) -> int | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
|
||||
raise BundlerError(f"Component priority must be an integer, got {raw!r}.")
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise BundlerError(
|
||||
f"Component priority must be an integer, got {raw!r}."
|
||||
) from None
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
2
src/specify_cli/bundler/services/__init__.py
Normal file
2
src/specify_cli/bundler/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Bundler services (catalog stack, resolver, installer, conflict, validator, packager)."""
|
||||
from __future__ import annotations
|
||||
193
src/specify_cli/bundler/services/adapters.py
Normal file
193
src/specify_cli/bundler/services/adapters.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Concrete adapters: catalog fetching and primitive installation.
|
||||
|
||||
These wire the bundler's injectable seams to the real environment:
|
||||
|
||||
* :func:`make_catalog_fetcher` returns an offline-first fetcher that reads
|
||||
built-in catalogs and local/pinned file URLs without network, and falls back
|
||||
to a timeout-bounded HTTP GET only for ``http(s)://`` sources.
|
||||
* :class:`DefaultPrimitiveInstaller` dispatches component install/remove to the
|
||||
existing Spec Kit primitive machinery in-process.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import loads_json
|
||||
from ..models.catalog import CatalogSource
|
||||
from ..models.manifest import ComponentRef
|
||||
|
||||
# Built-in catalog payloads ship empty by default; a host distribution can
|
||||
# replace these with curated content. Keeping them here makes ``search``/``info``
|
||||
# work fully offline against the default stack.
|
||||
_BUILTIN_CATALOGS: dict[str, dict] = {
|
||||
"builtin://default": {
|
||||
"schema_version": "1.0",
|
||||
"catalog_url": "builtin://default",
|
||||
"bundles": {},
|
||||
},
|
||||
"builtin://community": {
|
||||
"schema_version": "1.0",
|
||||
"catalog_url": "builtin://community",
|
||||
"bundles": {},
|
||||
},
|
||||
}
|
||||
|
||||
HTTP_TIMEOUT_SECONDS = 10
|
||||
|
||||
# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter
|
||||
# ``scheme`` under urlparse; treat them as local files rather than URLs.
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def _is_windows_drive_path(url: str) -> bool:
|
||||
return bool(_WINDOWS_DRIVE_RE.match(url))
|
||||
|
||||
|
||||
def _file_url_to_path(parsed: ParseResult) -> Path:
|
||||
"""Convert a ``file://`` URL to a local path.
|
||||
|
||||
Uses ``url2pathname`` for percent-decoding and OS-correct separators, and
|
||||
preserves ``netloc`` so UNC paths (``file://server/share``) and Windows
|
||||
drive URLs (``file:///C:/x``) resolve correctly instead of dropping host
|
||||
or producing ``/C:/x``.
|
||||
"""
|
||||
netloc = parsed.netloc
|
||||
if netloc and netloc.lower() != "localhost":
|
||||
# UNC share: file://server/share/... -> \\server\share\...
|
||||
return Path(url2pathname(f"//{netloc}{parsed.path}"))
|
||||
return Path(url2pathname(parsed.path))
|
||||
|
||||
|
||||
def _validate_remote_url(source_id: str, url: str) -> None:
|
||||
"""Restrict remote catalogs to HTTPS (HTTP only for localhost) with a host.
|
||||
|
||||
Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade
|
||||
issues before any network call.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
|
||||
)
|
||||
|
||||
|
||||
def make_catalog_fetcher(*, allow_network: bool = True):
|
||||
"""Return a fetcher callable suitable for :class:`CatalogStack`.
|
||||
|
||||
When *allow_network* is False, ``http(s)://`` sources raise instead of
|
||||
touching the network (used by offline tests and ``--offline`` flows).
|
||||
"""
|
||||
|
||||
def fetch(source: CatalogSource) -> dict:
|
||||
url = source.url
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
if scheme == "builtin":
|
||||
payload = _BUILTIN_CATALOGS.get(url)
|
||||
if payload is None:
|
||||
raise BundlerError(f"Unknown built-in catalog '{url}'.")
|
||||
return payload
|
||||
|
||||
if scheme == "file":
|
||||
path = _file_url_to_path(parsed)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"Catalog file not found: {path}")
|
||||
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
|
||||
|
||||
if scheme == "" or _is_windows_drive_path(url):
|
||||
path = Path(url)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"Catalog file not found: {path}")
|
||||
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
|
||||
|
||||
if scheme in ("http", "https"):
|
||||
if not allow_network:
|
||||
raise BundlerError(
|
||||
f"Network access disabled; cannot fetch catalog '{source.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
_validate_remote_url(source.id, url)
|
||||
return _http_get_json(source.id, url)
|
||||
|
||||
raise BundlerError(f"Unsupported catalog URL scheme: {url}")
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
def _http_get_json(source_id: str, url: str) -> dict:
|
||||
"""Fetch catalog JSON over HTTP(S) via the shared authenticated client.
|
||||
|
||||
Routing through :func:`specify_cli.authentication.http.open_url` gives
|
||||
``auth.json`` token support and strips the ``Authorization`` header when a
|
||||
redirect leaves the entry's trusted hosts or downgrades the scheme. We also
|
||||
reject any redirect that leaves HTTPS (the ``redirect_validator`` runs
|
||||
*before* each hop) and re-validate the final URL after redirects, so the
|
||||
HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end
|
||||
rather than only on the initial URL.
|
||||
"""
|
||||
from ...authentication.http import open_url
|
||||
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
_validate_remote_url(source_id, new_url)
|
||||
|
||||
try:
|
||||
with open_url(
|
||||
url,
|
||||
timeout=HTTP_TIMEOUT_SECONDS,
|
||||
redirect_validator=_validate_redirect,
|
||||
) as response:
|
||||
final_url = response.geturl()
|
||||
_validate_remote_url(source_id, final_url)
|
||||
raw = response.read().decode("utf-8")
|
||||
except BundlerError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise BundlerError(f"Failed to fetch catalog from {url}: {exc}") from exc
|
||||
return loads_json(raw, origin=final_url)
|
||||
|
||||
|
||||
class DefaultPrimitiveInstaller:
|
||||
"""Dispatch component install/remove to existing primitive machinery.
|
||||
|
||||
This adapter is intentionally thin: it owns no install logic of its own,
|
||||
delegating entirely to the per-primitive managers so the bundler honours
|
||||
Principle I (no duplicated primitive logic).
|
||||
|
||||
*allow_network* mirrors the bundle command's ``--offline`` flag: when False,
|
||||
component kinds that can only be sourced from a remote catalog refuse rather
|
||||
than touching the network. Bundled presets/extensions still install offline.
|
||||
"""
|
||||
|
||||
def __init__(self, *, allow_network: bool = True) -> None:
|
||||
self._allow_network = allow_network
|
||||
|
||||
def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
|
||||
manager = self._manager_for(component, project_root)
|
||||
return manager.is_installed(component)
|
||||
|
||||
def install(self, project_root: Path, component: ComponentRef) -> None:
|
||||
manager = self._manager_for(component, project_root)
|
||||
manager.install(component)
|
||||
|
||||
def remove(self, project_root: Path, component: ComponentRef) -> None:
|
||||
manager = self._manager_for(component, project_root)
|
||||
manager.remove(component)
|
||||
|
||||
def _manager_for(self, component: ComponentRef, project_root: Path):
|
||||
# Lazy import to avoid import cycles and keep startup cheap (Principle IV).
|
||||
from .primitives import primitive_manager
|
||||
|
||||
return primitive_manager(
|
||||
component.kind, project_root, allow_network=self._allow_network
|
||||
)
|
||||
114
src/specify_cli/bundler/services/catalog_stack.py
Normal file
114
src/specify_cli/bundler/services/catalog_stack.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Catalog stack: aggregate bundle entries across sources with precedence + policy.
|
||||
|
||||
Loads each source's catalog payload (via an injectable fetcher so tests stay
|
||||
offline), then resolves a bundle id to the highest-precedence entry while
|
||||
recording whether installation is permitted by that source's policy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from .. import BundlerError
|
||||
from ..models.catalog import (
|
||||
CatalogEntry,
|
||||
CatalogSource,
|
||||
load_catalog_payload,
|
||||
load_source_stack,
|
||||
)
|
||||
|
||||
# A fetcher returns the raw JSON payload (a dict) for a given source.
|
||||
CatalogFetcher = Callable[[CatalogSource], dict]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedBundle:
|
||||
entry: CatalogEntry
|
||||
source: CatalogSource
|
||||
|
||||
@property
|
||||
def install_allowed(self) -> bool:
|
||||
return self.source.install_allowed
|
||||
|
||||
|
||||
class CatalogStack:
|
||||
def __init__(
|
||||
self,
|
||||
sources: list[CatalogSource],
|
||||
fetcher: CatalogFetcher,
|
||||
) -> None:
|
||||
# Highest precedence (lowest priority number) first.
|
||||
self._sources = sorted(sources, key=lambda s: (s.priority, s.id))
|
||||
self._fetcher = fetcher
|
||||
self._payloads: dict[str, dict[str, CatalogEntry]] = {}
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
project_root: Path,
|
||||
fetcher: CatalogFetcher,
|
||||
user_config_dir: Path | None = None,
|
||||
) -> "CatalogStack":
|
||||
sources = load_source_stack(project_root, user_config_dir)
|
||||
return cls(sources, fetcher)
|
||||
|
||||
@property
|
||||
def sources(self) -> list[CatalogSource]:
|
||||
return list(self._sources)
|
||||
|
||||
def _entries_for(self, source: CatalogSource) -> dict[str, CatalogEntry]:
|
||||
if source.id not in self._payloads:
|
||||
try:
|
||||
raw = self._fetcher(source)
|
||||
except BundlerError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - surface as chained BundlerError
|
||||
raise BundlerError(
|
||||
f"Failed to load catalog '{source.id}' ({source.url}): {exc}"
|
||||
) from exc
|
||||
self._payloads[source.id] = load_catalog_payload(raw)
|
||||
return self._payloads[source.id]
|
||||
|
||||
def resolve(self, bundle_id: str) -> ResolvedBundle:
|
||||
"""Return the highest-precedence entry for *bundle_id* or raise."""
|
||||
for source in self._sources:
|
||||
entries = self._entries_for(source)
|
||||
entry = entries.get(bundle_id)
|
||||
if entry is not None:
|
||||
return ResolvedBundle(entry=entry.with_provenance(source), source=source)
|
||||
raise BundlerError(
|
||||
f"Bundle '{bundle_id}' was not found in any configured catalog."
|
||||
)
|
||||
|
||||
def search(self, query: str = "") -> list[ResolvedBundle]:
|
||||
"""Return entries matching *query* (substring over id/name/role/tags/description).
|
||||
|
||||
Each bundle id appears once, resolved at its highest-precedence source.
|
||||
Results are sorted by bundle id for deterministic output.
|
||||
"""
|
||||
needle = query.strip().lower()
|
||||
seen: dict[str, ResolvedBundle] = {}
|
||||
for source in self._sources:
|
||||
for bundle_id, entry in self._entries_for(source).items():
|
||||
if bundle_id in seen:
|
||||
continue
|
||||
if needle and not _matches(entry, needle):
|
||||
continue
|
||||
seen[bundle_id] = ResolvedBundle(
|
||||
entry=entry.with_provenance(source), source=source
|
||||
)
|
||||
return [seen[k] for k in sorted(seen)]
|
||||
|
||||
|
||||
def _matches(entry: CatalogEntry, needle: str) -> bool:
|
||||
haystack = " ".join(
|
||||
[
|
||||
entry.id,
|
||||
entry.name,
|
||||
entry.role,
|
||||
entry.description,
|
||||
" ".join(entry.tags),
|
||||
]
|
||||
).lower()
|
||||
return needle in haystack
|
||||
54
src/specify_cli/bundler/services/conflict.py
Normal file
54
src/specify_cli/bundler/services/conflict.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Conflict detection across the installed-bundle stack.
|
||||
|
||||
The single cross-bundle conflict point is the active integration (FR-019).
|
||||
Component-level overlaps (same preset id at different priorities, etc.) are
|
||||
resolved by the existing primitive machinery's own precedence rules, so the
|
||||
bundler only needs to guard the integration invariant and surface informational
|
||||
overlaps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..models.manifest import BundleManifest
|
||||
from ..models.records import InstalledBundleRecord
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConflictReport:
|
||||
integration_clash: str | None = None # message when a hard clash exists
|
||||
overlaps: list[str] = field(default_factory=list) # components already provided
|
||||
|
||||
@property
|
||||
def has_blocking_conflict(self) -> bool:
|
||||
return self.integration_clash is not None
|
||||
|
||||
|
||||
def detect_conflicts(
|
||||
manifest: BundleManifest,
|
||||
active_integration: str | None,
|
||||
installed: list[InstalledBundleRecord],
|
||||
) -> ConflictReport:
|
||||
report = ConflictReport()
|
||||
|
||||
if manifest.integration is not None and active_integration:
|
||||
if manifest.integration.id != active_integration:
|
||||
report.integration_clash = (
|
||||
f"Bundle targets integration '{manifest.integration.id}' but the "
|
||||
f"project's active integration is '{active_integration}'."
|
||||
)
|
||||
|
||||
already: dict[tuple[str, str], str] = {}
|
||||
for record in installed:
|
||||
for component in record.contributed_components:
|
||||
already[(component.kind, component.id)] = record.bundle_id
|
||||
|
||||
for component in manifest.components:
|
||||
owner = already.get((component.kind, component.id))
|
||||
if owner and owner != manifest.bundle.id:
|
||||
report.overlaps.append(
|
||||
f"{component.kind[:-1]} '{component.id}' is already provided by "
|
||||
f"bundle '{owner}'."
|
||||
)
|
||||
|
||||
return report
|
||||
210
src/specify_cli/bundler/services/installer.py
Normal file
210
src/specify_cli/bundler/services/installer.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Installer: apply an :class:`InstallPlan` via existing primitive machinery.
|
||||
|
||||
The actual component installation (extensions, presets, steps, workflows) is
|
||||
delegated to a :class:`PrimitiveInstaller` so the bundler never re-implements
|
||||
primitive logic (Principle I) and integration tests can inject a deterministic,
|
||||
offline fake (Principle II/IV). The real adapter dispatches in-process to the
|
||||
existing extension/preset/step/workflow machinery.
|
||||
|
||||
Installation is idempotent and stops on first failure with no partial record
|
||||
write (FR-018, SC partial-failure-stop).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from .. import BundlerError
|
||||
from ..models.manifest import BundleManifest, ComponentRef
|
||||
from ..models.records import (
|
||||
InstalledBundleRecord,
|
||||
components_still_needed,
|
||||
find_record,
|
||||
load_records,
|
||||
remove_record,
|
||||
save_records,
|
||||
upsert_record,
|
||||
)
|
||||
from .conflict import detect_conflicts
|
||||
from .resolver import InstallPlan
|
||||
|
||||
|
||||
class PrimitiveInstaller(Protocol):
|
||||
"""Adapter over the existing Spec Kit primitive install/remove machinery."""
|
||||
|
||||
def is_installed(self, project_root: Path, component: ComponentRef) -> bool: ...
|
||||
|
||||
def install(self, project_root: Path, component: ComponentRef) -> None: ...
|
||||
|
||||
def remove(self, project_root: Path, component: ComponentRef) -> None: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallResult:
|
||||
bundle_id: str
|
||||
installed: list[ComponentRef] = field(default_factory=list)
|
||||
skipped: list[ComponentRef] = field(default_factory=list)
|
||||
refreshed: list[ComponentRef] = field(default_factory=list)
|
||||
uninstalled: list[ComponentRef] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.installed or self.refreshed)
|
||||
|
||||
|
||||
def install_bundle(
|
||||
project_root: Path,
|
||||
plan: InstallPlan,
|
||||
installer: PrimitiveInstaller,
|
||||
manifest: BundleManifest | None = None,
|
||||
refresh: bool = False,
|
||||
) -> InstallResult:
|
||||
"""Execute *plan*, recording provenance. Idempotent, with bounded rollback.
|
||||
|
||||
Atomicity is scoped, not global: on failure only the components newly
|
||||
installed during *this* call are rolled back, and the provenance record is
|
||||
written solely on full success (a failure records nothing). Components that
|
||||
were already installed beforehand — including those re-applied when *refresh*
|
||||
is True — are never rolled back.
|
||||
|
||||
When *refresh* is True (used by ``specify bundle update``), components that
|
||||
are already installed are re-applied through the primitive machinery so they
|
||||
are brought up to the plan's pinned versions, rather than skipped. Primitive
|
||||
config (e.g. preset priority overrides) is preserved by the underlying
|
||||
machinery.
|
||||
|
||||
Version-pin enforcement is install-time only. The primitive ``is_installed``
|
||||
checks are id-based (they do not compare versions), so when a component is
|
||||
already present and *refresh* is False it is skipped without verifying that
|
||||
the on-disk version matches the manifest pin. Pins are therefore only
|
||||
guaranteed to be applied when the bundler actually performs an install or a
|
||||
refresh; running ``specify bundle update`` re-applies every owned component
|
||||
at its pinned version.
|
||||
"""
|
||||
records = load_records(project_root)
|
||||
|
||||
if manifest is not None:
|
||||
report = detect_conflicts(manifest, plan.effective_integration, records)
|
||||
if report.has_blocking_conflict:
|
||||
raise BundlerError(report.integration_clash)
|
||||
|
||||
result = InstallResult(bundle_id=plan.bundle_id)
|
||||
existing = find_record(records, plan.bundle_id)
|
||||
prior_ours = {
|
||||
(c.kind, c.id) for c in existing.contributed_components
|
||||
} if existing is not None else set()
|
||||
# Components already attributed to a *different* installed bundle: these are
|
||||
# legitimately shareable (refcounted on removal), so this bundle may also
|
||||
# claim them. A component that is installed on disk but tracked by no bundle
|
||||
# was installed independently and must NOT be attributed here — otherwise
|
||||
# removing this bundle would uninstall it (collateral removal, FR-022).
|
||||
other_tracked = {
|
||||
(c.kind, c.id)
|
||||
for r in records
|
||||
if r.bundle_id != plan.bundle_id
|
||||
for c in r.contributed_components
|
||||
}
|
||||
|
||||
contributed: list[ComponentRef] = []
|
||||
done: list[ComponentRef] = []
|
||||
try:
|
||||
for component in plan.components:
|
||||
key = (component.kind, component.id)
|
||||
if installer.is_installed(project_root, component):
|
||||
# A component is "ours" only when this bundle (or a sibling
|
||||
# bundle) already owns it. Independently-installed components
|
||||
# are never attributed and — crucially — never refreshed, so
|
||||
# ``bundle update`` cannot make collateral changes to things it
|
||||
# does not own (FR-022).
|
||||
owned = key in prior_ours or key in other_tracked
|
||||
if refresh and owned:
|
||||
_refresh_component(project_root, installer, component)
|
||||
result.refreshed.append(component)
|
||||
else:
|
||||
result.skipped.append(component)
|
||||
if owned:
|
||||
contributed.append(component)
|
||||
continue
|
||||
installer.install(project_root, component)
|
||||
done.append(component)
|
||||
result.installed.append(component)
|
||||
contributed.append(component)
|
||||
except BundlerError:
|
||||
_rollback(project_root, installer, done)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_rollback(project_root, installer, done)
|
||||
raise BundlerError(
|
||||
f"Failed to install bundle '{plan.bundle_id}': {exc}. "
|
||||
"No changes were recorded."
|
||||
) from exc
|
||||
|
||||
record = InstalledBundleRecord.create(
|
||||
bundle_id=plan.bundle_id,
|
||||
version=plan.version,
|
||||
components=contributed,
|
||||
# Preserve the original install time across refresh/update so
|
||||
# ``bundle list`` keeps reporting when the bundle was first installed.
|
||||
installed_at=existing.installed_at if existing is not None else None,
|
||||
)
|
||||
save_records(project_root, upsert_record(records, record))
|
||||
return result
|
||||
|
||||
|
||||
def remove_bundle(
|
||||
project_root: Path,
|
||||
bundle_id: str,
|
||||
installer: PrimitiveInstaller,
|
||||
) -> InstallResult:
|
||||
"""Remove a bundle, uninstalling only components no other bundle still needs."""
|
||||
records = load_records(project_root)
|
||||
target = next((r for r in records if r.bundle_id == bundle_id), None)
|
||||
if target is None:
|
||||
raise BundlerError(f"Bundle '{bundle_id}' is not installed.")
|
||||
|
||||
still_needed = components_still_needed(records, exclude_bundle_id=bundle_id)
|
||||
result = InstallResult(bundle_id=bundle_id)
|
||||
|
||||
for component in target.contributed_components:
|
||||
key = (component.kind, component.id)
|
||||
if key in still_needed:
|
||||
result.skipped.append(component)
|
||||
continue
|
||||
if installer.is_installed(project_root, component):
|
||||
installer.remove(project_root, component)
|
||||
result.uninstalled.append(component)
|
||||
else:
|
||||
result.skipped.append(component)
|
||||
|
||||
save_records(project_root, remove_record(records, bundle_id))
|
||||
return result
|
||||
|
||||
|
||||
def _refresh_component(
|
||||
project_root: Path,
|
||||
installer: PrimitiveInstaller,
|
||||
component: ComponentRef,
|
||||
) -> None:
|
||||
"""Re-apply an already-installed component to bring it up to its pinned version.
|
||||
|
||||
Prefers a primitive-provided ``refresh`` hook when available; otherwise falls
|
||||
back to a re-install through the existing idempotent install path.
|
||||
"""
|
||||
op = getattr(installer, "refresh", None)
|
||||
if callable(op):
|
||||
op(project_root, component)
|
||||
else:
|
||||
installer.install(project_root, component)
|
||||
|
||||
|
||||
def _rollback(
|
||||
project_root: Path,
|
||||
installer: PrimitiveInstaller,
|
||||
done: list[ComponentRef],
|
||||
) -> None:
|
||||
for component in reversed(done):
|
||||
try:
|
||||
installer.remove(project_root, component)
|
||||
except Exception: # noqa: BLE001 - best-effort rollback
|
||||
continue
|
||||
145
src/specify_cli/bundler/services/packager.py
Normal file
145
src/specify_cli/bundler/services/packager.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Packager: produce a single versioned distributable artifact from a bundle dir.
|
||||
|
||||
``specify bundle build`` zips the manifest, README, and any local assets into
|
||||
``<id>-<version>.zip``. Build refuses on an invalid manifest, pointing the
|
||||
author to ``validate``. All file reads are confined within the bundle source
|
||||
directory (Principle V path confinement).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import ensure_within
|
||||
from ..models.manifest import BundleManifest
|
||||
from .validator import validate_manifest
|
||||
|
||||
# Files/dirs never included in an artifact.
|
||||
EXCLUDE_NAMES = {".git", "__pycache__", ".DS_Store"}
|
||||
|
||||
# Fixed member timestamp (zip epoch) for reproducible, byte-stable artifacts.
|
||||
_FIXED_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildResult:
|
||||
artifact_path: Path
|
||||
file_count: int
|
||||
|
||||
|
||||
def build_bundle(
|
||||
bundle_dir: Path,
|
||||
output_dir: Path | None = None,
|
||||
) -> BuildResult:
|
||||
bundle_dir = Path(bundle_dir).resolve()
|
||||
manifest_path = bundle_dir / "bundle.yml"
|
||||
if not manifest_path.exists():
|
||||
raise BundlerError(f"No bundle.yml found in '{bundle_dir}'.")
|
||||
|
||||
# The artifact contract requires a human-facing README.md alongside the
|
||||
# manifest; refuse early rather than publish a bundle with no description.
|
||||
if not (bundle_dir / "README.md").exists():
|
||||
raise BundlerError(
|
||||
f"No README.md found in '{bundle_dir}'. Every bundle must ship a "
|
||||
"README.md describing it."
|
||||
)
|
||||
|
||||
manifest = BundleManifest.from_file(manifest_path)
|
||||
report = validate_manifest(manifest)
|
||||
if not report.ok:
|
||||
raise BundlerError(
|
||||
"Refusing to build an invalid manifest. Run 'specify bundle validate' "
|
||||
"and fix:\n - " + "\n - ".join(report.errors)
|
||||
)
|
||||
|
||||
out_dir = Path(output_dir).resolve() if output_dir else bundle_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_name = f"{manifest.bundle.id}-{manifest.bundle.version}.zip"
|
||||
artifact_path = out_dir / artifact_name
|
||||
# Defense in depth: even though validate_manifest() rejects unsafe ids, make
|
||||
# sure a crafted id cannot push the artifact outside the output directory.
|
||||
ensure_within(out_dir, artifact_path)
|
||||
|
||||
# If the output dir lives inside the bundle, skip its whole subtree so
|
||||
# previously-built artifacts are never re-packaged (keeps builds
|
||||
# reproducible and bounded).
|
||||
skip_dir = out_dir if out_dir != bundle_dir and _is_within(bundle_dir, out_dir) else None
|
||||
# Also skip any prior build artifact for this bundle (e.g. an older
|
||||
# <id>-<version>.zip sitting next to bundle.yml), not just the current one.
|
||||
# Match only a semver-looking version segment so legitimate assets that
|
||||
# merely start with the bundle id (e.g. <id>-assets.zip) are still packaged.
|
||||
artifact_re = re.compile(
|
||||
rf"^{re.escape(manifest.bundle.id)}-"
|
||||
r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\.zip$"
|
||||
)
|
||||
files = _collect_files(
|
||||
bundle_dir, skip=artifact_path, skip_dir=skip_dir, artifact_re=artifact_re
|
||||
)
|
||||
with zipfile.ZipFile(artifact_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for file_path in files:
|
||||
# Confinement: every packaged file must live under bundle_dir.
|
||||
ensure_within(bundle_dir, file_path)
|
||||
arcname = file_path.relative_to(bundle_dir).as_posix()
|
||||
# Fixed timestamp so identical inputs yield a byte-for-byte
|
||||
# identical artifact (reproducible builds).
|
||||
info = zipfile.ZipInfo(filename=arcname, date_time=_FIXED_TIMESTAMP)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
# Reproducible, normalized permissions: preserve executability so
|
||||
# bundled scripts (e.g. extension hook scripts) stay runnable after
|
||||
# extraction, but collapse to two canonical modes (0755 when any
|
||||
# execute bit is set on the source, otherwise 0644) so identical
|
||||
# inputs yield a byte-for-byte identical artifact.
|
||||
mode = 0o755 if file_path.stat().st_mode & 0o111 else 0o644
|
||||
info.external_attr = mode << 16
|
||||
archive.writestr(info, file_path.read_bytes())
|
||||
|
||||
return BuildResult(artifact_path=artifact_path, file_count=len(files))
|
||||
|
||||
|
||||
def _is_within(parent: Path, child: Path) -> bool:
|
||||
try:
|
||||
child.relative_to(parent)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _collect_files(
|
||||
bundle_dir: Path,
|
||||
skip: Path,
|
||||
skip_dir: Path | None = None,
|
||||
artifact_re: re.Pattern[str] | None = None,
|
||||
) -> list[Path]:
|
||||
collected: list[Path] = []
|
||||
# followlinks=False so a symlinked directory is never descended into,
|
||||
# which would otherwise pull in out-of-tree files and then fail at
|
||||
# ensure_within(). Symlinked dirs are pruned from traversal explicitly.
|
||||
for root, dirnames, filenames in os.walk(bundle_dir, followlinks=False):
|
||||
root_path = Path(root)
|
||||
# Prune directories we must not descend into (in-place edit of dirnames).
|
||||
dirnames[:] = [
|
||||
d
|
||||
for d in dirnames
|
||||
if d not in EXCLUDE_NAMES and not (root_path / d).is_symlink()
|
||||
]
|
||||
if skip_dir is not None and _is_within(skip_dir, root_path):
|
||||
dirnames[:] = []
|
||||
continue
|
||||
for name in filenames:
|
||||
path = root_path / name
|
||||
if path == skip:
|
||||
continue
|
||||
if name in EXCLUDE_NAMES:
|
||||
continue
|
||||
if artifact_re is not None and artifact_re.match(name):
|
||||
# A prior build artifact for this bundle — never re-package it.
|
||||
continue
|
||||
if path.is_symlink():
|
||||
# Skip symlinked files to avoid escaping the bundle directory.
|
||||
continue
|
||||
collected.append(path)
|
||||
return sorted(collected)
|
||||
345
src/specify_cli/bundler/services/primitives.py
Normal file
345
src/specify_cli/bundler/services/primitives.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""Bridge from bundler component kinds to existing primitive managers.
|
||||
|
||||
The bundler does not own install logic; it routes each component to the
|
||||
existing Spec Kit primitive machinery so a bundle install behaves exactly as a
|
||||
sequence of ``specify <primitive> add`` calls would (Principle I: never
|
||||
reimplement or fake primitive behaviour).
|
||||
|
||||
Routing strategy per kind:
|
||||
|
||||
* **presets** / **extensions** — wired through their reusable managers
|
||||
(``install_from_directory`` / ``install_from_zip``). Bundled assets shipped
|
||||
with Spec Kit install fully offline; catalog assets are fetched only when
|
||||
network access is permitted.
|
||||
* **workflows** / **steps** — their install/remove orchestration lives in the
|
||||
CLI command layer rather than a reusable service method, so the bundler
|
||||
delegates to those existing command callables in-process (with the project
|
||||
root as the working directory) instead of duplicating their download and
|
||||
validation logic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from .. import BundlerError
|
||||
from ..models.manifest import ComponentRef
|
||||
|
||||
DEFAULT_PRIORITY = 10
|
||||
|
||||
|
||||
def _assert_pinned_version(
|
||||
kind: str, component_id: str, pinned: str | None, advertised: object
|
||||
) -> None:
|
||||
"""Refuse to install when the catalog version differs from the manifest pin.
|
||||
|
||||
Bundle manifests pin component versions for reproducibility; installing
|
||||
whatever the active catalog currently serves would silently violate the
|
||||
pin. When the catalog advertises no version we cannot enforce the pin, so
|
||||
installation proceeds (the catalog, not the bundler, owns that gap).
|
||||
"""
|
||||
if not pinned or advertised is None:
|
||||
return
|
||||
actual = str(advertised).strip()
|
||||
if not actual:
|
||||
return
|
||||
from ..lib.versioning import parse_version
|
||||
|
||||
try:
|
||||
matches = parse_version(actual) == parse_version(pinned)
|
||||
except BundlerError:
|
||||
matches = actual == str(pinned).strip()
|
||||
if not matches:
|
||||
raise BundlerError(
|
||||
f"{kind} '{component_id}' is pinned to version {pinned} in the bundle "
|
||||
f"manifest, but the active catalog serves {actual}. Update the bundle's "
|
||||
"pinned version or the catalog before installing."
|
||||
)
|
||||
|
||||
|
||||
class _KindManager(Protocol):
|
||||
def is_installed(self, component: ComponentRef) -> bool: ...
|
||||
|
||||
def install(self, component: ComponentRef) -> None: ...
|
||||
|
||||
def remove(self, component: ComponentRef) -> None: ...
|
||||
|
||||
|
||||
def primitive_manager(
|
||||
kind: str, project_root: Path, *, allow_network: bool = True
|
||||
) -> _KindManager:
|
||||
if kind == "presets":
|
||||
return _PresetKindManager(project_root, allow_network)
|
||||
if kind == "extensions":
|
||||
return _ExtensionKindManager(project_root, allow_network)
|
||||
if kind == "workflows":
|
||||
return _WorkflowKindManager(project_root, allow_network)
|
||||
if kind == "steps":
|
||||
return _StepKindManager(project_root, allow_network)
|
||||
raise BundlerError(f"Unknown component kind '{kind}'.")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _chdir(path: Path):
|
||||
"""Temporarily switch the working directory.
|
||||
|
||||
The delegated workflow/step command callables resolve the project via
|
||||
``Path.cwd()``; this makes that resolution land on *path*.
|
||||
"""
|
||||
previous = Path.cwd()
|
||||
os.chdir(path)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
|
||||
def _delegate_command(action: str, label: str, call) -> None:
|
||||
"""Run a delegated CLI command callable, translating its exit into errors."""
|
||||
import typer
|
||||
|
||||
try:
|
||||
call()
|
||||
except typer.Exit as exc: # raised by the delegated command on failure
|
||||
code = getattr(exc, "exit_code", 0) or 0
|
||||
if code != 0:
|
||||
raise BundlerError(f"Failed to {action} {label}.") from exc
|
||||
except SystemExit as exc: # pragma: no cover - defensive
|
||||
if exc.code not in (0, None):
|
||||
raise BundlerError(f"Failed to {action} {label}.") from exc
|
||||
|
||||
|
||||
class _PresetKindManager:
|
||||
def __init__(self, project_root: Path, allow_network: bool) -> None:
|
||||
from ...presets import PresetManager
|
||||
|
||||
self._root = project_root
|
||||
self._allow_network = allow_network
|
||||
self._manager = PresetManager(project_root)
|
||||
|
||||
def is_installed(self, component: ComponentRef) -> bool:
|
||||
try:
|
||||
return self._manager.get_pack(component.id) is not None
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
from ... import get_speckit_version
|
||||
from ..._assets import _locate_bundled_preset
|
||||
|
||||
speckit_version = get_speckit_version()
|
||||
priority = DEFAULT_PRIORITY if component.priority is None else component.priority
|
||||
|
||||
bundled = _locate_bundled_preset(component.id)
|
||||
if bundled is not None:
|
||||
self._manager.install_from_directory(bundled, speckit_version, priority)
|
||||
return
|
||||
|
||||
if not self._allow_network:
|
||||
raise BundlerError(
|
||||
f"Preset '{component.id}' is not bundled and network access is "
|
||||
f"disabled; re-run without --offline or install it first with "
|
||||
f"'specify preset add {component.id}'."
|
||||
)
|
||||
|
||||
from ...presets import PresetCatalog
|
||||
|
||||
catalog = PresetCatalog(self._root)
|
||||
info = catalog.get_pack_info(component.id)
|
||||
if not info:
|
||||
raise BundlerError(f"Preset '{component.id}' not found in any catalog.")
|
||||
if not info.get("_install_allowed", True):
|
||||
raise BundlerError(
|
||||
f"Preset '{component.id}' is from a discovery-only catalog; "
|
||||
"installation is not allowed."
|
||||
)
|
||||
_assert_pinned_version(
|
||||
"Preset", component.id, component.version, info.get("version")
|
||||
)
|
||||
zip_path = catalog.download_pack(component.id)
|
||||
try:
|
||||
self._manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
def remove(self, component: ComponentRef) -> None:
|
||||
try:
|
||||
self._manager.remove(component.id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise BundlerError(
|
||||
f"Failed to remove preset '{component.id}': {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
class _ExtensionKindManager:
|
||||
def __init__(self, project_root: Path, allow_network: bool) -> None:
|
||||
from ...extensions import ExtensionManager
|
||||
|
||||
self._root = project_root
|
||||
self._allow_network = allow_network
|
||||
self._manager = ExtensionManager(project_root)
|
||||
|
||||
def is_installed(self, component: ComponentRef) -> bool:
|
||||
try:
|
||||
return self._manager.registry.is_installed(component.id)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
from ... import get_speckit_version
|
||||
from ..._assets import _locate_bundled_extension
|
||||
|
||||
speckit_version = get_speckit_version()
|
||||
priority = DEFAULT_PRIORITY if component.priority is None else component.priority
|
||||
|
||||
bundled = _locate_bundled_extension(component.id)
|
||||
if bundled is not None:
|
||||
self._manager.install_from_directory(
|
||||
bundled, speckit_version, priority=priority
|
||||
)
|
||||
return
|
||||
|
||||
if not self._allow_network:
|
||||
raise BundlerError(
|
||||
f"Extension '{component.id}' is not bundled and network access is "
|
||||
f"disabled; re-run without --offline or install it first with "
|
||||
f"'specify extension add {component.id}'."
|
||||
)
|
||||
|
||||
from ...extensions import ExtensionCatalog
|
||||
|
||||
catalog = ExtensionCatalog(self._root)
|
||||
info = catalog.get_extension_info(component.id)
|
||||
if not info:
|
||||
raise BundlerError(
|
||||
f"Extension '{component.id}' not found in any catalog."
|
||||
)
|
||||
if not info.get("_install_allowed", True):
|
||||
raise BundlerError(
|
||||
f"Extension '{component.id}' is from a discovery-only catalog; "
|
||||
"installation is not allowed."
|
||||
)
|
||||
_assert_pinned_version(
|
||||
"Extension", component.id, component.version, info.get("version")
|
||||
)
|
||||
zip_path = catalog.download_extension(component.id)
|
||||
try:
|
||||
self._manager.install_from_zip(
|
||||
zip_path, speckit_version, priority=priority
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
|
||||
def remove(self, component: ComponentRef) -> None:
|
||||
try:
|
||||
self._manager.remove(component.id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise BundlerError(
|
||||
f"Failed to remove extension '{component.id}': {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
class _WorkflowKindManager:
|
||||
def __init__(self, project_root: Path, allow_network: bool) -> None:
|
||||
from ...workflows.catalog import WorkflowRegistry
|
||||
|
||||
self._root = project_root
|
||||
self._allow_network = allow_network
|
||||
self._registry = WorkflowRegistry(project_root)
|
||||
|
||||
def is_installed(self, component: ComponentRef) -> bool:
|
||||
try:
|
||||
return self._registry.is_installed(component.id)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
if not self._allow_network and not self._is_bundled(component.id):
|
||||
raise BundlerError(
|
||||
f"Workflow '{component.id}' installs from a catalog and network "
|
||||
f"access is disabled; re-run without --offline or install it first "
|
||||
f"with 'specify workflow add {component.id}'."
|
||||
)
|
||||
self._assert_pinned_version(component)
|
||||
from ... import workflow_add
|
||||
|
||||
with _chdir(self._root):
|
||||
_delegate_command(
|
||||
"install", f"workflow '{component.id}'",
|
||||
lambda: workflow_add(component.id),
|
||||
)
|
||||
|
||||
def _assert_pinned_version(self, component: ComponentRef) -> None:
|
||||
if not component.version:
|
||||
return
|
||||
try:
|
||||
from ...workflows.catalog import WorkflowCatalog
|
||||
|
||||
info = WorkflowCatalog(self._root).get_workflow_info(component.id)
|
||||
except Exception: # noqa: BLE001 - catalog unreachable: cannot enforce
|
||||
return
|
||||
if info:
|
||||
_assert_pinned_version(
|
||||
"Workflow", component.id, component.version, info.get("version")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_bundled(workflow_id: str) -> bool:
|
||||
# A workflow that ships with Spec Kit installs fully offline.
|
||||
from ..._assets import _locate_bundled_workflow
|
||||
|
||||
return _locate_bundled_workflow(workflow_id) is not None
|
||||
|
||||
def remove(self, component: ComponentRef) -> None:
|
||||
from ... import workflow_remove
|
||||
|
||||
with _chdir(self._root):
|
||||
_delegate_command(
|
||||
"remove", f"workflow '{component.id}'",
|
||||
lambda: workflow_remove(component.id),
|
||||
)
|
||||
|
||||
|
||||
class _StepKindManager:
|
||||
def __init__(self, project_root: Path, allow_network: bool) -> None:
|
||||
from ...workflows.catalog import StepRegistry
|
||||
|
||||
self._root = project_root
|
||||
self._allow_network = allow_network
|
||||
self._registry = StepRegistry(project_root)
|
||||
|
||||
def is_installed(self, component: ComponentRef) -> bool:
|
||||
try:
|
||||
return self._registry.is_installed(component.id)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
if not self._allow_network:
|
||||
raise BundlerError(
|
||||
f"Step '{component.id}' installs from a catalog and network access "
|
||||
f"is disabled; re-run without --offline or install it first with "
|
||||
f"'specify workflow step add {component.id}'."
|
||||
)
|
||||
from ... import workflow_step_add
|
||||
|
||||
with _chdir(self._root):
|
||||
_delegate_command(
|
||||
"install", f"step '{component.id}'",
|
||||
lambda: workflow_step_add(component.id),
|
||||
)
|
||||
|
||||
def remove(self, component: ComponentRef) -> None:
|
||||
from ... import workflow_step_remove
|
||||
|
||||
with _chdir(self._root):
|
||||
_delegate_command(
|
||||
"remove", f"step '{component.id}'",
|
||||
lambda: workflow_step_remove(component.id),
|
||||
)
|
||||
114
src/specify_cli/bundler/services/references.py
Normal file
114
src/specify_cli/bundler/services/references.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Resolve bundle component references against real, available components.
|
||||
|
||||
Used by ``specify bundle validate`` (FR-005 / SC-007) to confirm that every
|
||||
declared component points at something installable. Resolution is offline-first:
|
||||
a reference resolves when the component is bundled with Spec Kit or already
|
||||
installed in the project; catalog sources are consulted only when network access
|
||||
is permitted. Offline runs that cannot confirm a reference downgrade to a
|
||||
warning rather than a false failure, while definitively-unknown references
|
||||
always error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..models.manifest import ComponentRef
|
||||
|
||||
|
||||
def _resolved_locally(root: Path, component: ComponentRef) -> bool:
|
||||
kind = component.kind
|
||||
try:
|
||||
if kind == "presets":
|
||||
from ..._assets import _locate_bundled_preset
|
||||
from ...presets import PresetManager
|
||||
|
||||
if _locate_bundled_preset(component.id) is not None:
|
||||
return True
|
||||
return PresetManager(root).get_pack(component.id) is not None
|
||||
if kind == "extensions":
|
||||
from ..._assets import _locate_bundled_extension
|
||||
from ...extensions import ExtensionManager
|
||||
|
||||
if _locate_bundled_extension(component.id) is not None:
|
||||
return True
|
||||
return ExtensionManager(root).registry.is_installed(component.id)
|
||||
if kind == "workflows":
|
||||
from ..._assets import _locate_bundled_workflow
|
||||
from ...workflows.catalog import WorkflowRegistry
|
||||
|
||||
if _locate_bundled_workflow(component.id) is not None:
|
||||
return True
|
||||
return WorkflowRegistry(root).is_installed(component.id)
|
||||
if kind == "steps":
|
||||
from ...workflows.catalog import StepRegistry
|
||||
|
||||
return StepRegistry(root).is_installed(component.id)
|
||||
except Exception: # noqa: BLE001 - resolution is best-effort
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | None:
|
||||
"""Return True/False if a catalog could be consulted, or None on failure."""
|
||||
kind = component.kind
|
||||
try:
|
||||
if kind == "presets":
|
||||
from ...presets import PresetCatalog
|
||||
|
||||
return PresetCatalog(root).get_pack_info(component.id) is not None
|
||||
if kind == "extensions":
|
||||
from ...extensions import ExtensionCatalog
|
||||
|
||||
return ExtensionCatalog(root).get_extension_info(component.id) is not None
|
||||
if kind == "workflows":
|
||||
from ...workflows.catalog import WorkflowCatalog
|
||||
|
||||
return WorkflowCatalog(root).get_workflow_info(component.id) is not None
|
||||
if kind == "steps":
|
||||
from ...workflows.catalog import StepCatalog
|
||||
|
||||
return StepCatalog(root).get_step_info(component.id) is not None
|
||||
except Exception: # noqa: BLE001 - catalog may be unreachable/misconfigured
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def make_reference_checker(
|
||||
project_root: Path,
|
||||
*,
|
||||
allow_network: bool,
|
||||
warnings: list[str],
|
||||
):
|
||||
"""Build a ``ReferenceChecker`` for :func:`validate_manifest`.
|
||||
|
||||
Returns an error string for a reference that is definitively unresolvable,
|
||||
``None`` otherwise. Unverifiable references (offline, or an unreachable
|
||||
catalog) append a note to *warnings* and pass.
|
||||
"""
|
||||
|
||||
def check(component: ComponentRef) -> str | None:
|
||||
if _resolved_locally(project_root, component):
|
||||
return None
|
||||
|
||||
if allow_network:
|
||||
in_catalog = _resolved_in_catalog(project_root, component)
|
||||
if in_catalog is True:
|
||||
return None
|
||||
if in_catalog is False:
|
||||
return (
|
||||
f"{component.kind[:-1]} '{component.id}' is not bundled, "
|
||||
"installed, or present in any active catalog."
|
||||
)
|
||||
warnings.append(
|
||||
f"Could not verify {component.kind[:-1]} '{component.id}' "
|
||||
"(catalog unreachable); reference left unchecked."
|
||||
)
|
||||
return None
|
||||
|
||||
warnings.append(
|
||||
f"Could not verify {component.kind[:-1]} '{component.id}' offline "
|
||||
"(not bundled or installed); re-run validate online to check catalogs."
|
||||
)
|
||||
return None
|
||||
|
||||
return check
|
||||
122
src/specify_cli/bundler/services/resolver.py
Normal file
122
src/specify_cli/bundler/services/resolver.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Resolver: expand a bundle manifest into a concrete, ordered install plan.
|
||||
|
||||
The plan the resolver produces is the single source of truth shared by
|
||||
``info`` (preview) and ``install`` (execution) so the two never diverge
|
||||
(SC-002 transparency). Resolution also enforces the SpecKit version gate
|
||||
(FR-016) and the integration-compatibility check (FR-019).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .. import BundlerError
|
||||
from ..lib.versioning import satisfies
|
||||
from ..models.manifest import BundleManifest, ComponentRef
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallPlan:
|
||||
bundle_id: str
|
||||
version: str
|
||||
role: str
|
||||
effective_integration: str | None
|
||||
components: list[ComponentRef] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def component_count(self) -> int:
|
||||
return len(self.components)
|
||||
|
||||
def grouped(self) -> dict[str, list[ComponentRef]]:
|
||||
groups: dict[str, list[ComponentRef]] = {
|
||||
"extensions": [],
|
||||
"presets": [],
|
||||
"steps": [],
|
||||
"workflows": [],
|
||||
}
|
||||
for component in self.components:
|
||||
groups.setdefault(component.kind, []).append(component)
|
||||
return groups
|
||||
|
||||
|
||||
def resolve_install_plan(
|
||||
manifest: BundleManifest,
|
||||
*,
|
||||
speckit_version: str,
|
||||
active_integration: str | None,
|
||||
integration_explicit: bool = False,
|
||||
enforce_version: bool = True,
|
||||
) -> InstallPlan:
|
||||
"""Expand *manifest* into an :class:`InstallPlan`, enforcing gates.
|
||||
|
||||
Raises :class:`BundlerError` when a hard gate fails (version gate,
|
||||
integration clash). Soft issues are collected in ``plan.warnings``.
|
||||
|
||||
*integration_explicit* signals that ``active_integration`` came from an
|
||||
explicit ``--integration`` override rather than project auto-detection. When
|
||||
a bundle pins an integration but the project's active integration cannot be
|
||||
determined (``active_integration is None``) and the caller did not supply an
|
||||
explicit override, resolution fails instead of silently adopting the
|
||||
bundle's required integration (FR-019 guard).
|
||||
"""
|
||||
structural = manifest.structural_errors()
|
||||
if structural:
|
||||
raise BundlerError(
|
||||
"Cannot resolve an invalid manifest:\n - " + "\n - ".join(structural)
|
||||
)
|
||||
|
||||
# FR-016: SpecKit version gate — refuse incompatible installs.
|
||||
if enforce_version and manifest.requires.speckit_version:
|
||||
if not satisfies(speckit_version, manifest.requires.speckit_version):
|
||||
raise BundlerError(
|
||||
f"Bundle '{manifest.bundle.id}' requires Spec Kit "
|
||||
f"{manifest.requires.speckit_version}, but this project uses "
|
||||
f"{speckit_version}. Update Spec Kit or choose a compatible bundle."
|
||||
)
|
||||
|
||||
# FR-019: integration-compatibility — a bundle that pins a different
|
||||
# integration than the project's active one halts (no silent change).
|
||||
effective_integration = active_integration
|
||||
if manifest.integration is not None:
|
||||
required = manifest.integration.id
|
||||
if active_integration and required != active_integration:
|
||||
raise BundlerError(
|
||||
f"Bundle '{manifest.bundle.id}' targets integration '{required}', "
|
||||
f"but this project's active integration is '{active_integration}'. "
|
||||
"Installing it would conflict; aborting with no changes."
|
||||
)
|
||||
if active_integration is None and not integration_explicit:
|
||||
raise BundlerError(
|
||||
f"Bundle '{manifest.bundle.id}' targets integration '{required}', "
|
||||
"but this project's active integration could not be determined "
|
||||
"(missing or unreadable .specify/integration.json). Re-run with "
|
||||
"'--integration' to confirm the target, or repair the project "
|
||||
"before installing."
|
||||
)
|
||||
effective_integration = required
|
||||
|
||||
warnings: list[str] = []
|
||||
if manifest.requires.tools:
|
||||
warnings.append(
|
||||
"Requires external tools: " + ", ".join(manifest.requires.tools)
|
||||
)
|
||||
if manifest.requires.mcp:
|
||||
warnings.append("Requires MCP servers: " + ", ".join(manifest.requires.mcp))
|
||||
|
||||
return InstallPlan(
|
||||
bundle_id=manifest.bundle.id,
|
||||
version=manifest.bundle.version,
|
||||
role=manifest.bundle.role,
|
||||
effective_integration=effective_integration,
|
||||
components=list(manifest.components),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def load_manifest_from_dir(bundle_dir: Path) -> BundleManifest:
|
||||
"""Load ``bundle.yml`` from a bundle directory."""
|
||||
manifest_path = Path(bundle_dir) / "bundle.yml"
|
||||
if not manifest_path.exists():
|
||||
raise BundlerError(f"No bundle.yml found in '{bundle_dir}'.")
|
||||
return BundleManifest.from_file(manifest_path)
|
||||
60
src/specify_cli/bundler/services/validator.py
Normal file
60
src/specify_cli/bundler/services/validator.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Validator: structural + reference validation for a bundle manifest.
|
||||
|
||||
``specify bundle validate`` reports whether a manifest is well-formed and all
|
||||
component references are resolvable. Structural checks come from the manifest
|
||||
model; reference resolution is optional (requires a resolver callback) so the
|
||||
command can run fully offline against pinned/local references.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from .. import BundlerError
|
||||
from ..lib.versioning import parse_constraint
|
||||
from ..models.manifest import BundleManifest, ComponentRef
|
||||
|
||||
# A reference checker returns None when resolvable, or an error string.
|
||||
ReferenceChecker = Callable[[ComponentRef], str | None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
def merge(self, other: "ValidationReport") -> None:
|
||||
self.errors.extend(other.errors)
|
||||
self.warnings.extend(other.warnings)
|
||||
|
||||
|
||||
def validate_manifest(
|
||||
manifest: BundleManifest,
|
||||
reference_checker: ReferenceChecker | None = None,
|
||||
) -> ValidationReport:
|
||||
report = ValidationReport()
|
||||
|
||||
report.errors.extend(manifest.structural_errors())
|
||||
|
||||
if manifest.requires.speckit_version:
|
||||
try:
|
||||
parse_constraint(manifest.requires.speckit_version)
|
||||
except BundlerError as exc:
|
||||
report.errors.append(
|
||||
f"requires.speckit_version '{manifest.requires.speckit_version}' "
|
||||
f"is not a valid constraint: {exc}"
|
||||
)
|
||||
|
||||
if reference_checker is not None:
|
||||
for component in manifest.components:
|
||||
problem = reference_checker(component)
|
||||
if problem:
|
||||
report.errors.append(
|
||||
f"Unresolved reference {component.label()}: {problem}"
|
||||
)
|
||||
|
||||
return report
|
||||
834
src/specify_cli/commands/bundle/__init__.py
Normal file
834
src/specify_cli/commands/bundle/__init__.py
Normal file
@@ -0,0 +1,834 @@
|
||||
"""``specify bundle`` command group — discover, install, author Spec Kit bundles.
|
||||
|
||||
This module is the CLI/UX layer only (Principle I: thin commands over services).
|
||||
Each command resolves a project, builds a catalog stack, delegates to a bundler
|
||||
service, and renders Rich output. ``--json`` emits machine-readable data on
|
||||
stdout; human logs go to stderr/console.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from ..._console import console
|
||||
from ...bundler import BundlerError
|
||||
from ...bundler.lib.project import (
|
||||
active_integration,
|
||||
find_project_root,
|
||||
require_project_root,
|
||||
)
|
||||
from ...bundler.models.records import load_records
|
||||
|
||||
bundle_app = typer.Typer(
|
||||
name="bundle",
|
||||
help="Discover, install, and author Spec Kit bundles",
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
bundle_catalog_app = typer.Typer(
|
||||
name="catalog",
|
||||
help="Manage bundle catalog sources",
|
||||
add_completion=False,
|
||||
)
|
||||
bundle_app.add_typer(bundle_catalog_app, name="catalog")
|
||||
|
||||
|
||||
# ===== helpers =====
|
||||
|
||||
|
||||
def _fail(message: str) -> None:
|
||||
"""Print an actionable error to stderr and exit non-zero."""
|
||||
console.print(f"[red]Error:[/red] {message}", style=None)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def _user_config_dir() -> Path:
|
||||
# User-scope Spec Kit config lives under ~/.specify (same convention as
|
||||
# auth.json, extension/preset catalogs). Passing this through to the source
|
||||
# stack is what makes the documented project > user > built-in precedence
|
||||
# reachable from the CLI.
|
||||
return Path.home() / ".specify"
|
||||
|
||||
|
||||
def _build_stack(project_root: Path, *, offline: bool):
|
||||
from ...bundler.services.adapters import make_catalog_fetcher
|
||||
from ...bundler.services.catalog_stack import CatalogStack
|
||||
|
||||
fetcher = make_catalog_fetcher(allow_network=not offline)
|
||||
return CatalogStack.load(project_root, fetcher, user_config_dir=_user_config_dir())
|
||||
|
||||
|
||||
def _speckit_version() -> str:
|
||||
from ..._assets import get_speckit_version
|
||||
|
||||
return get_speckit_version()
|
||||
|
||||
|
||||
def _trust_level(verified: bool) -> str:
|
||||
"""Trust framing for a catalog entry (FR-010): org-curated vs community."""
|
||||
return "verified" if verified else "community"
|
||||
|
||||
|
||||
def _trust_badge(verified: bool) -> str:
|
||||
return (
|
||||
"[green]✔ verified[/green]"
|
||||
if verified
|
||||
else "[yellow]community[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
def _default_script_type() -> str:
|
||||
"""OS-appropriate default script flavor (FR-013)."""
|
||||
import os
|
||||
|
||||
return "ps" if os.name == "nt" else "sh"
|
||||
|
||||
|
||||
def _run_init(integration: str, *, script_type: str, offline: bool = False) -> None:
|
||||
"""Idempotently scaffold a Spec Kit project here via the existing ``init`` machinery.
|
||||
|
||||
Reuses the real ``specify init`` command callback in-process (Principle I)
|
||||
with ``--here --force`` so it is non-interactive and merges into the current
|
||||
directory.
|
||||
"""
|
||||
from ... import app
|
||||
|
||||
init_cb = next(
|
||||
c.callback
|
||||
for c in app.registered_commands
|
||||
if c.callback and c.callback.__name__ == "init"
|
||||
)
|
||||
try:
|
||||
init_cb(
|
||||
project_name=None,
|
||||
script_type=script_type,
|
||||
ignore_agent_tools=True,
|
||||
here=True,
|
||||
force=True,
|
||||
skip_tls=False,
|
||||
debug=False,
|
||||
github_token=None,
|
||||
offline=offline,
|
||||
preset=None,
|
||||
integration=integration,
|
||||
integration_options=None,
|
||||
)
|
||||
except typer.Exit as exc:
|
||||
if exc.exit_code:
|
||||
raise BundlerError(
|
||||
f"Failed to initialize a Spec Kit project (integration '{integration}')."
|
||||
) from exc
|
||||
|
||||
|
||||
def _resolve_init_integration(override: str | None, manifest) -> str:
|
||||
"""Precedence (FR-013): explicit override → bundle-declared → default."""
|
||||
from ..._agent_config import DEFAULT_INIT_INTEGRATION
|
||||
|
||||
if override:
|
||||
return override
|
||||
if manifest is not None and manifest.integration is not None:
|
||||
return manifest.integration.id
|
||||
return DEFAULT_INIT_INTEGRATION
|
||||
|
||||
|
||||
# ===== Consume =====
|
||||
|
||||
|
||||
@bundle_app.command("search")
|
||||
def bundle_search(
|
||||
query: str = typer.Argument("", help="Optional text query"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Do not access the network"),
|
||||
as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"),
|
||||
) -> None:
|
||||
"""List matching bundles across the active catalog stack."""
|
||||
try:
|
||||
project_root = find_project_root() or Path.cwd()
|
||||
stack = _build_stack(project_root, offline=offline)
|
||||
results = stack.search(query)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
if as_json:
|
||||
payload = [
|
||||
{
|
||||
"id": r.entry.id,
|
||||
"name": r.entry.name,
|
||||
"role": r.entry.role,
|
||||
"version": r.entry.version,
|
||||
"description": r.entry.description,
|
||||
"source": r.source.id,
|
||||
"install_policy": r.source.install_policy.value,
|
||||
"verified": r.entry.verified,
|
||||
"trust": _trust_level(r.entry.verified),
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
print(_json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
if not results:
|
||||
console.print("[yellow]No matching bundles found.[/yellow]")
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Bundles:[/bold cyan]\n")
|
||||
for r in results:
|
||||
policy = (
|
||||
"[dim](discovery-only)[/dim]"
|
||||
if not r.source.install_allowed
|
||||
else ""
|
||||
)
|
||||
console.print(
|
||||
f" [bold]{r.entry.id}[/bold] v{r.entry.version} — {r.entry.name} "
|
||||
f"[dim]({r.entry.role})[/dim] {_trust_badge(r.entry.verified)} {policy}"
|
||||
)
|
||||
console.print(f" {r.entry.description}")
|
||||
console.print(f" [dim]source: {r.source.id}[/dim]")
|
||||
|
||||
|
||||
@bundle_app.command("info")
|
||||
def bundle_info(
|
||||
bundle_id: str = typer.Argument(..., help="Bundle id to inspect"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Do not access the network"),
|
||||
as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"),
|
||||
) -> None:
|
||||
"""Show full metadata and the fully expanded component set (== what install adds)."""
|
||||
try:
|
||||
project_root = find_project_root() or Path.cwd()
|
||||
stack = _build_stack(project_root, offline=offline)
|
||||
resolved = stack.resolve(bundle_id)
|
||||
# `info` must show the fully expanded component set that `install` would
|
||||
# apply (contracts/cli-commands.md). Expansion happens regardless of
|
||||
# install policy — discovery-only bundles stay inspectable; only
|
||||
# `install` is refused. But if the manifest itself can't be resolved
|
||||
# (e.g. --offline against an https:// download_url, or a download
|
||||
# failure), fail loudly and exit non-zero rather than silently
|
||||
# degrading to catalog `provides` counts, so users never mistake an
|
||||
# unverifiable bundle for a known/installable one.
|
||||
manifest = _download_manifest(resolved, offline=offline)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
overlaps = _bundle_overlaps(project_root, manifest, offline=offline)
|
||||
components = _manifest_component_view(manifest)
|
||||
|
||||
entry = resolved.entry
|
||||
if as_json:
|
||||
payload = {
|
||||
"id": entry.id,
|
||||
"name": entry.name,
|
||||
"version": entry.version,
|
||||
"role": entry.role,
|
||||
"description": entry.description,
|
||||
"author": entry.author,
|
||||
"license": entry.license,
|
||||
"source": resolved.source.id,
|
||||
"install_policy": resolved.source.install_policy.value,
|
||||
"provides": entry.provides,
|
||||
"requires": {"speckit_version": entry.requires_speckit_version},
|
||||
"verified": entry.verified,
|
||||
"trust": _trust_level(entry.verified),
|
||||
"integration": (manifest.integration.id if manifest and manifest.integration else None),
|
||||
"components": components,
|
||||
"overlaps": overlaps,
|
||||
}
|
||||
print(_json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
console.print(f"\n[bold cyan]{entry.id}[/bold cyan] v{entry.version} — {entry.name}")
|
||||
console.print(f" Role: {entry.role}")
|
||||
console.print(f" {entry.description}")
|
||||
console.print(f" Author: {entry.author} License: {entry.license}")
|
||||
console.print(f" Source: {resolved.source.id} ({resolved.source.install_policy.value})")
|
||||
console.print(f" Trust: {_trust_badge(entry.verified)}")
|
||||
if entry.requires_speckit_version:
|
||||
console.print(f" Requires Spec Kit: {entry.requires_speckit_version}")
|
||||
if manifest and manifest.integration:
|
||||
console.print(f" Integration: {manifest.integration.id}")
|
||||
|
||||
if components:
|
||||
console.print("\n [bold]Components[/bold] (added on install):")
|
||||
for kind in ("extensions", "presets", "steps", "workflows"):
|
||||
items = [c for c in components if c["kind"] == kind]
|
||||
if not items:
|
||||
continue
|
||||
console.print(f" [bold]{kind}:[/bold]")
|
||||
for item in items:
|
||||
console.print(f" - {_format_component(item)}")
|
||||
else:
|
||||
console.print("\n [bold]Provides:[/bold]")
|
||||
for kind in ("extensions", "presets", "steps", "workflows"):
|
||||
count = entry.provides.get(kind, 0)
|
||||
if count:
|
||||
console.print(f" {kind}: {count}")
|
||||
|
||||
if overlaps:
|
||||
console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]")
|
||||
for overlap in overlaps:
|
||||
console.print(f" [yellow]-[/yellow] {overlap}")
|
||||
|
||||
if not resolved.install_allowed:
|
||||
console.print(
|
||||
"\n [yellow]This source is discovery-only; the bundle cannot be "
|
||||
"installed from here.[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
@bundle_app.command("list")
|
||||
def bundle_list(
|
||||
as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"),
|
||||
) -> None:
|
||||
"""List bundles currently installed in the project with versions."""
|
||||
try:
|
||||
project_root = require_project_root()
|
||||
records = load_records(project_root)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
if as_json:
|
||||
print(_json.dumps([r.to_dict() for r in records], indent=2))
|
||||
return
|
||||
|
||||
if not records:
|
||||
console.print("[yellow]No bundles installed.[/yellow]")
|
||||
console.print("\nInstall one with: [cyan]specify bundle install <id>[/cyan]")
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n")
|
||||
for record in records:
|
||||
console.print(
|
||||
f" [bold]{record.bundle_id}[/bold] v{record.version} "
|
||||
f"[dim]({len(record.contributed_components)} components, "
|
||||
f"installed {record.installed_at})[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@bundle_app.command("install")
|
||||
def bundle_install(
|
||||
bundle_id: str = typer.Argument(
|
||||
...,
|
||||
help="Bundle id (from the catalog stack) or a local path to a .zip "
|
||||
"artifact, bundle directory, or bundle.yml",
|
||||
),
|
||||
integration: str = typer.Option(None, "--integration", help="Override integration"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Do not access the network"),
|
||||
) -> None:
|
||||
"""Install a bundle's full component set through each primitive's machinery.
|
||||
|
||||
``bundle_id`` may be a catalog bundle id, or a local path to a built
|
||||
artifact (``.zip``), a bundle directory, or a ``bundle.yml`` file. Local
|
||||
sources install directly without consulting the catalog stack.
|
||||
"""
|
||||
try:
|
||||
from ...bundler.lib.project import find_project_root
|
||||
from ...bundler.services.adapters import DefaultPrimitiveInstaller
|
||||
from ...bundler.services.installer import install_bundle
|
||||
from ...bundler.services.resolver import resolve_install_plan
|
||||
|
||||
project_root = find_project_root()
|
||||
|
||||
local_manifest = _local_manifest_source(bundle_id)
|
||||
if local_manifest is not None:
|
||||
manifest = local_manifest
|
||||
else:
|
||||
stack = _build_stack(project_root or Path.cwd(), offline=offline)
|
||||
resolved = stack.resolve(bundle_id)
|
||||
|
||||
if not resolved.install_allowed:
|
||||
raise BundlerError(
|
||||
f"Bundle '{bundle_id}' resolves only from a discovery-only source "
|
||||
f"('{resolved.source.id}'); it cannot be installed from there."
|
||||
)
|
||||
manifest = _download_manifest(resolved, offline=offline)
|
||||
|
||||
if project_root is None:
|
||||
init_integration = _resolve_init_integration(integration, manifest)
|
||||
console.print(
|
||||
f"[cyan]No Spec Kit project here; initializing with integration "
|
||||
f"'{init_integration}'…[/cyan]"
|
||||
)
|
||||
_run_init(init_integration, script_type=_default_script_type(), offline=offline)
|
||||
project_root = require_project_root()
|
||||
|
||||
for overlap in _bundle_overlaps(project_root, manifest, offline=offline):
|
||||
console.print(f"[yellow]![/yellow] {overlap}")
|
||||
|
||||
# For an already-initialized project, the project's recorded active
|
||||
# integration is authoritative — an explicit --integration must not be
|
||||
# able to bypass the FR-019 integration-clash guard. The override only
|
||||
# selects the integration at init time (handled above) or confirms the
|
||||
# target when the active integration cannot be determined.
|
||||
detected = active_integration(project_root)
|
||||
plan = resolve_install_plan(
|
||||
manifest,
|
||||
speckit_version=_speckit_version(),
|
||||
active_integration=detected if detected is not None else integration,
|
||||
integration_explicit=bool(integration) and detected is None,
|
||||
)
|
||||
for warning in plan.warnings:
|
||||
console.print(f"[yellow]![/yellow] {warning}")
|
||||
|
||||
result = install_bundle(
|
||||
project_root,
|
||||
plan,
|
||||
DefaultPrimitiveInstaller(allow_network=not offline),
|
||||
manifest=manifest,
|
||||
)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Installed '{result.bundle_id}' "
|
||||
f"({len(result.installed)} added, {len(result.skipped)} already present)."
|
||||
)
|
||||
|
||||
|
||||
@bundle_app.command("update")
|
||||
def bundle_update(
|
||||
bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"),
|
||||
all_bundles: bool = typer.Option(False, "--all", help="Update every installed bundle"),
|
||||
integration: str = typer.Option(None, "--integration", help="Override integration"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Do not access the network"),
|
||||
) -> None:
|
||||
"""Re-resolve and refresh a bundle's components via each primitive's update path."""
|
||||
try:
|
||||
project_root = require_project_root()
|
||||
records = load_records(project_root)
|
||||
if not all_bundles and not bundle_id:
|
||||
raise BundlerError("Specify a bundle id or use --all.")
|
||||
targets = (
|
||||
[r.bundle_id for r in records]
|
||||
if all_bundles
|
||||
else [bundle_id]
|
||||
)
|
||||
if not targets:
|
||||
console.print("[yellow]No installed bundles to update.[/yellow]")
|
||||
return
|
||||
|
||||
stack = _build_stack(project_root, offline=offline)
|
||||
from ...bundler.services.adapters import DefaultPrimitiveInstaller
|
||||
from ...bundler.services.installer import install_bundle
|
||||
from ...bundler.services.resolver import resolve_install_plan
|
||||
|
||||
installer = DefaultPrimitiveInstaller(allow_network=not offline)
|
||||
for target in targets:
|
||||
if not any(r.bundle_id == target for r in records):
|
||||
raise BundlerError(f"Bundle '{target}' is not installed.")
|
||||
resolved = stack.resolve(target)
|
||||
if not resolved.install_allowed:
|
||||
raise BundlerError(
|
||||
f"Bundle '{target}' resolves only from a discovery-only source "
|
||||
f"('{resolved.source.id}'); it cannot be updated from there. "
|
||||
"Update requires an install-allowed source (FR-025)."
|
||||
)
|
||||
manifest = _download_manifest(resolved, offline=offline)
|
||||
detected = active_integration(project_root)
|
||||
plan = resolve_install_plan(
|
||||
manifest,
|
||||
speckit_version=_speckit_version(),
|
||||
active_integration=detected if detected is not None else integration,
|
||||
integration_explicit=bool(integration) and detected is None,
|
||||
)
|
||||
install_bundle(project_root, plan, installer, manifest=manifest, refresh=True)
|
||||
console.print(f"[green]✓[/green] Updated '{target}' to v{plan.version}.")
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
|
||||
@bundle_app.command("remove")
|
||||
def bundle_remove(
|
||||
bundle_id: str = typer.Argument(..., help="Installed bundle id to remove"),
|
||||
) -> None:
|
||||
"""Uninstall only the components this bundle contributed (no collateral removals)."""
|
||||
try:
|
||||
project_root = require_project_root()
|
||||
from ...bundler.services.adapters import DefaultPrimitiveInstaller
|
||||
from ...bundler.services.installer import remove_bundle
|
||||
|
||||
result = remove_bundle(project_root, bundle_id, DefaultPrimitiveInstaller())
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Removed '{result.bundle_id}' "
|
||||
f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)."
|
||||
)
|
||||
|
||||
|
||||
# ===== Author =====
|
||||
|
||||
|
||||
@bundle_app.command("validate")
|
||||
def bundle_validate(
|
||||
path: Path = typer.Option(
|
||||
None, "--path", help="Bundle directory or bundle.yml (default: cwd)"
|
||||
),
|
||||
offline: bool = typer.Option(
|
||||
False,
|
||||
"--offline",
|
||||
help="Do not access catalogs; verify references against bundled/installed only",
|
||||
),
|
||||
) -> None:
|
||||
"""Report whether the manifest is well-formed and references resolve."""
|
||||
try:
|
||||
manifest_path = _resolve_manifest_path(path)
|
||||
from ...bundler.lib.project import find_project_root
|
||||
from ...bundler.models.manifest import BundleManifest
|
||||
from ...bundler.services.references import make_reference_checker
|
||||
from ...bundler.services.validator import validate_manifest
|
||||
|
||||
manifest = BundleManifest.from_file(manifest_path)
|
||||
ref_root = find_project_root(manifest_path.parent) or Path.cwd()
|
||||
ref_warnings: list[str] = []
|
||||
checker = make_reference_checker(
|
||||
ref_root, allow_network=not offline, warnings=ref_warnings
|
||||
)
|
||||
report = validate_manifest(manifest, reference_checker=checker)
|
||||
report.warnings.extend(ref_warnings)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
for warning in report.warnings:
|
||||
console.print(f"[yellow]![/yellow] {warning}")
|
||||
if not report.ok:
|
||||
console.print("[red]Manifest is invalid:[/red]")
|
||||
for error in report.errors:
|
||||
console.print(f" [red]-[/red] {error}")
|
||||
raise typer.Exit(code=1)
|
||||
console.print(f"[green]✓[/green] {manifest.bundle.id} is well-formed and valid.")
|
||||
|
||||
|
||||
@bundle_app.command("build")
|
||||
def bundle_build(
|
||||
path: Path = typer.Option(
|
||||
None, "--path", help="Bundle directory (default: cwd)"
|
||||
),
|
||||
output: Path = typer.Option(None, "--output", help="Output directory for the artifact"),
|
||||
) -> None:
|
||||
"""Produce a single versioned distributable artifact (.zip)."""
|
||||
try:
|
||||
bundle_dir = (path or Path.cwd()).resolve()
|
||||
if bundle_dir.is_file():
|
||||
bundle_dir = bundle_dir.parent
|
||||
from ...bundler.services.packager import build_bundle
|
||||
|
||||
result = build_bundle(bundle_dir, output_dir=output)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Built {result.artifact_path.name} "
|
||||
f"({result.file_count} files) → {result.artifact_path}"
|
||||
)
|
||||
|
||||
|
||||
@bundle_app.command("init")
|
||||
def bundle_init(
|
||||
bundle: str = typer.Argument(None, help="Optional bundle to install after init"),
|
||||
integration: str = typer.Option(None, "--integration", help="Integration override"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Do not access the network"),
|
||||
) -> None:
|
||||
"""Ensure the project is initialized (idempotent), then optionally install a bundle."""
|
||||
from ...bundler.lib.project import find_project_root
|
||||
|
||||
try:
|
||||
project_root = find_project_root()
|
||||
if project_root is None:
|
||||
init_integration = _resolve_init_integration(integration, None)
|
||||
console.print(
|
||||
f"[cyan]Initializing a Spec Kit project with integration "
|
||||
f"'{init_integration}'…[/cyan]"
|
||||
)
|
||||
_run_init(init_integration, script_type=_default_script_type(), offline=offline)
|
||||
project_root = require_project_root()
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(f"[green]✓[/green] Spec Kit project ready at {project_root}.")
|
||||
if bundle:
|
||||
bundle_install(bundle, integration=integration, offline=offline)
|
||||
|
||||
|
||||
# ===== Catalog management =====
|
||||
|
||||
|
||||
@bundle_catalog_app.command("list")
|
||||
def catalog_list() -> None:
|
||||
"""Print the active, priority-ordered catalog stack with scope and policy."""
|
||||
try:
|
||||
project_root = require_project_root()
|
||||
from ...bundler.models.catalog import Scope, load_source_stack
|
||||
|
||||
sources = load_source_stack(project_root, user_config_dir=_user_config_dir())
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Catalog stack[/bold cyan] (highest precedence first):\n")
|
||||
only_builtin = all(s.scope == Scope.BUILTIN for s in sources)
|
||||
for source in sources:
|
||||
console.print(
|
||||
f" [bold]{source.id}[/bold] priority={source.priority} "
|
||||
f"policy={source.install_policy.value} scope={source.scope.value}"
|
||||
)
|
||||
console.print(f" [dim]{source.url}[/dim]")
|
||||
if only_builtin:
|
||||
console.print("\n[dim]Using the built-in default stack.[/dim]")
|
||||
|
||||
|
||||
@bundle_catalog_app.command("add")
|
||||
def catalog_add(
|
||||
url: str = typer.Argument(..., help="Catalog URL"),
|
||||
policy: str = typer.Option(
|
||||
"install-allowed", "--policy", help="install-allowed | discovery-only"
|
||||
),
|
||||
priority: int = typer.Option(10, "--priority", help="Source priority (lower = higher)"),
|
||||
source_id: str = typer.Option(None, "--id", help="Explicit source id"),
|
||||
) -> None:
|
||||
"""Register a project-scoped catalog source and persist it."""
|
||||
try:
|
||||
project_root = require_project_root()
|
||||
from ...bundler.commands_impl.catalog_config import add_source
|
||||
|
||||
source = add_source(project_root, url, policy=policy, priority=priority, source_id=source_id)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Added catalog '{source.id}' "
|
||||
f"(priority {source.priority}, {source.install_policy.value})."
|
||||
)
|
||||
|
||||
|
||||
@bundle_catalog_app.command("remove")
|
||||
def catalog_remove(
|
||||
id_or_url: str = typer.Argument(..., help="Source id or url to remove"),
|
||||
) -> None:
|
||||
"""Remove a project-scoped catalog source (built-in defaults can't be deleted)."""
|
||||
try:
|
||||
project_root = require_project_root()
|
||||
from ...bundler.commands_impl.catalog_config import remove_source
|
||||
|
||||
removed = remove_source(project_root, id_or_url)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(f"[green]✓[/green] Removed catalog source '{removed}'.")
|
||||
|
||||
|
||||
# ===== internal helpers =====
|
||||
|
||||
|
||||
def _manifest_component_view(manifest) -> list[dict]:
|
||||
"""Flatten a manifest's components to JSON-friendly dicts (id, version, ...)."""
|
||||
if manifest is None:
|
||||
return []
|
||||
view: list[dict] = []
|
||||
for component in manifest.components:
|
||||
item = {
|
||||
"kind": component.kind,
|
||||
"id": component.id,
|
||||
"version": component.version,
|
||||
}
|
||||
if component.priority is not None:
|
||||
item["priority"] = component.priority
|
||||
if component.strategy is not None:
|
||||
item["strategy"] = component.strategy
|
||||
view.append(item)
|
||||
return view
|
||||
|
||||
|
||||
def _format_component(item: dict) -> str:
|
||||
label = f"{item['id']} v{item['version']}" if item.get("version") else item["id"]
|
||||
extras = []
|
||||
if item.get("priority") is not None:
|
||||
extras.append(f"priority={item['priority']}")
|
||||
if item.get("strategy") is not None:
|
||||
extras.append(f"strategy={item['strategy']}")
|
||||
if extras:
|
||||
label += f" ({', '.join(extras)})"
|
||||
return label
|
||||
|
||||
|
||||
def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]:
|
||||
"""Return informational overlaps between *manifest* and installed bundles."""
|
||||
if manifest is None:
|
||||
return []
|
||||
try:
|
||||
from ...bundler.services.conflict import detect_conflicts
|
||||
|
||||
report = detect_conflicts(
|
||||
manifest,
|
||||
active_integration(project_root),
|
||||
load_records(project_root),
|
||||
)
|
||||
return list(report.overlaps)
|
||||
except BundlerError:
|
||||
return []
|
||||
|
||||
|
||||
def _local_manifest_source(arg: str):
|
||||
"""Return a :class:`BundleManifest` if *arg* points at a local bundle.
|
||||
|
||||
Supports a built ``.zip`` artifact, a bundle directory, or a ``bundle.yml``
|
||||
file. Returns ``None`` when *arg* is not an existing path, so callers fall
|
||||
back to catalog-stack resolution by bundle id.
|
||||
"""
|
||||
from ...bundler.models.manifest import BundleManifest
|
||||
|
||||
candidate = Path(arg).expanduser()
|
||||
if not candidate.exists():
|
||||
return None
|
||||
|
||||
if candidate.is_dir():
|
||||
manifest_path = candidate / "bundle.yml"
|
||||
if not manifest_path.exists():
|
||||
raise BundlerError(f"No bundle.yml found in '{candidate}'.")
|
||||
return BundleManifest.from_file(manifest_path)
|
||||
|
||||
if candidate.suffix == ".zip":
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import yaml as _yaml
|
||||
|
||||
with zipfile.ZipFile(candidate) as archive:
|
||||
try:
|
||||
raw = archive.read("bundle.yml")
|
||||
except KeyError as exc:
|
||||
raise BundlerError(
|
||||
f"Artifact '{candidate}' does not contain a bundle.yml."
|
||||
) from exc
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"):
|
||||
return BundleManifest.from_file(candidate)
|
||||
|
||||
raise BundlerError(
|
||||
f"'{candidate}' is not a recognised bundle source (.zip artifact, bundle "
|
||||
"directory, or bundle.yml)."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_manifest_path(path: Path | None) -> Path:
|
||||
target = (path or Path.cwd()).resolve()
|
||||
if target.is_dir():
|
||||
target = target / "bundle.yml"
|
||||
if not target.exists():
|
||||
raise BundlerError(f"No bundle.yml found at '{target}'.")
|
||||
return target
|
||||
|
||||
|
||||
def _download_manifest(resolved, *, offline: bool):
|
||||
"""Resolve a bundle's manifest from its catalog ``download_url``.
|
||||
|
||||
Local/``file://`` URLs always work offline and may point at a ``.zip``
|
||||
artifact, a bundle directory, or a ``bundle.yml`` (handled by
|
||||
:func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with
|
||||
the shared authenticated, redirect-validated HTTP client, and only when not
|
||||
``--offline``.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
url = resolved.entry.download_url
|
||||
if not url:
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{resolved.entry.id}' has no download_url; cannot resolve "
|
||||
"its manifest."
|
||||
)
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
# On Windows an absolute path like ``C:\bundle.yml`` parses with a
|
||||
# single-letter ``scheme``; treat it as a local file, not a URL scheme.
|
||||
if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url):
|
||||
local = Path(parsed.path if scheme == "file" else url)
|
||||
manifest = _local_manifest_source(str(local))
|
||||
if manifest is None:
|
||||
raise BundlerError(f"Bundle manifest not found: {local}")
|
||||
return manifest
|
||||
|
||||
if scheme in ("http", "https"):
|
||||
if offline:
|
||||
raise BundlerError(
|
||||
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
return _download_remote_manifest(resolved.entry.id, url)
|
||||
|
||||
raise BundlerError(
|
||||
f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}"
|
||||
)
|
||||
|
||||
|
||||
def _require_https(label: str, url: str) -> None:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise BundlerError(
|
||||
f"Refusing to download {label} over non-HTTPS URL: {url}"
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise BundlerError(f"Refusing to download {label} from URL with no host: {url}")
|
||||
|
||||
|
||||
def _download_remote_manifest(entry_id: str, url: str):
|
||||
"""Fetch a remote bundle artifact over HTTPS and extract its manifest."""
|
||||
import io
|
||||
import tempfile
|
||||
|
||||
from ...authentication.http import open_url
|
||||
|
||||
def _validate_redirect(old_url: str, new_url: str) -> None:
|
||||
_require_https(f"bundle '{entry_id}'", new_url)
|
||||
|
||||
_require_https(f"bundle '{entry_id}'", url)
|
||||
try:
|
||||
with open_url(url, timeout=30, redirect_validator=_validate_redirect) as resp:
|
||||
_require_https(f"bundle '{entry_id}'", resp.geturl())
|
||||
raw = resp.read()
|
||||
except BundlerError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise BundlerError(f"Failed to download bundle '{entry_id}' from {url}: {exc}") from exc
|
||||
|
||||
# A .zip artifact is written to a temp file and parsed via the local-source
|
||||
# path (which extracts bundle.yml); any other payload is treated as YAML.
|
||||
if url.lower().endswith(".zip"):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
artifact = Path(tmp) / "bundle.zip"
|
||||
artifact.write_bytes(raw)
|
||||
manifest = _local_manifest_source(str(artifact))
|
||||
if manifest is None:
|
||||
raise BundlerError(
|
||||
f"Downloaded artifact for bundle '{entry_id}' is not a valid bundle."
|
||||
)
|
||||
return manifest
|
||||
|
||||
import yaml as _yaml
|
||||
|
||||
from ...bundler.models.manifest import BundleManifest
|
||||
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
"""Attach the bundle command group to the root Typer app."""
|
||||
app.add_typer(bundle_app, name="bundle")
|
||||
@@ -28,7 +28,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
from ._init_options import is_ai_skills_enabled
|
||||
from ._invocation_style import is_slash_skills_agent
|
||||
from ._utils import dump_frontmatter
|
||||
from ._utils import dump_frontmatter, relative_extension_path_violation
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry
|
||||
from .catalogs import CatalogStackBase
|
||||
|
||||
@@ -290,6 +290,18 @@ class ExtensionManifest:
|
||||
if "name" not in cmd or "file" not in cmd:
|
||||
raise ValidationError("Command missing 'name' or 'file'")
|
||||
|
||||
# Validate the 'file' field at manifest-load time using the single
|
||||
# shared policy in relative_extension_path_violation(), so manifest
|
||||
# validation cannot drift from the runtime registrar guard. This is
|
||||
# defense-in-depth: the command/skill/preset readers also contain
|
||||
# the resolved path, but rejecting an unsafe value here surfaces a
|
||||
# clear error instead of silently skipping the command.
|
||||
cmd_file = cmd["file"]
|
||||
reason = relative_extension_path_violation(cmd_file)
|
||||
if reason:
|
||||
label = repr(cmd_file) if isinstance(cmd_file, str) else f"for command '{cmd.get('name')}'"
|
||||
raise ValidationError(f"Invalid command 'file' {label}: {reason}")
|
||||
|
||||
# Validate command name format
|
||||
if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]):
|
||||
corrected = self._try_correct_command_name(cmd["name"], ext["id"])
|
||||
@@ -1061,20 +1073,10 @@ class ExtensionManager:
|
||||
)
|
||||
# Preserve the command's argument-hint in the generated skill,
|
||||
# mirroring the core template path (ClaudeIntegration.setup injects
|
||||
# it for built-in commands). The value is added to the frontmatter
|
||||
# dict before serialization — rather than via the string-based
|
||||
# inject_argument_hint helper — so that a folded multi-line
|
||||
# description cannot be split by the inserted line. Gated on the
|
||||
# integration exposing inject_argument_hint so only argument-hint
|
||||
# aware agents receive the key, leaving build_skill_frontmatter's
|
||||
# shared shape unchanged for every other agent.
|
||||
argument_hint = frontmatter.get("argument-hint")
|
||||
if (
|
||||
argument_hint
|
||||
and integration is not None
|
||||
and hasattr(integration, "inject_argument_hint")
|
||||
):
|
||||
frontmatter_data["argument-hint"] = str(argument_hint)
|
||||
# it for built-in commands). See CommandRegistrar.apply_argument_hint
|
||||
# for why the value is added to the dict before serialization rather
|
||||
# than via the string-based inject_argument_hint helper.
|
||||
registrar.apply_argument_hint(frontmatter, frontmatter_data, integration)
|
||||
frontmatter_text = dump_frontmatter(frontmatter_data)
|
||||
|
||||
# Derive a human-friendly title from the command name
|
||||
|
||||
@@ -1064,11 +1064,14 @@ class PresetManager:
|
||||
body = self._resolve_skill_command_refs(
|
||||
body, registrar, selected_ai
|
||||
)
|
||||
from ..integrations import get_integration
|
||||
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
|
||||
fm_data = registrar.build_skill_frontmatter(
|
||||
selected_ai if isinstance(selected_ai, str) else "",
|
||||
skill_name, desc,
|
||||
f"override:{cmd_name}",
|
||||
)
|
||||
registrar.apply_argument_hint(fm, fm_data, integration)
|
||||
fm_text = dump_frontmatter(fm_data)
|
||||
skill_title = self._skill_title_from_command(cmd_name)
|
||||
skill_content = (
|
||||
@@ -1076,8 +1079,6 @@ class PresetManager:
|
||||
f"# Speckit {skill_title} Skill\n\n{body}\n"
|
||||
)
|
||||
# Apply integration post-processing (e.g. Claude flags)
|
||||
from ..integrations import get_integration
|
||||
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
|
||||
if integration is not None and hasattr(integration, "post_process_skill_content"):
|
||||
skill_content = integration.post_process_skill_content(skill_content)
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
@@ -1346,6 +1347,7 @@ class PresetManager:
|
||||
enhanced_desc,
|
||||
f"preset:{manifest.id}",
|
||||
)
|
||||
registrar.apply_argument_hint(frontmatter, frontmatter_data, integration)
|
||||
frontmatter_text = dump_frontmatter(frontmatter_data)
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
@@ -1442,6 +1444,7 @@ class PresetManager:
|
||||
enhanced_desc,
|
||||
f"templates/commands/{short_name}.md",
|
||||
)
|
||||
registrar.apply_argument_hint(frontmatter, frontmatter_data, integration)
|
||||
frontmatter_text = dump_frontmatter(frontmatter_data)
|
||||
skill_title = self._skill_title_from_command(short_name)
|
||||
skill_content = (
|
||||
@@ -1479,6 +1482,7 @@ class PresetManager:
|
||||
frontmatter.get("description", f"Extension command: {command_name}"),
|
||||
extension_restore["source"],
|
||||
)
|
||||
registrar.apply_argument_hint(frontmatter, frontmatter_data, integration)
|
||||
frontmatter_text = dump_frontmatter(frontmatter_data)
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
|
||||
@@ -47,9 +47,10 @@ class StepContext:
|
||||
#: Resolved workflow inputs (from user prompts / defaults).
|
||||
inputs: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
#: Accumulated step results keyed by step ID.
|
||||
#: Each entry is ``{"integration": ..., "model": ..., "options": ...,
|
||||
#: "input": ..., "output": ...}``.
|
||||
#: Accumulated step results keyed by step ID. Each entry is the dict the
|
||||
#: engine persists per step:
|
||||
#: ``{"type": ..., "integration": ..., "model": ..., "options": ...,
|
||||
#: "input": ..., "output": ..., "status": ...}``.
|
||||
steps: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
#: Current fan-out item (set only inside fan-out iterations).
|
||||
|
||||
@@ -676,6 +676,7 @@ class WorkflowEngine:
|
||||
|
||||
# Record step results — prefer resolved values from step output
|
||||
step_data = {
|
||||
"type": step_type,
|
||||
"integration": result.output.get("integration")
|
||||
or step_config.get("integration")
|
||||
or context.default_integration,
|
||||
|
||||
@@ -12,6 +12,19 @@ import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
# The filters the expression evaluator recognizes. Used to tell a
|
||||
# *registered* filter used in an unsupported form (e.g. `| join` with no
|
||||
# argument) apart from a genuinely unknown filter name, so each raises an
|
||||
# error that names the real problem.
|
||||
_REGISTERED_FILTERS: tuple[str, ...] = (
|
||||
"default",
|
||||
"join",
|
||||
"map",
|
||||
"contains",
|
||||
"from_json",
|
||||
)
|
||||
|
||||
|
||||
# -- Custom filters -------------------------------------------------------
|
||||
|
||||
def _filter_default(value: Any, default_value: Any = "") -> Any:
|
||||
@@ -192,7 +205,27 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
filter_name = filter_expr.strip()
|
||||
if filter_name == "default":
|
||||
return _filter_default(value)
|
||||
return value
|
||||
# No recognized filter matched. Fail loudly rather than silently
|
||||
# returning the unfiltered value: a passthrough turns a mis-typed or
|
||||
# unsupported filter into a wrong result with no signal. Mirrors the
|
||||
# strict `from_json` handling above. Distinguish a *registered* filter
|
||||
# used in an unsupported form (e.g. `| join` or `| map` with no
|
||||
# argument) from a genuinely unknown filter name, so the message names
|
||||
# the real problem instead of calling a known filter "unknown".
|
||||
leading_name = re.match(r"\w+", filter_expr)
|
||||
name = leading_name.group(0) if leading_name else filter_expr
|
||||
expected = (
|
||||
"expected one of default or default('x'), join('sep'), "
|
||||
"map('attr'), contains('s'), or from_json"
|
||||
)
|
||||
if name in _REGISTERED_FILTERS:
|
||||
raise ValueError(
|
||||
f"filter '{name}' used in an unsupported form (got "
|
||||
f"'| {filter_expr}'): {expected}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
|
||||
)
|
||||
|
||||
# Boolean operators — parse 'or' first (lower precedence) so that
|
||||
# 'a or b and c' is evaluated as 'a or (b and c)'.
|
||||
|
||||
125
tests/bundler_helpers.py
Normal file
125
tests/bundler_helpers.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Shared helpers and fakes for bundler tests.
|
||||
|
||||
Kept out of ``tests/conftest.py`` so the existing root fixtures are untouched.
|
||||
Import what you need explicitly, e.g.::
|
||||
|
||||
from tests.bundler_helpers import FakeInstaller, write_manifest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
|
||||
|
||||
def valid_manifest_dict(**overrides) -> dict:
|
||||
"""Return a structurally valid manifest dict; override any top-level key."""
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "demo-bundle",
|
||||
"name": "Demo Bundle",
|
||||
"version": "1.2.0",
|
||||
"role": "developer",
|
||||
"description": "A demo bundle for tests.",
|
||||
"author": "Spec Kit",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"extensions": [{"id": "ext-a", "version": "1.0.0"}],
|
||||
"presets": [
|
||||
{"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
|
||||
],
|
||||
"steps": [{"id": "step-a"}],
|
||||
"workflows": [{"id": "wf-a", "version": "0.3.0"}],
|
||||
},
|
||||
"tags": ["demo", "test"],
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def write_manifest(directory: Path, data: dict | None = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = directory / "bundle.yml"
|
||||
manifest_path.write_text(
|
||||
yaml.safe_dump(data if data is not None else valid_manifest_dict()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return manifest_path
|
||||
|
||||
|
||||
def make_project(root: Path) -> Path:
|
||||
"""Create a minimal Spec Kit project skeleton under *root*."""
|
||||
(root / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def catalog_payload(bundles: dict | None = None) -> dict:
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-19T00:00:00Z",
|
||||
"catalog_url": "file://test",
|
||||
"bundles": bundles or {},
|
||||
}
|
||||
|
||||
|
||||
def catalog_entry_dict(bundle_id: str = "demo-bundle", **overrides) -> dict:
|
||||
entry = {
|
||||
"id": bundle_id,
|
||||
"name": "Demo Bundle",
|
||||
"version": "1.2.0",
|
||||
"role": "developer",
|
||||
"description": "A demo bundle.",
|
||||
"author": "Spec Kit",
|
||||
"license": "MIT",
|
||||
"download_url": "",
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"extensions": 1, "presets": 1, "steps": 1, "workflows": 1},
|
||||
"verified": True,
|
||||
}
|
||||
entry.update(overrides)
|
||||
return entry
|
||||
|
||||
|
||||
def write_catalog_file(path: Path, bundles: dict) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(catalog_payload(bundles)), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class FakeInstaller:
|
||||
"""Deterministic in-memory PrimitiveInstaller for offline integration tests."""
|
||||
|
||||
def __init__(self, *, fail_on: str | None = None) -> None:
|
||||
self.installed: set[tuple[str, str]] = set()
|
||||
self.install_calls: list[tuple[str, str]] = []
|
||||
self.remove_calls: list[tuple[str, str]] = []
|
||||
self.refresh_calls: list[tuple[str, str]] = []
|
||||
self._fail_on = fail_on
|
||||
|
||||
def _key(self, component: ComponentRef) -> tuple[str, str]:
|
||||
return (component.kind, component.id)
|
||||
|
||||
def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
|
||||
return self._key(component) in self.installed
|
||||
|
||||
def install(self, project_root: Path, component: ComponentRef) -> None:
|
||||
from specify_cli.bundler import BundlerError
|
||||
|
||||
self.install_calls.append(self._key(component))
|
||||
if self._fail_on is not None and component.id == self._fail_on:
|
||||
raise BundlerError(f"Simulated failure installing {component.id}")
|
||||
self.installed.add(self._key(component))
|
||||
|
||||
def remove(self, project_root: Path, component: ComponentRef) -> None:
|
||||
self.remove_calls.append(self._key(component))
|
||||
self.installed.discard(self._key(component))
|
||||
|
||||
def refresh(self, project_root: Path, component: ComponentRef) -> None:
|
||||
self.refresh_calls.append(self._key(component))
|
||||
self.installed.add(self._key(component))
|
||||
391
tests/contract/test_bundle_cli.py
Normal file
391
tests/contract/test_bundle_cli.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""Contract test for the `specify bundle` CLI surface (Typer integration).
|
||||
|
||||
Exercises the wired commands end-to-end via CliRunner against a temp project,
|
||||
asserting exit codes and the cross-cutting error guarantees from
|
||||
contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import (
|
||||
catalog_entry_dict,
|
||||
valid_manifest_dict,
|
||||
write_catalog_file,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project(tmp_path: Path, monkeypatch) -> Path:
|
||||
(tmp_path / ".specify").mkdir()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_bundle_help_lists_all_commands():
|
||||
result = runner.invoke(app, ["bundle", "--help"])
|
||||
assert result.exit_code == 0
|
||||
for cmd in ("search", "info", "list", "install", "update", "remove",
|
||||
"validate", "build", "init", "catalog"):
|
||||
assert cmd in result.output
|
||||
|
||||
|
||||
def test_update_accepts_integration_override():
|
||||
# Update must expose --integration so integration-pinned bundles can be
|
||||
# updated in projects where the active integration can't be auto-detected.
|
||||
# Rich may insert ANSI escapes between the two leading dashes, so match the
|
||||
# un-split option word rather than the literal "--integration".
|
||||
result = runner.invoke(app, ["bundle", "update", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "integration" in result.output
|
||||
|
||||
|
||||
def test_list_empty_project(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No bundles installed" in result.output
|
||||
|
||||
|
||||
def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code == 1
|
||||
assert "Spec Kit project" in result.output
|
||||
|
||||
|
||||
def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
|
||||
# Discovery commands fall back to the built-in/user catalog stack and must
|
||||
# not require a Spec Kit project (matches README/quickstart examples).
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.strip().startswith("[")
|
||||
|
||||
|
||||
def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"])
|
||||
# Reaches catalog resolution (not the project gate) and reports a clean miss.
|
||||
assert result.exit_code == 1
|
||||
assert "Spec Kit project" not in result.output
|
||||
|
||||
|
||||
def test_catalog_list_shows_builtin_defaults(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "catalog", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "default" in result.output
|
||||
assert "community" in result.output
|
||||
assert "built-in default stack" in result.output
|
||||
|
||||
|
||||
def test_catalog_add_and_remove(project: Path):
|
||||
catalog = project / "local-catalog.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
listed = runner.invoke(app, ["bundle", "catalog", "list"])
|
||||
assert "local" in listed.output
|
||||
|
||||
removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"])
|
||||
assert removed.exit_code == 0
|
||||
|
||||
|
||||
def test_catalog_remove_builtin_is_refused(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "catalog", "remove", "default"])
|
||||
assert result.exit_code == 1
|
||||
assert "built-in" in result.output
|
||||
|
||||
|
||||
def test_validate_reports_invalid_manifest(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 1
|
||||
assert "license" in result.output
|
||||
|
||||
|
||||
def test_validate_accepts_valid_manifest(project: Path):
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
# Offline mode does not fail on references it cannot verify (synthetic ids
|
||||
# here); they surface as warnings while structure is confirmed valid.
|
||||
result = runner.invoke(app, ["bundle", "validate", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "valid" in result.output
|
||||
|
||||
|
||||
def test_validate_rejects_broken_reference(project: Path):
|
||||
# Synthetic component ids resolve to nothing in any catalog → hard failure.
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 1
|
||||
assert "preset-a" in result.output or "ext-a" in result.output
|
||||
|
||||
|
||||
def test_validate_accepts_bundled_reference(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}
|
||||
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "valid" in result.output
|
||||
|
||||
|
||||
def test_build_produces_artifact(project: Path):
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(project / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")])
|
||||
assert result.exit_code == 0, result.output
|
||||
artifacts = list((project / "dist").glob("*.zip"))
|
||||
assert len(artifacts) == 1
|
||||
|
||||
|
||||
def test_info_expands_full_component_set(project: Path):
|
||||
bundle_dir = project / "src-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
catalog = project / "local-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]): c for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
preset = components[("presets", "preset-a")]
|
||||
assert preset["version"] == "2.0.0"
|
||||
assert preset["priority"] == 10
|
||||
assert preset["strategy"] == "append"
|
||||
assert payload["trust"] == "verified"
|
||||
|
||||
text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"])
|
||||
assert "preset-a v2.0.0" in text.output
|
||||
assert "Trust" in text.output
|
||||
|
||||
|
||||
def test_info_expands_discovery_only_bundle(project: Path):
|
||||
# Discovery-only bundles must still be fully inspectable via `info`;
|
||||
# only `install` is refused for them.
|
||||
bundle_dir = project / "disc-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
catalog = project / "disc-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]) for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_info_resolves_local_zip_download_url(project: Path):
|
||||
# A local .zip artifact as download_url is extracted to read bundle.yml.
|
||||
bundle_dir = project / "zip-src"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(bundle_dir / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path
|
||||
catalog = project / "zip-catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
|
||||
)
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]) for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_install_refuses_discovery_only_source(project: Path, monkeypatch):
|
||||
# Point a discovery-only catalog at a local payload containing the bundle.
|
||||
catalog = project / "disc.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "install", "demo", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "discovery-only" in result.output
|
||||
|
||||
|
||||
def test_update_refuses_discovery_only_source(project: Path):
|
||||
# An installed bundle whose only resolvable source is discovery-only must
|
||||
# not be updatable from there (FR-025), mirroring the install policy gate.
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.models.records import (
|
||||
InstalledBundleRecord,
|
||||
save_records,
|
||||
)
|
||||
|
||||
save_records(
|
||||
project,
|
||||
[
|
||||
InstalledBundleRecord.create(
|
||||
"demo",
|
||||
"1.0.0",
|
||||
[ComponentRef(kind="extensions", id="ext-a", version=None)],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
catalog = project / "disc.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["bundle", "update", "demo", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "discovery-only" in result.output
|
||||
|
||||
|
||||
def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path):
|
||||
# `info` must expand the real component set; if the manifest can't be
|
||||
# resolved (here: --offline against an https download_url), it should error
|
||||
# and exit non-zero rather than silently degrading to `provides` counts.
|
||||
catalog = project / "remote-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "Network access disabled" in result.output
|
||||
|
||||
|
||||
def test_search_json_offline(project: Path):
|
||||
catalog = project / "c.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "c", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "install-allowed"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload[0]["id"] == "demo"
|
||||
# Trust indicator is exposed on the discovery surface (FR-010 / FR-027).
|
||||
assert payload[0]["verified"] is True
|
||||
assert payload[0]["trust"] == "verified"
|
||||
|
||||
|
||||
def test_search_text_shows_trust(project: Path):
|
||||
catalog = project / "c.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{
|
||||
"verified-one": catalog_entry_dict("verified-one", verified=True),
|
||||
"community-one": catalog_entry_dict("community-one", verified=False),
|
||||
},
|
||||
)
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "c", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "install-allowed"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "verified" in result.output
|
||||
assert "community" in result.output
|
||||
|
||||
|
||||
def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
|
||||
# An initialized project's recorded active integration is authoritative:
|
||||
# passing --integration must not let a differently-pinned bundle install.
|
||||
import json
|
||||
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({"integration": "copilot"}), encoding="utf-8"
|
||||
)
|
||||
bundle_dir = project / "claude-bundle"
|
||||
bundle_dir.mkdir()
|
||||
data = valid_manifest_dict(integration={"id": "claude"})
|
||||
(bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "claude" in result.output and "copilot" in result.output
|
||||
147
tests/contract/test_catalog_schema.py
Normal file
147
tests/contract/test_catalog_schema.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Contract tests for the catalog schema and source stack.
|
||||
|
||||
Mirrors contracts/bundle-catalog.schema.md: source precedence project > user >
|
||||
built-in, install policy gating, payload parsing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.models.catalog import (
|
||||
BUILTIN_DEFAULT_STACK,
|
||||
CatalogSource,
|
||||
InstallPolicy,
|
||||
Scope,
|
||||
load_catalog_payload,
|
||||
load_source_stack,
|
||||
)
|
||||
from specify_cli.bundler import BundlerError
|
||||
import pytest
|
||||
from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project
|
||||
|
||||
|
||||
def test_non_integer_source_priority_raises_actionable_error():
|
||||
with pytest.raises(BundlerError, match="non-integer priority"):
|
||||
CatalogSource.from_dict(
|
||||
{"id": "corp", "url": "https://corp/catalog.json", "priority": "high"},
|
||||
Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_default_stack_when_no_config(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
sources = load_source_stack(tmp_path)
|
||||
ids = [s.id for s in sources]
|
||||
assert ids == ["default", "community"]
|
||||
assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED
|
||||
assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY
|
||||
assert all(s.scope is Scope.BUILTIN for s in sources)
|
||||
|
||||
|
||||
def test_project_config_overrides_same_id(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "default", "url": "file://local", "priority": 1,
|
||||
"install_policy": "install-allowed"},
|
||||
{"id": "corp", "url": "https://corp/catalog.json", "priority": 0,
|
||||
"install_policy": "install-allowed"},
|
||||
],
|
||||
}
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
sources = load_source_stack(tmp_path)
|
||||
by_id = {s.id: s for s in sources}
|
||||
assert by_id["default"].scope is Scope.PROJECT
|
||||
assert by_id["default"].url == "file://local"
|
||||
# Highest precedence (lowest priority number) sorts first.
|
||||
assert sources[0].id == "corp"
|
||||
|
||||
|
||||
def test_user_scope_between_builtin_and_project(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
user_dir = tmp_path / "userconf"
|
||||
user_dir.mkdir()
|
||||
(user_dir / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"catalogs": [
|
||||
{"id": "community", "url": "https://u", "priority": 2,
|
||||
"install_policy": "install-allowed"}
|
||||
]}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sources = load_source_stack(tmp_path, user_config_dir=user_dir)
|
||||
by_id = {s.id: s for s in sources}
|
||||
# User overrode the built-in community policy to install-allowed.
|
||||
assert by_id["community"].scope is Scope.USER
|
||||
assert by_id["community"].install_allowed is True
|
||||
|
||||
|
||||
def test_load_payload_parses_entries():
|
||||
payload = catalog_payload({"demo-bundle": catalog_entry_dict()})
|
||||
entries = load_catalog_payload(payload)
|
||||
assert "demo-bundle" in entries
|
||||
assert entries["demo-bundle"].version == "1.2.0"
|
||||
assert entries["demo-bundle"].provides["presets"] == 1
|
||||
|
||||
|
||||
def test_builtin_default_stack_constant_shape():
|
||||
ids = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
|
||||
assert ids == {"default", "community"}
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_string_tags():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["tags"] = "not-a-list"
|
||||
with pytest.raises(BundlerError, match="'tags' must be a list"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_boolean_verified():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["verified"] = "false" # truthy string must not mark the entry verified
|
||||
with pytest.raises(BundlerError, match="'verified' must be a boolean"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_load_payload_rejects_id_key_mismatch():
|
||||
# The enclosing key is authoritative; an entry whose own id disagrees with
|
||||
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.
|
||||
payload = catalog_payload({"demo-bundle": catalog_entry_dict("other-id")})
|
||||
with pytest.raises(BundlerError, match="id mismatch"):
|
||||
load_catalog_payload(payload)
|
||||
|
||||
|
||||
def test_load_payload_rejects_missing_entry_id():
|
||||
entry = catalog_entry_dict("demo-bundle")
|
||||
entry["id"] = ""
|
||||
payload = catalog_payload({"demo-bundle": entry})
|
||||
with pytest.raises(BundlerError, match="missing its 'id'"):
|
||||
load_catalog_payload(payload)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_mapping_requires():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["requires"] = "speckit>=0.1"
|
||||
with pytest.raises(BundlerError, match="'requires' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_mapping_provides():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["provides"] = "extensions"
|
||||
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
126
tests/contract/test_manifest_schema.py
Normal file
126
tests/contract/test_manifest_schema.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Contract tests for the bundle manifest schema (bundle.yml).
|
||||
|
||||
Mirrors contracts/bundle-manifest.schema.md: required identity/metadata fields,
|
||||
semver pinning of components, preset priority+strategy, integration optionality.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def test_valid_manifest_has_no_structural_errors():
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
assert manifest.structural_errors() == []
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
assert manifest.is_agnostic() is True
|
||||
|
||||
|
||||
def test_missing_required_field_is_reported_by_name():
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("bundle.license" in e for e in errors)
|
||||
|
||||
|
||||
def test_unsupported_schema_version_is_rejected():
|
||||
data = valid_manifest_dict(schema_version="9.9")
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("schema_version" in e for e in errors)
|
||||
|
||||
|
||||
def test_non_semver_bundle_version_is_rejected():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["version"] = "not-a-version"
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("semver" in e for e in errors)
|
||||
|
||||
|
||||
def test_preset_requires_priority_and_strategy():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"] = [{"id": "p", "version": "1.0.0"}]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("priority" in e for e in errors)
|
||||
assert any("strategy" in e for e in errors)
|
||||
|
||||
|
||||
def test_invalid_preset_strategy_is_rejected():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"][0]["strategy"] = "merge"
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("strategy" in e for e in errors)
|
||||
|
||||
|
||||
def test_non_integer_priority_raises_actionable_error():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"][0]["priority"] = "high"
|
||||
with pytest.raises(BundlerError, match="priority must be an integer"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_non_step_components_must_be_pinned():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["extensions"] = [{"id": "ext-unpinned"}]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("must be pinned" in e for e in errors)
|
||||
|
||||
|
||||
def test_steps_may_be_unpinned():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["steps"] = [{"id": "step-x"}]
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.structural_errors() == []
|
||||
|
||||
|
||||
def test_integration_makes_bundle_non_agnostic():
|
||||
data = valid_manifest_dict(integration={"id": "copilot"})
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.is_agnostic() is False
|
||||
assert manifest.integration.id == "copilot"
|
||||
|
||||
|
||||
def test_components_property_orders_by_kind():
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
kinds = [c.kind for c in manifest.components]
|
||||
assert kinds == ["extensions", "presets", "steps", "workflows"]
|
||||
|
||||
|
||||
def test_string_tags_rejected_not_split_per_character():
|
||||
# A bare string would otherwise be iterated character-by-character; the
|
||||
# schema requires a list of strings.
|
||||
data = valid_manifest_dict()
|
||||
data["tags"] = "security"
|
||||
with pytest.raises(BundlerError, match="'tags' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_unsafe_bundle_id_flagged_by_structural_validation():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "../evil"
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
errors = manifest.structural_errors()
|
||||
assert any("bundle.id" in e and "slug" in e for e in errors)
|
||||
|
||||
|
||||
def test_valid_slug_bundle_id_passes():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "team-a.bundle_1"
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert not any("bundle.id" in e for e in manifest.structural_errors())
|
||||
|
||||
|
||||
def test_string_tools_rejected_not_split_per_character():
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["tools"] = "docker"
|
||||
with pytest.raises(BundlerError, match="'requires.tools' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_string_mcp_rejected_not_split_per_character():
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["mcp"] = "github"
|
||||
with pytest.raises(BundlerError, match="'requires.mcp' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
79
tests/integration/test_bundler_catalog_stack.py
Normal file
79
tests/integration/test_bundler_catalog_stack.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Integration tests for the catalog stack: precedence, policy gating, search."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope
|
||||
from specify_cli.bundler.services.catalog_stack import CatalogStack
|
||||
from tests.bundler_helpers import catalog_entry_dict, catalog_payload
|
||||
|
||||
|
||||
def _source(source_id, priority, policy, url="builtin://x"):
|
||||
return CatalogSource(
|
||||
id=source_id, url=url, priority=priority,
|
||||
install_policy=InstallPolicy(policy), scope=Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def _stack(sources, payloads):
|
||||
def fetcher(src):
|
||||
return payloads[src.id]
|
||||
return CatalogStack(sources, fetcher)
|
||||
|
||||
|
||||
def test_resolve_prefers_highest_precedence_source():
|
||||
sources = [
|
||||
_source("low", 2, "install-allowed"),
|
||||
_source("high", 1, "discovery-only"),
|
||||
]
|
||||
payloads = {
|
||||
"high": catalog_payload({"b": catalog_entry_dict("b", version="9.0.0")}),
|
||||
"low": catalog_payload({"b": catalog_entry_dict("b", version="1.0.0")}),
|
||||
}
|
||||
resolved = _stack(sources, payloads).resolve("b")
|
||||
assert resolved.source.id == "high"
|
||||
assert resolved.entry.version == "9.0.0"
|
||||
assert resolved.install_allowed is False
|
||||
|
||||
|
||||
def test_resolve_unknown_bundle_errors():
|
||||
stack = _stack(
|
||||
[_source("only", 1, "install-allowed")],
|
||||
{"only": catalog_payload({})},
|
||||
)
|
||||
with pytest.raises(BundlerError, match="not found"):
|
||||
stack.resolve("missing")
|
||||
|
||||
|
||||
def test_search_dedupes_by_precedence_and_filters():
|
||||
sources = [_source("a", 1, "install-allowed"), _source("b", 2, "install-allowed")]
|
||||
payloads = {
|
||||
"a": catalog_payload({
|
||||
"alpha": catalog_entry_dict("alpha", role="developer"),
|
||||
}),
|
||||
"b": catalog_payload({
|
||||
"alpha": catalog_entry_dict("alpha", version="0.0.1"),
|
||||
"beta": catalog_entry_dict("beta", role="qa"),
|
||||
}),
|
||||
}
|
||||
stack = _stack(sources, payloads)
|
||||
|
||||
all_results = stack.search()
|
||||
ids = [r.entry.id for r in all_results]
|
||||
assert ids == ["alpha", "beta"]
|
||||
# alpha resolved from the higher-precedence source 'a'.
|
||||
alpha = next(r for r in all_results if r.entry.id == "alpha")
|
||||
assert alpha.source.id == "a"
|
||||
|
||||
qa_only = stack.search("qa")
|
||||
assert [r.entry.id for r in qa_only] == ["beta"]
|
||||
|
||||
|
||||
def test_unreachable_source_raises_named_error():
|
||||
def fetcher(src):
|
||||
raise RuntimeError("boom")
|
||||
stack = CatalogStack([_source("bad", 1, "install-allowed")], fetcher)
|
||||
with pytest.raises(BundlerError, match="bad"):
|
||||
stack.resolve("anything")
|
||||
92
tests/integration/test_bundler_init_install.py
Normal file
92
tests/integration/test_bundler_init_install.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Install-time initialization and integration precedence (T049, T050).
|
||||
|
||||
``specify bundle install`` into an uninitialized directory must scaffold a Spec
|
||||
Kit project first (FR-012), choosing the integration by precedence (FR-013):
|
||||
explicit ``--integration`` override → bundle-declared integration → default.
|
||||
The end-to-end test runs fully offline against bundled assets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.commands.bundle import _resolve_init_integration
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _manifest(**overrides):
|
||||
data = valid_manifest_dict(**overrides)
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_precedence_override_wins():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
assert _resolve_init_integration("gemini", manifest) == "gemini"
|
||||
|
||||
|
||||
def test_precedence_bundle_declared_when_no_override():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
assert _resolve_init_integration(None, manifest) == "claude"
|
||||
|
||||
|
||||
def test_precedence_default_when_unspecified():
|
||||
manifest = _manifest()
|
||||
assert _resolve_init_integration(None, manifest) == "copilot"
|
||||
assert _resolve_init_integration(None, None) == "copilot"
|
||||
|
||||
|
||||
def _build_mini(tmp_path: Path) -> Path:
|
||||
bundle = tmp_path / "mini"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "mini",
|
||||
"name": "Mini",
|
||||
"version": "1.0.0",
|
||||
"role": "developer",
|
||||
"description": "minimal",
|
||||
"author": "tests",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bundle / "README.md").write_text("# Mini\n", encoding="utf-8")
|
||||
return build_bundle(bundle).artifact_path
|
||||
|
||||
|
||||
def test_install_initializes_uninitialized_project(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
artifact = _build_mini(tmp_path)
|
||||
|
||||
previous = Path.cwd()
|
||||
os.chdir(project)
|
||||
try:
|
||||
result = runner.invoke(
|
||||
app, ["bundle", "install", str(artifact), "--offline"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
assert (project / ".specify").is_dir()
|
||||
marker = project / ".specify" / "integration.json"
|
||||
assert marker.exists()
|
||||
data = json.loads(marker.read_text(encoding="utf-8"))
|
||||
assert "copilot" in json.dumps(data)
|
||||
222
tests/integration/test_bundler_install_flow.py
Normal file
222
tests/integration/test_bundler_install_flow.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Integration tests for the install → record → remove lifecycle (offline, fake installer).
|
||||
|
||||
Uses :class:`FakeInstaller` so no network or real primitive machinery is touched
|
||||
(Constitution Principle II network-mocking, Principle IV offline-first).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict
|
||||
|
||||
|
||||
def _plan(manifest):
|
||||
return resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_install_records_and_invokes_primitives(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
result = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
assert len(result.installed) == 4
|
||||
assert len(installer.install_calls) == 4
|
||||
records = load_records(tmp_path)
|
||||
assert len(records) == 1
|
||||
assert records[0].bundle_id == "demo-bundle"
|
||||
|
||||
|
||||
def test_install_is_idempotent(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
second = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# Second install adds nothing and does not duplicate the record.
|
||||
assert second.installed == []
|
||||
assert len(second.skipped) == 4
|
||||
assert len(load_records(tmp_path)) == 1
|
||||
|
||||
|
||||
def test_partial_failure_rolls_back_and_records_nothing(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller(fail_on="preset-a")
|
||||
|
||||
with pytest.raises(BundlerError):
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# ext-a was installed first, then rolled back; no record persisted.
|
||||
assert installer.installed == set()
|
||||
assert load_records(tmp_path) == []
|
||||
|
||||
|
||||
def test_remove_is_non_collateral(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
# Bundle A provides a shared preset; Bundle B also provides it.
|
||||
data_a = valid_manifest_dict()
|
||||
data_a["bundle"]["id"] = "a"
|
||||
data_b = valid_manifest_dict()
|
||||
data_b["bundle"]["id"] = "b"
|
||||
data_b["provides"] = {"presets": [
|
||||
{"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
|
||||
]}
|
||||
|
||||
man_a = BundleManifest.from_dict(data_a)
|
||||
man_b = BundleManifest.from_dict(data_b)
|
||||
install_bundle(tmp_path, _plan(man_a), installer, manifest=man_a)
|
||||
install_bundle(tmp_path, _plan(man_b), installer, manifest=man_b)
|
||||
|
||||
# Removing B must NOT uninstall preset-a (still needed by A).
|
||||
result = remove_bundle(tmp_path, "b", installer)
|
||||
assert ("presets", "preset-a") in {(c.kind, c.id) for c in result.skipped}
|
||||
assert installer.is_installed(tmp_path, man_a.presets[0]) is True
|
||||
|
||||
remaining = {r.bundle_id for r in load_records(tmp_path)}
|
||||
assert remaining == {"a"}
|
||||
|
||||
|
||||
def test_remove_unknown_bundle_errors(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
with pytest.raises(BundlerError, match="not installed"):
|
||||
remove_bundle(tmp_path, "ghost", FakeInstaller())
|
||||
|
||||
|
||||
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
result = remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
# Removal flows populate the dedicated ``uninstalled`` list; ``installed``
|
||||
# stays empty so the result type is never ambiguous for callers.
|
||||
assert result.installed == []
|
||||
assert len(result.uninstalled) == 4
|
||||
assert installer.installed == set()
|
||||
|
||||
|
||||
def test_remove_counts_only_components_actually_removed(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# Simulate one contributed component already gone from disk (e.g. removed
|
||||
# out of band). It must not be reported as uninstalled and remove() must
|
||||
# not be called for it.
|
||||
gone = manifest.components[0]
|
||||
installer.installed.discard((gone.kind, gone.id))
|
||||
|
||||
result = remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
assert len(result.uninstalled) == 3
|
||||
assert (gone.kind, gone.id) not in installer.remove_calls
|
||||
assert gone in result.skipped
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# With refresh, already-installed components are re-applied, not skipped.
|
||||
assert result.skipped == []
|
||||
assert len(result.refreshed) == 4
|
||||
assert len(installer.refresh_calls) == 4
|
||||
assert result.changed is True
|
||||
|
||||
|
||||
def test_refresh_falls_back_to_install_without_hook(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
|
||||
class NoRefreshInstaller(FakeInstaller):
|
||||
refresh = None # type: ignore[assignment]
|
||||
|
||||
installer = NoRefreshInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
before = len(installer.install_calls)
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# No refresh hook → re-install path keeps components current.
|
||||
assert len(result.refreshed) == 4
|
||||
assert len(installer.install_calls) == before + 4
|
||||
|
||||
|
||||
def test_update_preserves_original_installed_at(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
original = load_records(tmp_path)[0].installed_at
|
||||
|
||||
# A refresh (bundle update) must not rewrite the original install timestamp.
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True)
|
||||
|
||||
assert load_records(tmp_path)[0].installed_at == original
|
||||
|
||||
|
||||
def test_refresh_does_not_touch_independently_installed_component(tmp_path: Path):
|
||||
# bundle update (refresh) must not re-apply a component installed
|
||||
# independently and tracked by no bundle — refreshing it would be a
|
||||
# collateral change to something the bundle does not own (FR-022).
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
installer.installed.add(("extensions", "ext-a"))
|
||||
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# ext-a is skipped (not refreshed) and never attributed to the bundle.
|
||||
assert ("extensions", "ext-a") not in installer.refresh_calls
|
||||
assert ("extensions", "ext-a") in {(c.kind, c.id) for c in result.skipped}
|
||||
assert ("extensions", "ext-a") not in {(c.kind, c.id) for c in result.refreshed}
|
||||
contributed = {
|
||||
(c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
|
||||
}
|
||||
assert ("extensions", "ext-a") not in contributed
|
||||
|
||||
|
||||
def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
|
||||
# A component installed independently (before any bundle) must not be
|
||||
# attributed to the bundle, so removing the bundle never uninstalls it
|
||||
# (FR-022, no collateral removal).
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
# Pre-install ext-a independently — no bundle record references it yet.
|
||||
installer.installed.add(("extensions", "ext-a"))
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
contributed = {
|
||||
(c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
|
||||
}
|
||||
assert ("extensions", "ext-a") not in contributed
|
||||
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
assert ("extensions", "ext-a") in installer.installed
|
||||
114
tests/integration/test_bundler_local_install.py
Normal file
114
tests/integration/test_bundler_local_install.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Tests for installing a bundle from a local artifact/path (T045).
|
||||
|
||||
The resolution-level tests are pure; the end-to-end test installs the bundled
|
||||
``agent-context`` extension fully offline from a built ``.zip`` artifact,
|
||||
proving the real in-process primitive dispatch (T044) works without a network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.commands.bundle import _local_manifest_source
|
||||
from tests.bundler_helpers import make_project, valid_manifest_dict, write_manifest
|
||||
|
||||
|
||||
def test_local_source_none_for_non_path():
|
||||
assert _local_manifest_source("some-catalog-bundle-id") is None
|
||||
|
||||
|
||||
def test_local_source_from_directory(tmp_path: Path):
|
||||
write_manifest(tmp_path, valid_manifest_dict())
|
||||
manifest = _local_manifest_source(str(tmp_path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_from_bundle_yml(tmp_path: Path):
|
||||
path = write_manifest(tmp_path, valid_manifest_dict())
|
||||
manifest = _local_manifest_source(str(path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_from_zip_artifact(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
write_manifest(bundle_dir, valid_manifest_dict())
|
||||
(bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)])
|
||||
assert result.exit_code == 0, result.output
|
||||
artifact = next(bundle_dir.glob("*.zip"))
|
||||
|
||||
manifest = _local_manifest_source(str(artifact))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_rejects_unknown_file(tmp_path: Path):
|
||||
weird = tmp_path / "thing.txt"
|
||||
weird.write_text("nope", encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="not a recognised bundle source"):
|
||||
_local_manifest_source(str(weird))
|
||||
|
||||
|
||||
def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
|
||||
"""End-to-end: build → install (offline, local .zip) → list → remove."""
|
||||
project = make_project(tmp_path / "proj")
|
||||
|
||||
bundle_dir = tmp_path / "mini"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "mini",
|
||||
"name": "Mini",
|
||||
"version": "1.0.0",
|
||||
"role": "developer",
|
||||
"description": "minimal",
|
||||
"author": "tests",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"extensions": [{"id": "agent-context", "version": "1.0.0"}]
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bundle_dir / "README.md").write_text("# Mini\n", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
previous = Path.cwd()
|
||||
os.chdir(project)
|
||||
try:
|
||||
build = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)])
|
||||
assert build.exit_code == 0, build.output
|
||||
artifact = next(bundle_dir.glob("*.zip"))
|
||||
|
||||
install = runner.invoke(app, ["bundle", "install", str(artifact), "--offline"])
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
assert ExtensionManager(project).registry.is_installed("agent-context")
|
||||
|
||||
listing = runner.invoke(app, ["bundle", "list"])
|
||||
assert "mini" in listing.output
|
||||
|
||||
remove = runner.invoke(app, ["bundle", "remove", "mini"])
|
||||
assert remove.exit_code == 0, remove.output
|
||||
assert not ExtensionManager(project).registry.is_installed("agent-context")
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
78
tests/integration/test_bundler_offline.py
Normal file
78
tests/integration/test_bundler_offline.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Offline-first tests (Constitution Principle IV).
|
||||
|
||||
Assert that consume/author flows work with no network access: built-in catalogs
|
||||
resolve offline, file:// catalogs resolve offline, and http(s) sources are
|
||||
refused (never silently attempted) when network is disabled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope
|
||||
from specify_cli.bundler.services.adapters import make_catalog_fetcher
|
||||
from specify_cli.bundler.services.catalog_stack import CatalogStack
|
||||
from tests.bundler_helpers import catalog_entry_dict, write_catalog_file
|
||||
|
||||
|
||||
def _src(source_id, url, priority=1, policy="install-allowed"):
|
||||
return CatalogSource(
|
||||
id=source_id, url=url, priority=priority,
|
||||
install_policy=InstallPolicy(policy), scope=Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_catalog_resolves_offline():
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("default", "builtin://default")], fetcher)
|
||||
# Built-in default ships empty; search works without network and returns [].
|
||||
assert stack.search() == []
|
||||
|
||||
|
||||
def test_file_catalog_resolves_offline(tmp_path: Path):
|
||||
catalog_path = tmp_path / "catalog.json"
|
||||
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", str(catalog_path))], fetcher)
|
||||
resolved = stack.resolve("demo")
|
||||
assert resolved.entry.id == "demo"
|
||||
|
||||
|
||||
def test_http_source_refused_when_offline():
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("remote", "https://example.com/catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="Network access disabled"):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_missing_file_catalog_errors_offline(tmp_path: Path):
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", str(tmp_path / "nope.json"))], fetcher)
|
||||
with pytest.raises(BundlerError):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_file_url_catalog_resolves_offline(tmp_path: Path):
|
||||
catalog_path = tmp_path / "catalog.json"
|
||||
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", catalog_path.as_uri())], fetcher)
|
||||
resolved = stack.resolve("demo")
|
||||
assert resolved.entry.id == "demo"
|
||||
|
||||
|
||||
def test_plain_http_remote_rejected_before_network():
|
||||
# HTTPS is required for non-localhost catalogs; reject http:// up front.
|
||||
fetcher = make_catalog_fetcher(allow_network=True)
|
||||
stack = CatalogStack([_src("remote", "http://example.com/catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_remote_url_without_host_rejected():
|
||||
fetcher = make_catalog_fetcher(allow_network=True)
|
||||
stack = CatalogStack([_src("remote", "https:///catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="valid URL with a host"):
|
||||
stack.resolve("anything")
|
||||
173
tests/integration/test_bundler_security_paths.py
Normal file
173
tests/integration/test_bundler_security_paths.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Security tests: path-traversal / symlink confinement (Constitution Principle V).
|
||||
|
||||
These assert the bundler refuses to read or write outside an allowed root, so a
|
||||
malicious manifest or artifact path cannot escape the project/bundle directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath
|
||||
|
||||
|
||||
def test_ensure_within_allows_child(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
child = root / "sub" / "file.txt"
|
||||
assert ensure_within(root, child) == child.resolve()
|
||||
|
||||
|
||||
def test_ensure_within_rejects_parent_traversal(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
escape = root / ".." / "secret.txt"
|
||||
with pytest.raises(BundlerError, match="escapes"):
|
||||
ensure_within(root, escape)
|
||||
|
||||
|
||||
def test_ensure_within_rejects_absolute_outside(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
with pytest.raises(BundlerError):
|
||||
ensure_within(root, Path("/etc/passwd"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
|
||||
def test_ensure_within_rejects_symlink_escape(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
link = root / "link.txt"
|
||||
link.symlink_to(outside)
|
||||
with pytest.raises(BundlerError, match="escapes"):
|
||||
ensure_within(root, link)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel,safe", [
|
||||
("a/b.txt", True),
|
||||
("./a.txt", True),
|
||||
("../escape", False),
|
||||
("a/../../escape", False),
|
||||
("/abs", False),
|
||||
("C:/abs", False),
|
||||
("C:\\abs", False),
|
||||
("\\\\server\\share", False),
|
||||
("", False),
|
||||
])
|
||||
def test_is_safe_relpath(rel, safe):
|
||||
assert is_safe_relpath(rel) is safe
|
||||
|
||||
|
||||
def test_build_skips_symlinks(tmp_path: Path):
|
||||
"""Packager must not follow symlinks out of the bundle dir."""
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(bundle / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
|
||||
if os.name != "nt":
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("top secret", encoding="utf-8")
|
||||
(bundle / "leak.txt").symlink_to(secret)
|
||||
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert "leak.txt" not in names
|
||||
assert "bundle.yml" in names
|
||||
|
||||
|
||||
def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
# Reading bundle-records.json must honour the same confinement as writes:
|
||||
# a symlinked .specify pointing outside project_root is refused.
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-records.json").write_text(
|
||||
'{"schema_version": "1.0", "bundles": []}', encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
load_records(project)
|
||||
|
||||
|
||||
def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
# Reading the integration marker must not follow a .specify symlink that
|
||||
# resolves outside project_root; an escape is treated as "not determinable".
|
||||
from specify_cli.bundler.lib.project import active_integration
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "integration.json").write_text(
|
||||
'{"integration": "leaked"}', encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
assert active_integration(project) is None
|
||||
|
||||
|
||||
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
from specify_cli.bundler.commands_impl import catalog_config as cc
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-catalogs.yml").write_text(
|
||||
"schema_version: '1.0'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path):
|
||||
from specify_cli.bundler.models.catalog import load_source_stack
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-catalogs.yml").write_text("catalogs: []\n", encoding="utf-8")
|
||||
try:
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
load_source_stack(project)
|
||||
|
||||
|
||||
def test_find_project_root_ignores_symlinked_specify(tmp_path: Path):
|
||||
from specify_cli.bundler.lib.project import find_project_root
|
||||
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
try:
|
||||
(project / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
# A symlinked .specify must not be accepted as a project root.
|
||||
assert find_project_root(project) is None
|
||||
@@ -377,6 +377,40 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid command name"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_file",
|
||||
["../../../outside.md", "../escape.md", "a/../../escape.md", "/abs/outside.md", "C:escape.md", "C:\\Windows\\x.md", "..\\..\\escape.md"],
|
||||
)
|
||||
def test_command_file_traversal_rejected(self, temp_dir, valid_manifest_data, bad_file):
|
||||
"""Manifest 'file' field with traversal/absolute path raises ValidationError.
|
||||
|
||||
Defense-in-depth for GHSA-w5fv-7w9x-7fc5.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"][0]["file"] = bad_file
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
yaml.safe_dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="Invalid command 'file'"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize("bad_file", [" commands/hello.md", "commands/hello.md ", "\tcommands/hello.md"])
|
||||
def test_command_file_whitespace_rejected(self, temp_dir, valid_manifest_data, bad_file):
|
||||
"""Manifest 'file' with leading/trailing whitespace raises ValidationError."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"][0]["file"] = bad_file
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
yaml.safe_dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="leading or trailing whitespace"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
|
||||
"""Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'."""
|
||||
import yaml
|
||||
|
||||
@@ -2997,6 +2997,84 @@ class TestPresetSkills:
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit-specify" in metadata.get("registered_skills", [])
|
||||
|
||||
def _install_arg_hint_preset(self, project_dir, temp_dir, ai, skills_dir, description, arg_hint):
|
||||
"""Install a preset whose command declares argument-hint; return the SKILL.md path."""
|
||||
self._write_init_options(project_dir, ai=ai)
|
||||
self._create_skill(skills_dir, "speckit-hinttest-cmd")
|
||||
(project_dir / ".specify" / "extensions" / "hinttest").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preset_dir = temp_dir / f"hint-preset-{ai}"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.hinttest.cmd.md").write_text(
|
||||
"---\n"
|
||||
f'description: "{description}"\n'
|
||||
f'argument-hint: "{arg_hint}"\n'
|
||||
"---\n\n"
|
||||
"Preset command body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": f"hint-preset-{ai}",
|
||||
"name": "Hint Preset",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.hinttest.cmd",
|
||||
"file": "commands/speckit.hinttest.cmd.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
return skills_dir / "speckit-hinttest-cmd" / "SKILL.md"
|
||||
|
||||
def test_argument_hint_preserved_for_preset_command(self, project_dir, temp_dir):
|
||||
"""argument-hint from a preset command must survive into the SKILL.md.
|
||||
|
||||
Follow-up to #2903/#2916 for the preset skill generator. The
|
||||
description is long enough to fold across lines when serialized,
|
||||
guarding against an in-place string injection that would split the
|
||||
folded scalar into invalid YAML.
|
||||
"""
|
||||
long_description = (
|
||||
"Build and maintain a lean, static context/ knowledge folder so "
|
||||
"coding agents load only what is relevant and save tokens"
|
||||
)
|
||||
arg_hint = "<init | update | list | check> [area] [slug] [-- notes]"
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
|
||||
skill_file = self._install_arg_hint_preset(
|
||||
project_dir, temp_dir, "claude", skills_dir, long_description, arg_hint
|
||||
)
|
||||
assert skill_file.exists()
|
||||
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
|
||||
assert parsed["argument-hint"] == arg_hint
|
||||
assert parsed["description"] == long_description
|
||||
|
||||
def test_argument_hint_not_added_for_non_claude_preset_command(self, project_dir, temp_dir):
|
||||
"""Non-Claude skills agents must not receive argument-hint in preset skills."""
|
||||
arg_hint = "<init | update | list | check> [area]"
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
|
||||
skill_file = self._install_arg_hint_preset(
|
||||
project_dir, temp_dir, "codex", skills_dir, "Build context", arg_hint
|
||||
)
|
||||
assert skill_file.exists()
|
||||
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
|
||||
assert "argument-hint" not in parsed
|
||||
|
||||
def test_register_skills_resolves_command_refs(self, project_dir, temp_dir):
|
||||
"""Preset skill overrides must resolve __SPECKIT_COMMAND_*__ tokens (issue #2717).
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli._utils import relative_extension_path_violation
|
||||
|
||||
|
||||
TRAVERSAL_PAYLOADS = [
|
||||
@@ -135,8 +136,185 @@ class TestCopilotPromptTraversal:
|
||||
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
|
||||
|
||||
|
||||
class TestSafeRegistration:
|
||||
"""Positive regression — well-formed names continue to register."""
|
||||
ABS_OUTSIDE = "__ABS_OUTSIDE__"
|
||||
|
||||
FILE_FIELD_PAYLOADS = [
|
||||
"../outside.txt",
|
||||
"../../outside.txt",
|
||||
"commands/../../outside.txt",
|
||||
"C:outside.txt",
|
||||
ABS_OUTSIDE,
|
||||
]
|
||||
|
||||
|
||||
def _resolve_payload(bad_file: str, outside_file: Path) -> str:
|
||||
"""Map the absolute-path sentinel to the real, existing outside file.
|
||||
|
||||
Using the temp file's own absolute path (instead of ``/etc/passwd``)
|
||||
guarantees the file exists on every platform — so the test fails if the
|
||||
absolute-path guard regresses, rather than passing because the target
|
||||
happens not to exist (e.g. on Windows runners).
|
||||
"""
|
||||
return str(outside_file) if bad_file == ABS_OUTSIDE else bad_file
|
||||
|
||||
|
||||
def _assert_no_marker_leak(project: Path, marker: str) -> None:
|
||||
"""Fail if ``marker`` content was written into any file under ``project``."""
|
||||
leaked = [
|
||||
p for p in project.rglob("*")
|
||||
if p.is_file() and marker in p.read_text(encoding="utf-8", errors="ignore")
|
||||
]
|
||||
assert leaked == [], f"Outside file leaked into generated command: {leaked}"
|
||||
|
||||
|
||||
class TestCommandFileTraversal:
|
||||
"""The manifest ``file`` field must not read files outside source_dir.
|
||||
|
||||
Regression for GHSA-w5fv-7w9x-7fc5: ``register_commands`` read
|
||||
``source_dir / cmd_file`` with no containment check, so a manifest with
|
||||
a traversal (``file: ../../../outside.txt``) or an absolute path read an
|
||||
arbitrary host file verbatim into the generated agent command.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("bad_file", FILE_FIELD_PAYLOADS)
|
||||
def test_claude_skips_traversal_in_file_field(self, tmp_path, bad_file):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".claude" / "skills").mkdir(parents=True)
|
||||
|
||||
outside_file = tmp_path / "outside.txt"
|
||||
outside_file.write_text("OUTSIDE-FILE-MARKER", encoding="utf-8")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"claude",
|
||||
[{"name": "speckit.myext.hello", "file": _resolve_payload(bad_file, outside_file), "aliases": []}],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert registered == []
|
||||
_assert_no_marker_leak(project, "OUTSIDE-FILE-MARKER")
|
||||
|
||||
@pytest.mark.parametrize("bad_file", FILE_FIELD_PAYLOADS)
|
||||
def test_gemini_skips_traversal_in_file_field(self, tmp_path, bad_file):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
|
||||
outside_file = tmp_path / "outside.txt"
|
||||
outside_file.write_text("OUTSIDE-FILE-MARKER", encoding="utf-8")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"gemini",
|
||||
[{"name": "speckit.myext.hello", "file": _resolve_payload(bad_file, outside_file), "aliases": []}],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert registered == []
|
||||
_assert_no_marker_leak(project, "OUTSIDE-FILE-MARKER")
|
||||
|
||||
@pytest.mark.parametrize("bad_value", [None, 123, "", ["x"]])
|
||||
def test_non_string_file_is_skipped(self, tmp_path, bad_value):
|
||||
"""A non-string/empty ``file`` must be skipped, not raise TypeError."""
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"gemini",
|
||||
[{"name": "speckit.myext.hello", "file": bad_value, "aliases": []}],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert registered == []
|
||||
|
||||
def test_dotdot_rejected_even_when_target_is_in_bounds(self, tmp_path):
|
||||
"""An in-bounds ``..`` payload is rejected by the ``..`` check itself.
|
||||
|
||||
``commands/../cmd.md`` resolves to ``ext_dir/cmd.md`` — inside
|
||||
source_dir — so the resolve()/relative_to() containment backstop would
|
||||
allow it. Creating that target file ensures the command is skipped
|
||||
because of the ``..`` rejection, not merely because the file is absent.
|
||||
"""
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
(ext_dir / "cmd.md").write_text(
|
||||
"---\ndescription: test\n---\n\nbody\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"gemini",
|
||||
[{"name": "speckit.myext.hello", "file": "commands/../cmd.md", "aliases": []}],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert registered == []
|
||||
|
||||
|
||||
class TestRelativeExtensionPathPolicy:
|
||||
"""Unit tests for the shared ``relative_extension_path_violation`` policy."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"commands/hello.md",
|
||||
"hello.md",
|
||||
"a/b/c/hello.md",
|
||||
],
|
||||
)
|
||||
def test_safe_relative_paths_have_no_violation(self, value):
|
||||
assert relative_extension_path_violation(value) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
None,
|
||||
123,
|
||||
["x"],
|
||||
"",
|
||||
" ",
|
||||
" hello.md",
|
||||
"hello.md ",
|
||||
"/abs/outside.md",
|
||||
"/etc/passwd",
|
||||
"C:foo.md",
|
||||
"C:\\Windows\\system32",
|
||||
"\\\\server\\share\\x.md",
|
||||
"../escape.md",
|
||||
"commands/../../escape.md",
|
||||
],
|
||||
)
|
||||
def test_unsafe_values_report_violation(self, value):
|
||||
assert relative_extension_path_violation(value) is not None
|
||||
|
||||
|
||||
class TestReadSkipWarning:
|
||||
"""Unregisterable but in-bounds files warn instead of failing silently."""
|
||||
|
||||
def test_unreadable_target_warns_and_skips(self, tmp_path):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
(ext_dir / "cmd.md").write_bytes(b"\xff\xfe\x00\x80bad")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.warns(UserWarning):
|
||||
registered = registrar.register_commands(
|
||||
"gemini",
|
||||
[{"name": "speckit.myext.hello", "file": "cmd.md", "aliases": []}],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert registered == []
|
||||
|
||||
def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path):
|
||||
"""Lexical check must not block legitimately symlinked sub-directories.
|
||||
|
||||
@@ -342,6 +342,73 @@ class TestExpressions:
|
||||
"{{ steps.emit.output.stdout | " + bad + " }}", ctx
|
||||
)
|
||||
|
||||
def test_filter_unknown_name_raises(self):
|
||||
# An unregistered filter name must fail loudly rather than silently
|
||||
# returning the unfiltered value (which hides a typo / unsupported
|
||||
# filter as a wrong result).
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"items": [1, 2, 3]})
|
||||
with pytest.raises(ValueError, match="unknown filter 'length'"):
|
||||
evaluate_expression("{{ inputs.items | length }}", ctx)
|
||||
|
||||
def test_filter_unknown_name_with_args_raises(self):
|
||||
# The unknown-filter path must also catch the `name(arg)` form, which
|
||||
# otherwise falls through the recognized-args branch silently.
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"text": "hello"})
|
||||
with pytest.raises(ValueError, match="unknown filter 'upper'"):
|
||||
evaluate_expression("{{ inputs.text | upper('x') }}", ctx)
|
||||
|
||||
def test_registered_filters_unaffected(self):
|
||||
# Regression: all five registered filters keep working unchanged.
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(
|
||||
inputs={
|
||||
"tags": ["a", "b", "c"],
|
||||
"text": "hello world",
|
||||
"missing": "",
|
||||
"rows": [{"id": "a"}, {"id": "b"}],
|
||||
},
|
||||
steps={"emit": {"output": {"stdout": '{"n": 1}'}}},
|
||||
)
|
||||
assert (
|
||||
evaluate_expression("{{ inputs.missing | default('fb') }}", ctx) == "fb"
|
||||
)
|
||||
assert evaluate_expression("{{ inputs.tags | join(', ') }}", ctx) == "a, b, c"
|
||||
assert evaluate_expression("{{ inputs.rows | map('id') }}", ctx) == ["a", "b"]
|
||||
assert (
|
||||
evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True
|
||||
)
|
||||
assert evaluate_expression(
|
||||
"{{ steps.emit.output.stdout | from_json }}", ctx
|
||||
) == {"n": 1}
|
||||
|
||||
def test_registered_filter_unsupported_form_raises(self):
|
||||
# A *registered* filter used in an unsupported form (e.g. `| join` with
|
||||
# no argument) must fail loudly with a message that names it as a known
|
||||
# filter misused, not as an "unknown filter".
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext(inputs={"tags": ["a", "b", "c"]})
|
||||
with pytest.raises(
|
||||
ValueError, match="filter 'join' used in an unsupported form"
|
||||
):
|
||||
evaluate_expression("{{ inputs.tags | join }}", ctx)
|
||||
with pytest.raises(
|
||||
ValueError, match="filter 'map' used in an unsupported form"
|
||||
):
|
||||
evaluate_expression("{{ inputs.tags | map }}", ctx)
|
||||
|
||||
def test_condition_evaluation(self):
|
||||
from specify_cli.workflows.expressions import evaluate_condition
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -5341,3 +5408,234 @@ steps:
|
||||
assert resumed.exit_code == 1, resumed.stdout
|
||||
payload = _json.loads(resumed.stdout)
|
||||
assert payload["status"] == "failed"
|
||||
|
||||
|
||||
class TestWorkflowRunGateOutcomeJson:
|
||||
"""CLI-level tests: the --json payload surfaces gate pauses."""
|
||||
|
||||
_WF_GATE = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "gate-json"
|
||||
name: "Gate JSON"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: review
|
||||
type: gate
|
||||
message: "Approve the thing?"
|
||||
options: ["approve", "reject"]
|
||||
"""
|
||||
|
||||
_WF_PLAIN = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "plain-json"
|
||||
name: "Plain JSON"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: fine
|
||||
type: shell
|
||||
run: "exit 0"
|
||||
"""
|
||||
|
||||
def _run_json(self, tmp_path, monkeypatch, content, *, expected_exit=0):
|
||||
import json as _json
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
path = tmp_path / "wf.yml"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = CliRunner().invoke(app, ["workflow", "run", str(path), "--json"])
|
||||
# Assert the expected exit code before parsing so a real failure
|
||||
# surfaces the actual output instead of an opaque JSON decode error.
|
||||
# A terminal run still emits its JSON payload, then exits non-zero on
|
||||
# ``failed``/``aborted`` (see ``_run_outcome_exit_code``), so callers
|
||||
# pass the expected code. Use ``result.output`` for the message:
|
||||
# under ``--json`` step output is redirected off stdout, so the useful
|
||||
# diagnostics live there.
|
||||
assert result.exit_code == expected_exit, result.output
|
||||
return _json.loads(result.stdout)
|
||||
|
||||
def test_gate_pause_carries_gate_block(self, tmp_path, monkeypatch):
|
||||
# CliRunner stdin is not a TTY, so the gate pauses for resume.
|
||||
payload = self._run_json(tmp_path, monkeypatch, self._WF_GATE)
|
||||
assert payload["status"] == "paused"
|
||||
assert payload["gate"] == {
|
||||
"step_id": "review",
|
||||
"message": "Approve the thing?",
|
||||
"options": ["approve", "reject"],
|
||||
"choice": None,
|
||||
}
|
||||
|
||||
def test_completed_run_has_no_gate_block(self, tmp_path, monkeypatch):
|
||||
payload = self._run_json(tmp_path, monkeypatch, self._WF_PLAIN)
|
||||
assert payload["status"] == "completed"
|
||||
assert "gate" not in payload
|
||||
|
||||
def test_gate_abort_carries_gate_block(self, tmp_path, monkeypatch):
|
||||
# An interactive gate the operator rejects ends the run as `aborted`
|
||||
# (on_reject defaults to abort), not `paused`. The JSON surface must
|
||||
# still carry the gate block with the recorded choice so an
|
||||
# orchestrator can see *why* the run stopped. A gate abort emits the
|
||||
# payload and then exits non-zero (aborted → exit 1), so the helper
|
||||
# is told to expect exit code 1.
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
|
||||
_force_gate_stdin(monkeypatch, tty=True)
|
||||
monkeypatch.setattr(
|
||||
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "reject")
|
||||
)
|
||||
payload = self._run_json(
|
||||
tmp_path, monkeypatch, self._WF_GATE, expected_exit=1
|
||||
)
|
||||
assert payload["status"] == "aborted"
|
||||
assert payload["gate"] == {
|
||||
"step_id": "review",
|
||||
"message": "Approve the thing?",
|
||||
"options": ["approve", "reject"],
|
||||
"choice": "reject",
|
||||
}
|
||||
|
||||
def test_gate_block_emitted_only_when_run_rests_at_gate(self):
|
||||
# A run rests *on* a gate only while `paused` (awaiting a decision) or
|
||||
# `aborted` (gate rejected with on_reject: abort). current_step_id is
|
||||
# not cleared afterwards, so a `completed`/`failed` run whose last
|
||||
# executed step was a gate must NOT surface a stale gate block.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
|
||||
gate_step = {
|
||||
"type": "gate",
|
||||
"output": {
|
||||
"message": "m",
|
||||
"options": ["approve", "reject"],
|
||||
"choice": "reject",
|
||||
},
|
||||
}
|
||||
|
||||
def _state(status):
|
||||
return SimpleNamespace(
|
||||
status=SimpleNamespace(value=status),
|
||||
current_step_id="review",
|
||||
step_results={"review": gate_step},
|
||||
)
|
||||
|
||||
assert _gate_outcome(_state("completed")) is None
|
||||
assert _gate_outcome(_state("failed")) is None
|
||||
assert _gate_outcome(_state("paused")) is not None
|
||||
assert _gate_outcome(_state("aborted")) is not None
|
||||
|
||||
def test_gate_block_message_coerced_to_string(self):
|
||||
# message may be a non-string YAML literal (e.g. a number); the JSON
|
||||
# surface normalises it so the emitted schema stays stable.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
current_step_id="review",
|
||||
step_results={
|
||||
"review": {
|
||||
"type": "gate",
|
||||
"output": {"message": 12.5, "options": ["ok"], "choice": None},
|
||||
}
|
||||
},
|
||||
)
|
||||
assert _gate_outcome(state)["message"] == "12.5"
|
||||
|
||||
def test_gate_block_options_coerced_to_strings(self):
|
||||
# options may be non-string / non-list literals in an unvalidated
|
||||
# workflow; the JSON surface always normalises them to list[str] | None
|
||||
# so the emitted schema is stable regardless of the input shape.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
|
||||
def _options_payload(options):
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
current_step_id="review",
|
||||
step_results={
|
||||
"review": {
|
||||
"type": "gate",
|
||||
"output": {
|
||||
"message": "m",
|
||||
"options": options,
|
||||
"choice": None,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
return _gate_outcome(state)["options"]
|
||||
|
||||
assert _options_payload([1, 2.5]) == ["1", "2.5"] # list
|
||||
assert _options_payload(("approve", "reject")) == ["approve", "reject"] # tuple
|
||||
assert _options_payload("approve") == ["approve"] # bare scalar, not iterated
|
||||
assert _options_payload(7) == ["7"] # numeric scalar
|
||||
assert _options_payload(None) is None # absent stays absent
|
||||
|
||||
def test_gate_block_choice_coerced_to_string(self):
|
||||
# An unvalidated gate can record a non-string choice; the JSON
|
||||
# surface normalises it to str (and keeps None = no decision yet),
|
||||
# consistent with the message/options normalization.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
|
||||
def _choice_payload(choice):
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
current_step_id="review",
|
||||
step_results={
|
||||
"review": {
|
||||
"type": "gate",
|
||||
"output": {"message": "m", "options": ["ok"], "choice": choice},
|
||||
}
|
||||
},
|
||||
)
|
||||
return _gate_outcome(state)["choice"]
|
||||
|
||||
assert _choice_payload(None) is None # no decision yet
|
||||
assert _choice_payload("reject") == "reject" # normal string passes through
|
||||
assert _choice_payload(2) == "2" # non-string coerced
|
||||
|
||||
def test_gate_block_detected_without_type_field(self):
|
||||
# A run paused by an older version has no persisted step `type`. The
|
||||
# gate is still detected by its unique output signature (`on_reject`),
|
||||
# so resume surfaces the gate block instead of silently dropping it.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
current_step_id="review",
|
||||
step_results={
|
||||
"review": {
|
||||
# no "type" key — pre-dates the field being persisted
|
||||
"output": {
|
||||
"message": "Approve?",
|
||||
"options": ["approve", "reject"],
|
||||
"on_reject": "abort",
|
||||
"choice": None,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
gate = _gate_outcome(state)
|
||||
assert gate is not None
|
||||
assert gate["step_id"] == "review"
|
||||
assert gate["options"] == ["approve", "reject"]
|
||||
|
||||
def test_non_gate_step_without_type_is_not_a_gate(self):
|
||||
# A typeless record lacking the gate signature must NOT be mistaken for
|
||||
# a gate (the fallback keys off `on_reject`, which only GateStep writes).
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
current_step_id="run-tests",
|
||||
step_results={
|
||||
"run-tests": {"output": {"exit_code": 0, "stdout": "ok"}},
|
||||
},
|
||||
)
|
||||
assert _gate_outcome(state) is None
|
||||
|
||||
71
tests/unit/test_bundler_adapters.py
Normal file
71
tests/unit/test_bundler_adapters.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Unit tests for catalog-fetch adapters (auth + redirect safety)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy
|
||||
from specify_cli.bundler.services import adapters
|
||||
|
||||
|
||||
def _source(url: str) -> CatalogSource:
|
||||
return CatalogSource(
|
||||
id="team",
|
||||
url=url,
|
||||
priority=10,
|
||||
install_policy=InstallPolicy.INSTALL_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, body: bytes, final_url: str) -> None:
|
||||
self._body = body
|
||||
self._final_url = final_url
|
||||
|
||||
def __enter__(self) -> "_FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> bool:
|
||||
return False
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self._final_url
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
|
||||
def test_http_fetch_uses_shared_client_and_rejects_redirect_downgrade(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
|
||||
captured["url"] = url
|
||||
captured["validator"] = redirect_validator
|
||||
return _FakeResponse(b'{"schema_version": "1.0"}', url)
|
||||
|
||||
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
||||
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
result = fetcher(_source("https://example.com/c.json"))
|
||||
assert result == {"schema_version": "1.0"}
|
||||
assert captured["url"] == "https://example.com/c.json"
|
||||
|
||||
# The validator handed to open_url must reject an HTTP downgrade redirect.
|
||||
validator = captured["validator"]
|
||||
assert validator is not None
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
validator("https://example.com/c.json", "http://evil.example/c.json")
|
||||
# And a same-scheme HTTPS redirect is allowed (no raise).
|
||||
validator("https://example.com/c.json", "https://cdn.example/c.json")
|
||||
|
||||
|
||||
def test_http_fetch_rejects_non_https_final_url(monkeypatch):
|
||||
def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None):
|
||||
# Simulate a response whose final URL silently downgraded to HTTP.
|
||||
return _FakeResponse(b"{}", "http://evil.example/c.json")
|
||||
|
||||
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
|
||||
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
fetcher(_source("https://example.com/c.json"))
|
||||
181
tests/unit/test_bundler_catalog_config.py
Normal file
181
tests/unit/test_bundler_catalog_config.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Unit tests for project catalog-config id derivation and url canonicalization."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.commands_impl import catalog_config as cc
|
||||
|
||||
|
||||
def test_derive_id_incorporates_path_stem_for_same_host():
|
||||
# Two catalogs on the same host must not collide on the derived id.
|
||||
a = cc._derive_id("https://example.com/team-a.json")
|
||||
b = cc._derive_id("https://example.com/team-b.json")
|
||||
assert a == "example-com-team-a"
|
||||
assert b == "example-com-team-b"
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_derive_id_distinguishes_tlds():
|
||||
# Different TLDs sharing a second-level label must not collide.
|
||||
com = cc._derive_id("https://example.com/team-a.json")
|
||||
net = cc._derive_id("https://example.net/team-a.json")
|
||||
assert com == "example-com-team-a"
|
||||
assert net == "example-net-team-a"
|
||||
assert com != net
|
||||
|
||||
|
||||
def test_derive_id_falls_back_to_host_when_no_path():
|
||||
assert cc._derive_id("https://example.com/") == "example-com"
|
||||
|
||||
|
||||
def test_derive_id_for_local_path_uses_stem():
|
||||
assert cc._derive_id("./catalogs/my-catalog.json") == "my-catalog"
|
||||
|
||||
|
||||
def test_canonicalize_makes_relative_local_path_absolute(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "local.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
result = cc._canonicalize_url("local.json")
|
||||
|
||||
assert Path(result).is_absolute()
|
||||
assert Path(result) == (tmp_path / "local.json").resolve()
|
||||
|
||||
|
||||
def test_canonicalize_leaves_remote_urls_untouched():
|
||||
for url in (
|
||||
"https://example.com/c.json",
|
||||
"http://localhost:8080/c.json",
|
||||
"file:///tmp/c.json",
|
||||
"builtin://default",
|
||||
):
|
||||
assert cc._canonicalize_url(url) == url
|
||||
|
||||
|
||||
def test_add_source_persists_absolute_local_path(tmp_path: Path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
catalog = project / "sub" / "cat.json"
|
||||
catalog.parent.mkdir()
|
||||
catalog.write_text("{}", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project)
|
||||
source = cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50)
|
||||
|
||||
assert Path(source.url).is_absolute()
|
||||
assert Path(source.url) == catalog.resolve()
|
||||
|
||||
|
||||
def test_add_source_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
cc.add_source(project, "https://example.com/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_read_rejects_non_list_catalogs(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.0'\ncatalogs: not-a-list\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="'catalogs' must be a list"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_non_mapping_catalog_entry(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.0'\ncatalogs:\n - just-a-string\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="each catalog entry must be a mapping"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_non_mapping_top_level(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text("- a\n- b\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_unknown_schema_version(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '2.0'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(BundlerError, match="Unsupported catalog config schema version"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_accepts_forward_compatible_minor_schema(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(
|
||||
"schema_version: '1.5'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_read_tolerates_missing_schema_version(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text("catalogs: []\n", encoding="utf-8")
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_read_returns_empty_for_missing_or_empty_config(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
assert cc._read(project) == []
|
||||
|
||||
cc._config_path(project).write_text("schema_version: '1.0'\n", encoding="utf-8")
|
||||
assert cc._read(project) == []
|
||||
|
||||
|
||||
def test_slug_lowercases_for_deterministic_ids():
|
||||
# Mixed-case local filenames must derive the same id regardless of case so
|
||||
# the case-sensitive duplicate check cannot admit logical duplicates.
|
||||
assert cc._slug("Team-A") == "team-a"
|
||||
assert cc._derive_id("./catalogs/Team-A.json") == "team-a"
|
||||
assert cc._derive_id("https://Example.com/Team-A.json") == "example-com-team-a"
|
||||
|
||||
|
||||
def test_derive_id_handles_ipv6_literal():
|
||||
# An IPv6 host must not be truncated at the first colon.
|
||||
derived = cc._derive_id("https://[2001:db8::1]/catalog.json")
|
||||
assert derived == "2001-db8--1-catalog"
|
||||
|
||||
|
||||
def test_derive_id_ignores_credentials_and_port():
|
||||
assert cc._derive_id("https://user:pw@example.com:8443/c.json") == "example-com-c"
|
||||
|
||||
|
||||
def test_add_source_rejects_unsupported_scheme(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="Unsupported catalog url scheme"):
|
||||
cc.add_source(project, "ssh://host/catalog.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
monkeypatch.chdir(project)
|
||||
# A relative path containing ':' but no '://' is still a local path.
|
||||
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
|
||||
assert source.url.endswith("weird:name.json") or "weird" in source.url
|
||||
54
tests/unit/test_bundler_conflict.py
Normal file
54
tests/unit/test_bundler_conflict.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Unit tests for conflict detection (T034): integration clash and overlap precedence."""
|
||||
from __future__ import annotations
|
||||
|
||||
from specify_cli.bundler.models.manifest import BundleManifest, ComponentRef
|
||||
from specify_cli.bundler.models.records import InstalledBundleRecord
|
||||
from specify_cli.bundler.services.conflict import detect_conflicts
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_integration_clash_is_blocking():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is True
|
||||
assert "claude" in report.integration_clash
|
||||
assert "copilot" in report.integration_clash
|
||||
|
||||
|
||||
def test_matching_integration_no_clash():
|
||||
manifest = _manifest(integration={"id": "copilot"})
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_agnostic_bundle_never_clashes():
|
||||
manifest = _manifest() # no integration
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[])
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_overlap_with_other_bundle_is_reported():
|
||||
manifest = _manifest()
|
||||
other = InstalledBundleRecord.create(
|
||||
bundle_id="other",
|
||||
version="1.0.0",
|
||||
components=[ComponentRef(kind="presets", id="preset-a")],
|
||||
)
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[other])
|
||||
assert any("preset-a" in o and "other" in o for o in report.overlaps)
|
||||
assert report.has_blocking_conflict is False
|
||||
|
||||
|
||||
def test_same_bundle_reinstall_is_not_overlap():
|
||||
manifest = _manifest()
|
||||
same = InstalledBundleRecord.create(
|
||||
bundle_id="demo-bundle",
|
||||
version="1.2.0",
|
||||
components=[ComponentRef(kind="presets", id="preset-a")],
|
||||
)
|
||||
report = detect_conflicts(manifest, active_integration="copilot", installed=[same])
|
||||
assert report.overlaps == []
|
||||
193
tests/unit/test_bundler_packager.py
Normal file
193
tests/unit/test_bundler_packager.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for the artifact packager (T023): contents, versioning, determinism."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _make_bundle(directory: Path, *, extra_files: dict | None = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(directory / "README.md").write_text("# Demo bundle", encoding="utf-8")
|
||||
for rel, content in (extra_files or {}).items():
|
||||
target = directory / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
def test_artifact_named_by_id_and_version(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b")
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
assert result.artifact_path.name == "demo-bundle-1.2.0.zip"
|
||||
|
||||
|
||||
def test_artifact_contains_manifest_and_assets(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"assets/logo.txt": "logo"})
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "bundle.yml" in names
|
||||
assert "README.md" in names
|
||||
assert "assets/logo.txt" in names
|
||||
|
||||
|
||||
def test_build_refuses_invalid_manifest(tmp_path: Path):
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir()
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
(bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle / "README.md").write_text("# x", encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="validate"):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_build_missing_manifest_errors(tmp_path: Path):
|
||||
with pytest.raises(BundlerError, match="No bundle.yml"):
|
||||
build_bundle(tmp_path, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_build_is_deterministic(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a", "z.txt": "z"})
|
||||
first = build_bundle(bundle, output_dir=tmp_path / "out1")
|
||||
second = build_bundle(bundle, output_dir=tmp_path / "out2")
|
||||
with zipfile.ZipFile(first.artifact_path) as a, zipfile.ZipFile(second.artifact_path) as b:
|
||||
# Same files, same order (sorted).
|
||||
assert a.namelist() == b.namelist()
|
||||
# Fixed timestamps + permissions make each member byte-identical.
|
||||
for left, right in zip(a.infolist(), b.infolist()):
|
||||
assert left.date_time == right.date_time
|
||||
assert left.external_attr == right.external_attr
|
||||
# The whole artifact is byte-for-byte reproducible.
|
||||
assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes()
|
||||
|
||||
|
||||
def test_output_dir_inside_bundle_excludes_prior_artifacts(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
out_dir = bundle / "dist"
|
||||
# Build twice into a dir nested in the bundle; the second build must not
|
||||
# re-package the first artifact, so contents stay identical and bounded.
|
||||
first = build_bundle(bundle, output_dir=out_dir)
|
||||
second = build_bundle(bundle, output_dir=out_dir)
|
||||
with zipfile.ZipFile(second.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert not any(name.startswith("dist/") for name in names)
|
||||
assert not any(name.endswith(".zip") for name in names)
|
||||
assert first.file_count == second.file_count
|
||||
|
||||
|
||||
def test_prior_version_artifact_not_repackaged(tmp_path: Path):
|
||||
# An older artifact sitting next to bundle.yml must not be packaged.
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
(bundle / "demo-bundle-0.9.0.zip").write_bytes(b"PK\x03\x04 old artifact")
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert not any(name.endswith(".zip") for name in names)
|
||||
assert "demo-bundle-0.9.0.zip" not in names
|
||||
|
||||
|
||||
def test_symlinked_directory_is_not_followed(tmp_path: Path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("secret", encoding="utf-8")
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
|
||||
link = bundle / "linkdir"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
# Build must succeed (no ensure_within failure) and must not pull in the
|
||||
# out-of-tree file behind the symlinked directory.
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert "linkdir/secret.txt" not in names
|
||||
assert not any("secret" in name for name in names)
|
||||
|
||||
|
||||
def test_unsafe_bundle_id_is_rejected_before_build(tmp_path: Path):
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "../evil"
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir(parents=True)
|
||||
(bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle / "README.md").write_text("# x", encoding="utf-8")
|
||||
with pytest.raises(BundlerError):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
# The traversal target must not have been written outside out_dir.
|
||||
assert not (tmp_path / "evil-1.2.0.zip").exists()
|
||||
|
||||
|
||||
def test_build_refuses_missing_readme(tmp_path: Path):
|
||||
bundle = tmp_path / "b"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="README.md"):
|
||||
build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_asset_zip_starting_with_bundle_id_is_packaged(tmp_path: Path):
|
||||
# A non-artifact asset whose name merely starts with the bundle id (but is
|
||||
# not a semver-named build artifact) must still be included.
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-assets.zip": "data"})
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-assets.zip" in names
|
||||
|
||||
|
||||
def test_prior_semver_artifact_is_excluded(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-0.9.0.zip": "old"})
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-0.9.0.zip" not in names
|
||||
|
||||
|
||||
def test_prior_artifact_with_prerelease_and_build_is_excluded(tmp_path: Path):
|
||||
# A semver artifact carrying both prerelease and build metadata must still
|
||||
# be recognized as a prior build artifact and excluded.
|
||||
bundle = _make_bundle(
|
||||
tmp_path / "b", extra_files={"demo-bundle-1.0.0-rc1+build5.zip": "old"}
|
||||
)
|
||||
result = build_bundle(bundle, output_dir=bundle)
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert "demo-bundle-1.0.0-rc1+build5.zip" not in names
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="Windows filesystems do not carry Unix execute bits, so chmod(0o755) "
|
||||
"is a no-op and there is no executability to preserve.",
|
||||
)
|
||||
def test_executable_bit_preserved_in_artifact(tmp_path: Path):
|
||||
bundle = _make_bundle(tmp_path / "bundle")
|
||||
script = bundle / "scripts" / "hook.sh"
|
||||
script.parent.mkdir(parents=True, exist_ok=True)
|
||||
script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
|
||||
script.chmod(0o755)
|
||||
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
modes = {
|
||||
info.filename: (info.external_attr >> 16) & 0o777
|
||||
for info in archive.infolist()
|
||||
}
|
||||
# Executable source -> 0755; plain text files -> 0644.
|
||||
assert modes["scripts/hook.sh"] == 0o755
|
||||
assert modes["README.md"] == 0o644
|
||||
133
tests/unit/test_bundler_primitives.py
Normal file
133
tests/unit/test_bundler_primitives.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Unit tests for the primitive-dispatch bridge (T044).
|
||||
|
||||
Covers routing, offline gating, and the network-aware ``DefaultPrimitiveInstaller``
|
||||
seam — without touching real catalogs or the network (Constitution Principle II,
|
||||
offline-first).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
|
||||
from specify_cli.bundler.services.primitives import (
|
||||
_ExtensionKindManager,
|
||||
_PresetKindManager,
|
||||
_StepKindManager,
|
||||
_WorkflowKindManager,
|
||||
primitive_manager,
|
||||
)
|
||||
|
||||
|
||||
def _component(kind: str, cid: str = "x") -> ComponentRef:
|
||||
return ComponentRef(kind=kind, id=cid)
|
||||
|
||||
|
||||
def test_primitive_manager_routes_each_kind(tmp_path: Path):
|
||||
assert isinstance(primitive_manager("presets", tmp_path), _PresetKindManager)
|
||||
assert isinstance(primitive_manager("extensions", tmp_path), _ExtensionKindManager)
|
||||
assert isinstance(primitive_manager("workflows", tmp_path), _WorkflowKindManager)
|
||||
assert isinstance(primitive_manager("steps", tmp_path), _StepKindManager)
|
||||
|
||||
|
||||
def test_primitive_manager_rejects_unknown_kind(tmp_path: Path):
|
||||
with pytest.raises(BundlerError, match="Unknown component kind"):
|
||||
primitive_manager("bogus", tmp_path)
|
||||
|
||||
|
||||
def test_offline_preset_not_bundled_refuses(tmp_path: Path):
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("presets", "definitely-not-bundled"))
|
||||
|
||||
|
||||
def test_offline_extension_not_bundled_refuses(tmp_path: Path):
|
||||
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("extensions", "definitely-not-bundled"))
|
||||
|
||||
|
||||
def test_offline_workflow_refuses_without_network(tmp_path: Path):
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("workflows"))
|
||||
|
||||
|
||||
def test_offline_step_refuses_without_network(tmp_path: Path):
|
||||
manager = primitive_manager("steps", tmp_path, allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
manager.install(_component("steps"))
|
||||
|
||||
|
||||
def test_default_installer_threads_allow_network(tmp_path: Path):
|
||||
installer = DefaultPrimitiveInstaller(allow_network=False)
|
||||
with pytest.raises(BundlerError, match="network access is disabled"):
|
||||
installer.install(tmp_path, _component("workflows"))
|
||||
|
||||
|
||||
def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch):
|
||||
# A workflow that ships with Spec Kit must install even with --offline.
|
||||
import specify_cli
|
||||
import specify_cli._assets as assets
|
||||
|
||||
monkeypatch.setattr(
|
||||
assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf"
|
||||
)
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(specify_cli, "workflow_add", lambda wid: calls.append(wid))
|
||||
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=False)
|
||||
manager.install(_component("workflows", "bundled-wf"))
|
||||
|
||||
assert calls == ["bundled-wf"]
|
||||
|
||||
|
||||
def test_assert_pinned_version_matches_passes():
|
||||
from specify_cli.bundler.services.primitives import _assert_pinned_version
|
||||
|
||||
# Equal (including v-prefix/normalization) is accepted; no version pins are no-ops.
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", "2.0.0")
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", "v2.0.0")
|
||||
_assert_pinned_version("Preset", "p", None, "9.9.9")
|
||||
_assert_pinned_version("Preset", "p", "2.0.0", None)
|
||||
|
||||
|
||||
def test_assert_pinned_version_mismatch_raises():
|
||||
from specify_cli.bundler.services.primitives import _assert_pinned_version
|
||||
|
||||
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
||||
_assert_pinned_version("Preset", "preset-a", "2.0.0", "3.1.0")
|
||||
|
||||
|
||||
def test_workflow_version_mismatch_refuses(tmp_path: Path, monkeypatch):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
monkeypatch.setattr(
|
||||
WorkflowCatalog, "get_workflow_info", lambda self, wid: {"version": "9.9.9"}
|
||||
)
|
||||
manager = primitive_manager("workflows", tmp_path, allow_network=True)
|
||||
component = ComponentRef(kind="workflows", id="wf-a", version="0.3.0")
|
||||
with pytest.raises(BundlerError, match="pinned to version 0.3.0"):
|
||||
manager.install(component)
|
||||
|
||||
|
||||
def test_preset_install_preserves_explicit_zero_priority(tmp_path: Path, monkeypatch):
|
||||
import specify_cli._assets as assets
|
||||
|
||||
calls = {}
|
||||
|
||||
class _FakeManager:
|
||||
def install_from_directory(self, directory, speckit_version, priority):
|
||||
calls["priority"] = priority
|
||||
|
||||
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: tmp_path)
|
||||
|
||||
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
||||
manager._manager = _FakeManager()
|
||||
manager.install(ComponentRef(kind="presets", id="p", priority=0))
|
||||
|
||||
# An explicit priority of 0 must be passed through, not replaced by default.
|
||||
assert calls["priority"] == 0
|
||||
190
tests/unit/test_bundler_records.py
Normal file
190
tests/unit/test_bundler_records.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Unit tests for installed-bundle records and collateral-protection logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.models.records import (
|
||||
InstalledBundleRecord,
|
||||
components_still_needed,
|
||||
load_records,
|
||||
records_path,
|
||||
remove_record,
|
||||
save_records,
|
||||
upsert_record,
|
||||
)
|
||||
|
||||
|
||||
def _record(bundle_id: str, comps) -> InstalledBundleRecord:
|
||||
return InstalledBundleRecord.create(
|
||||
bundle_id=bundle_id,
|
||||
version="1.0.0",
|
||||
components=[ComponentRef(kind=k, id=i) for k, i in comps],
|
||||
)
|
||||
|
||||
|
||||
def test_save_and_load_roundtrip(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
rec = _record("a", [("presets", "p1"), ("steps", "s1")])
|
||||
save_records(tmp_path, [rec])
|
||||
loaded = load_records(tmp_path)
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].bundle_id == "a"
|
||||
assert {(c.kind, c.id) for c in loaded[0].contributed_components} == {
|
||||
("presets", "p1"),
|
||||
("steps", "s1"),
|
||||
}
|
||||
|
||||
|
||||
def test_load_missing_file_returns_empty(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
assert load_records(tmp_path) == []
|
||||
|
||||
|
||||
def test_corrupt_priority_raises_actionable_error(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
rec = _record("a", [("presets", "p1")])
|
||||
save_records(tmp_path, [rec])
|
||||
path = records_path(tmp_path)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data["bundles"][0]["contributed_components"][0]["priority"] = "high"
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="priority must be an integer"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_upsert_replaces_same_id():
|
||||
rec1 = _record("a", [("presets", "p1")])
|
||||
rec2 = _record("a", [("presets", "p2")])
|
||||
result = upsert_record([rec1], rec2)
|
||||
assert len(result) == 1
|
||||
assert result[0].contributed_components[0].id == "p2"
|
||||
|
||||
|
||||
def test_remove_record_drops_target():
|
||||
recs = [_record("a", [("presets", "p1")]), _record("b", [("steps", "s1")])]
|
||||
result = remove_record(recs, "a")
|
||||
assert [r.bundle_id for r in result] == ["b"]
|
||||
|
||||
|
||||
def test_components_still_needed_excludes_target():
|
||||
recs = [
|
||||
_record("a", [("presets", "shared"), ("steps", "only-a")]),
|
||||
_record("b", [("presets", "shared")]),
|
||||
]
|
||||
needed = components_still_needed(recs, exclude_bundle_id="a")
|
||||
assert ("presets", "shared") in needed
|
||||
assert ("steps", "only-a") not in needed
|
||||
|
||||
|
||||
def test_save_records_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
# Defense-in-depth: a symlinked .specify pointing outside the project must
|
||||
# not let records be written outside project_root.
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
save_records(project, [_record("a", [("presets", "p1")])])
|
||||
|
||||
|
||||
def test_load_records_rejects_non_list_bundles(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
path = records_path(tmp_path)
|
||||
path.write_text(json.dumps({"schema_version": "1.0", "bundles": "oops"}), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="'bundles' must be a list"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_non_list_contributed_components(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
path = records_path(tmp_path)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"bundles": [
|
||||
{"bundle_id": "a", "version": "1.0.0", "contributed_components": "oops"}
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="'contributed_components' must be a list"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_unknown_component_kind(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
path = records_path(tmp_path)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"bundles": [
|
||||
{
|
||||
"bundle_id": "a",
|
||||
"version": "1.0.0",
|
||||
"contributed_components": [{"kind": "bogus", "id": "x"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="must be one of"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_component_missing_id(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
path = records_path(tmp_path)
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"bundles": [
|
||||
{
|
||||
"bundle_id": "a",
|
||||
"version": "1.0.0",
|
||||
"contributed_components": [{"kind": "presets", "id": ""}],
|
||||
}
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="missing its 'id'"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_missing_schema_version(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
records_path(tmp_path).write_text(json.dumps({"bundles": []}), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="missing 'schema_version'"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_unknown_schema_version(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
payload = {"schema_version": "2.0", "bundles": []}
|
||||
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="Unsupported records schema version"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_record_missing_bundle_id(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
payload = {"schema_version": "1.0", "bundles": [{"version": "1.0.0"}]}
|
||||
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="missing its 'bundle_id'"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_rejects_record_missing_version(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
payload = {"schema_version": "1.0", "bundles": [{"bundle_id": "a"}]}
|
||||
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="missing its 'version'"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
def test_load_records_accepts_forward_compatible_minor_schema(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
payload = {"schema_version": "1.5", "bundles": []}
|
||||
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
|
||||
assert load_records(tmp_path) == []
|
||||
41
tests/unit/test_bundler_references.py
Normal file
41
tests/unit/test_bundler_references.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Unit tests for the bundle reference checker (T047 / FR-005 / SC-007).
|
||||
|
||||
Resolution is offline-first: bundled and installed components resolve without a
|
||||
network; unknown ids fail online and downgrade to warnings offline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.services.references import make_reference_checker
|
||||
from tests.bundler_helpers import make_project
|
||||
|
||||
|
||||
def _ref(kind: str, id_: str) -> ComponentRef:
|
||||
return ComponentRef(kind=kind, id=id_, version="1.0.0")
|
||||
|
||||
|
||||
def test_bundled_extension_resolves(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=True, warnings=warnings)
|
||||
assert check(_ref("extensions", "agent-context")) is None
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_unknown_reference_errors_online(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=True, warnings=warnings)
|
||||
problem = check(_ref("presets", "does-not-exist"))
|
||||
assert problem is not None
|
||||
assert "does-not-exist" in problem
|
||||
|
||||
|
||||
def test_unknown_reference_warns_offline(tmp_path: Path):
|
||||
root = make_project(tmp_path)
|
||||
warnings: list[str] = []
|
||||
check = make_reference_checker(root, allow_network=False, warnings=warnings)
|
||||
assert check(_ref("presets", "does-not-exist")) is None
|
||||
assert any("does-not-exist" in w for w in warnings)
|
||||
81
tests/unit/test_bundler_resolver.py
Normal file
81
tests/unit/test_bundler_resolver.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for the resolver: version gate and integration compatibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_plan_expands_all_components():
|
||||
plan = resolve_install_plan(
|
||||
_manifest(), speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.component_count == 4
|
||||
assert plan.bundle_id == "demo-bundle"
|
||||
|
||||
|
||||
def test_version_gate_refuses_incompatible():
|
||||
manifest = _manifest(requires={"speckit_version": ">=99.0.0"})
|
||||
with pytest.raises(BundlerError, match="requires Spec Kit"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_integration_clash_halts():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
with pytest.raises(BundlerError, match="active integration"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_agnostic_inherits_active_integration():
|
||||
plan = resolve_install_plan(
|
||||
_manifest(), speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.effective_integration == "copilot"
|
||||
|
||||
|
||||
def test_matching_integration_is_allowed():
|
||||
manifest = _manifest(integration={"id": "copilot"})
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert plan.effective_integration == "copilot"
|
||||
|
||||
|
||||
def test_pinned_integration_with_indeterminate_active_fails():
|
||||
# FR-019 guard: a bundle that pins an integration must not silently adopt it
|
||||
# when the project's active integration cannot be determined.
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
with pytest.raises(BundlerError, match="could not be determined"):
|
||||
resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration=None
|
||||
)
|
||||
|
||||
|
||||
def test_pinned_integration_with_indeterminate_active_allows_explicit_override():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
plan = resolve_install_plan(
|
||||
manifest,
|
||||
speckit_version="0.11.2",
|
||||
active_integration="claude",
|
||||
integration_explicit=True,
|
||||
)
|
||||
assert plan.effective_integration == "claude"
|
||||
|
||||
|
||||
def test_tool_requirements_become_warnings():
|
||||
manifest = _manifest(requires={"speckit_version": ">=0.1.0", "tools": ["docker"]})
|
||||
plan = resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
assert any("docker" in w for w in plan.warnings)
|
||||
32
tests/unit/test_bundler_validator.py
Normal file
32
tests/unit/test_bundler_validator.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Unit tests for the bundle manifest validator service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.services import validator as validator_mod
|
||||
from specify_cli.bundler.services.validator import validate_manifest
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def _manifest(**overrides) -> BundleManifest:
|
||||
return BundleManifest.from_dict(valid_manifest_dict(**overrides))
|
||||
|
||||
|
||||
def test_invalid_speckit_constraint_reported_as_error():
|
||||
manifest = _manifest(requires={"speckit_version": ">>bad"})
|
||||
report = validate_manifest(manifest)
|
||||
assert not report.ok
|
||||
assert any("speckit_version" in e for e in report.errors)
|
||||
|
||||
|
||||
def test_non_bundler_error_not_swallowed(monkeypatch):
|
||||
# A programming error inside constraint parsing must propagate, not be
|
||||
# masked behind an "invalid constraint" validation message.
|
||||
def boom(_value):
|
||||
raise RuntimeError("unexpected bug")
|
||||
|
||||
monkeypatch.setattr(validator_mod, "parse_constraint", boom)
|
||||
manifest = _manifest(requires={"speckit_version": ">=1.0.0"})
|
||||
with pytest.raises(RuntimeError, match="unexpected bug"):
|
||||
validate_manifest(manifest)
|
||||
68
tests/unit/test_bundler_versioning.py
Normal file
68
tests/unit/test_bundler_versioning.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Unit tests for version parsing and constraint satisfaction (FR-016 gate)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.lib.versioning import is_semver, satisfies
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
("1.0.0", True),
|
||||
("0.11.2", True),
|
||||
("1.2.3-rc1", True),
|
||||
("1.2.3-alpha1", True),
|
||||
("1.2.3-beta2", True),
|
||||
("v1.2.3", True),
|
||||
("not-a-version", False),
|
||||
("", False),
|
||||
# packaging.version.Version accepts these partial versions; SemVer must not.
|
||||
("1", False),
|
||||
("1.0", False),
|
||||
("1.2.3.4", False),
|
||||
])
|
||||
def test_is_semver(value, expected):
|
||||
assert is_semver(value) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installed,constraint,ok", [
|
||||
("0.11.2", ">=0.1.0", True),
|
||||
("0.11.2", ">=1.0.0", False),
|
||||
("1.0.0", ">=1.0.0,<2.0.0", True),
|
||||
("2.0.0", ">=1.0.0,<2.0.0", False),
|
||||
("1.5.0", "", True), # empty constraint is permissive
|
||||
# Prerelease spellings normalize consistently for constraint checks.
|
||||
("1.2.3-rc1", ">=1.2.0", True),
|
||||
("1.2.3-alpha1", ">=2.0.0", False),
|
||||
])
|
||||
def test_satisfies(installed, constraint, ok):
|
||||
assert satisfies(installed, constraint) is ok
|
||||
|
||||
|
||||
def test_invalid_constraint_raises():
|
||||
with pytest.raises(BundlerError):
|
||||
satisfies("1.0.0", ">>bad")
|
||||
|
||||
|
||||
def test_uppercase_v_prefix_tolerated():
|
||||
# Mirrors specify_cli._version tag normalization (V -> v).
|
||||
assert is_semver("V1.2.3") is True
|
||||
assert satisfies("V1.2.3", ">=1.2.0") is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installed,constraint,ok", [
|
||||
# Prerelease spellings are now normalized inside constraints too, so a
|
||||
# constraint like ">=1.2.3-rc1" parses (previously raised InvalidSpecifier).
|
||||
("1.2.3-rc2", ">=1.2.3-rc1", True),
|
||||
("1.2.2", ">=1.2.3-rc1", False),
|
||||
("1.5.0", ">=1.2.3-rc1,<2.0.0", True),
|
||||
("1.2.3-beta.1", ">=1.2.3-alpha1", True),
|
||||
])
|
||||
def test_satisfies_prerelease_in_constraint(installed, constraint, ok):
|
||||
assert satisfies(installed, constraint) is ok
|
||||
|
||||
|
||||
def test_parse_constraint_empty_is_permissive():
|
||||
from specify_cli.bundler.lib.versioning import parse_constraint
|
||||
|
||||
assert str(parse_constraint("")) == ""
|
||||
Reference in New Issue
Block a user