Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
690dc28356 chore: bump version to 0.12.14 2026-07-13 22:52:30 +00:00
23 changed files with 305 additions and 7744 deletions

View File

@@ -2,21 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.12.15] - 2026-07-14
### Changed
- Update Autonomous Run Governance preset to v0.1.4 (#3511)
- fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
- fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)
- fix: add trailing newline to init-options.json output (#3509)
- feat(workflows): align workflow CLI with extension command surface (#3419)
- fix(extensions): stop env-var config leaking across prefix-colliding extension IDs (#3497)
- fix(integrations): escape control characters in goose recipe YAML renderer (#3384)
- [extension] Update DocGuard — CDD Enforcement extension to v0.32.0 (#3489)
- [extension] Add Multi-Repo Branch Sync extension to community catalog (#3411)
- chore: release 0.12.14, begin 0.12.15.dev0 development (#3506)
## [0.12.14] - 2026-07-13
### Changed

View File

@@ -51,7 +51,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
@@ -84,7 +84,6 @@ The following community-contributed extensions are available in [`catalog.commun
| MemoryLint | Evidence-driven instruction drift checker: audits agent memory files for boundary, reality, conflict, and redundancy drift. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |

View File

@@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 12 templates, 2 commands, 2 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning. | 10 templates, 2 commands | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |

View File

@@ -86,30 +86,8 @@ Lists workflows installed in the current project.
specify workflow add <source>
```
| Option | Description |
| --------------- | ------------------------------------------------------ |
| `--dev` | Install from a local workflow YAML file or directory |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
## Update Workflows
```bash
specify workflow update [workflow_id]
```
Updates one installed catalog workflow — or all of them when no ID is given — to the latest catalog version. Prompts for confirmation and keeps the installed copy if a download or validation fails.
## Enable or Disable a Workflow
```bash
specify workflow enable <workflow_id>
specify workflow disable <workflow_id>
```
Disabled workflows stay installed and listed (marked `[disabled]`) but refuse to run until re-enabled.
## Remove a Workflow
```bash
@@ -124,10 +102,9 @@ Removes an installed workflow from the project.
specify workflow search [query]
```
| Option | Description |
| ---------- | ----------------- |
| `--tag` | Filter by tag |
| `--author` | Filter by author |
| Option | Description |
| ------- | --------------- |
| `--tag` | Filter by tag |
Searches all active catalogs for workflows matching the query.

View File

@@ -1106,10 +1106,10 @@
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"description": "Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js.",
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
"author": "raccioly",
"version": "0.32.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.32.0/spec-kit-docguard-v0.32.0.zip",
"version": "0.30.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1124,14 +1124,6 @@
"name": "node",
"version": ">=18.0.0",
"required": true
},
{
"name": "npx",
"required": true
},
{
"name": "specify",
"required": false
}
]
},
@@ -1153,7 +1145,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
"updated_at": "2026-07-06T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -2466,48 +2458,6 @@
"created_at": "2026-05-04T02:51:52Z",
"updated_at": "2026-06-18T00:00:00Z"
},
"multi-repo-sync": {
"name": "Multi-Repo Branch Sync",
"id": "multi-repo-sync",
"description": "Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks",
"author": "Fyloss",
"version": "1.0.0",
"download_url": "https://github.com/fyloss/spec-kit-multi-repo-sync/releases/download/v1.0.0/spec-kit-multi-repo-sync.zip",
"sha256": "12a5c7392145b4424b20715aaa3d8b6a8218c143dea596873e344146c1a76ba0",
"repository": "https://github.com/fyloss/spec-kit-multi-repo-sync",
"homepage": "https://github.com/fyloss/spec-kit-multi-repo-sync",
"documentation": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/README.md",
"changelog": "https://github.com/fyloss/spec-kit-multi-repo-sync/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{
"name": "git",
"version": ">=2.31",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 2
},
"tags": [
"git",
"branching",
"multi-repo",
"submodules",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"multi-sites": {
"name": "Multi-Sites Spec Kit",
"id": "multi-sites",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-14T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -134,21 +134,20 @@
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
"id": "autonomous-run-governance",
"version": "0.1.4",
"version": "0.1.1",
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery, convergence, resume, closeout, and retrospective learning.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.4.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.1.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.1.4/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 12,
"commands": 2,
"scripts": 2
"templates": 10,
"commands": 2
},
"tags": [
"autonomous",
@@ -158,7 +157,7 @@
"retrospective"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-14T00:00:00Z"
"updated_at": "2026-07-13T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.15"
version = "0.12.14"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -24,7 +24,6 @@ GITHUB_HOSTS = frozenset({
"api.github.com",
"codeload.github.com",
})
_MAX_RELEASE_METADATA_BYTES = 5 * 1024 * 1024
def build_github_request(url: str) -> urllib.request.Request:
@@ -69,8 +68,6 @@ def resolve_github_release_asset_api_url(
open_url_fn: Callable,
timeout: int = 60,
github_hosts: tuple[str, ...] = (),
redirect_validator: Callable[[str, str], None] | None = None,
max_metadata_bytes: int = _MAX_RELEASE_METADATA_BYTES,
) -> Optional[str]:
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
@@ -94,8 +91,6 @@ def resolve_github_release_asset_api_url(
authenticated release-metadata lookup.
timeout: Per-request timeout in seconds.
github_hosts: Host patterns to treat as GitHub Enterprise Server.
redirect_validator: Optional policy applied to metadata redirects.
max_metadata_bytes: Maximum release-metadata response size.
"""
import json
import urllib.error
@@ -154,33 +149,13 @@ def resolve_github_release_asset_api_url(
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
try:
open_kwargs = {"timeout": timeout}
if redirect_validator is not None:
open_kwargs["redirect_validator"] = redirect_validator
with open_url_fn(release_url, **open_kwargs) as response:
raw_release_data = response.read(max_metadata_bytes + 1)
if len(raw_release_data) > max_metadata_bytes:
raise ValueError("GitHub release metadata exceeds size limit")
release_data = json.loads(raw_release_data)
except (
urllib.error.URLError,
json.JSONDecodeError,
TypeError,
ValueError,
):
with open_url_fn(release_url, timeout=timeout) as response:
release_data = json.loads(response.read())
except (urllib.error.URLError, json.JSONDecodeError):
return None
if not isinstance(release_data, dict):
return None
assets = release_data.get("assets", [])
if not isinstance(assets, list):
return None
for asset in assets:
if (
isinstance(asset, dict)
and asset.get("name") == asset_name
and asset.get("url")
):
for asset in release_data.get("assets", []):
if asset.get("name") == asset_name and asset.get("url"):
return str(asset["url"])
return None

View File

@@ -14,7 +14,7 @@ def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
encoding="utf-8",
)

View File

@@ -10,8 +10,6 @@ from __future__ import annotations
import json
import os
import re
import stat
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any
@@ -89,63 +87,17 @@ def loads_json(text: str, *, origin: str = "<string>") -> Any:
def dump_json(path: Path, data: Any, *, within: Path | None = None) -> Path:
"""Atomically write pretty JSON to *path* (optionally confined to *within*)."""
"""Write *data* as pretty JSON to *path* (optionally confined to *within*)."""
path = Path(path)
if within is not None:
path = ensure_within(within, path)
fd = -1
temp_path: Path | None = None
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
temp_path = Path(temp_name)
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as handle:
with path.open("w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=False)
handle.write("\n")
try:
if path.exists():
existing = path.stat(follow_symlinks=False)
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchmod"):
os.fchmod(fd, stat.S_IMODE(existing.st_mode))
if stat.S_ISREG(existing.st_mode) and hasattr(os, "fchown"):
try:
os.fchown(fd, existing.st_uid, existing.st_gid)
except PermissionError:
pass
except OSError:
pass
staged = os.stat(temp_path, follow_symlinks=False)
opened = os.fstat(fd)
if (
not stat.S_ISREG(staged.st_mode)
or staged.st_dev != opened.st_dev
or staged.st_ino != opened.st_ino
):
raise OSError("staged JSON file changed before commit")
os.close(fd)
fd = -1
os.replace(temp_path, path)
temp_path = None
except OSError as exc:
raise BundlerError(f"Could not write {path}: {exc}") from exc
finally:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
return path

View File

@@ -187,41 +187,19 @@ def remove_bundle(
still_needed = components_still_needed(records, exclude_bundle_id=bundle_id)
result = InstallResult(bundle_id=bundle_id)
remove_attempted = False
try:
for component in target.contributed_components:
key = (component.kind, component.id)
if key in still_needed:
result.skipped.append(component)
continue
if installer.is_installed(project_root, component):
remove_attempted = True
installer.remove(project_root, component)
result.uninstalled.append(component)
save_records(project_root, remove_record(records, bundle_id))
except Exception as exc: # noqa: BLE001
if result.uninstalled:
detail = (
f"{len(result.uninstalled)} component(s) were already removed "
"before this failure; the bundle record was left unchanged, "
"so the project may be partially uninstalled."
)
elif remove_attempted:
detail = (
"No components were removed, but the failing component may "
"have made partial changes before raising, so the project "
"may be partially uninstalled."
)
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:
detail = (
"No components were removed and no removal was attempted; "
"the bundle record was left unchanged."
)
raise BundlerError(
f"Failed to remove bundle '{bundle_id}': {exc}. {detail}"
) from exc
result.skipped.append(component)
save_records(project_root, remove_record(records, bundle_id))
return result

View File

@@ -2737,36 +2737,6 @@ class ConfigManager:
config_file = self.extension_dir / "local-config.yml"
return self._load_yaml_config(config_file)
def _sibling_extension_ids(self) -> list[str]:
"""Return IDs of other extensions installed alongside this one.
Sourced from ``ExtensionRegistry`` (``.specify/extensions/.registry``)
rather than a directory scan: ``ExtensionManager.remove(...,
keep_config=True)`` deliberately preserves the extension directory
while dropping the registry entry, so a directory scan would treat
that config-only leftover as an installed sibling and keep silently
absorbing its ``SPECKIT_<sibling>_*`` env vars into no one. The
registry is the source of truth for "installed".
Returns an empty list if the registry is missing or corrupted
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
opens the file in text mode and only handles ``JSONDecodeError`` /
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
otherwise surface a ``UnicodeDecodeError`` here and break *every*
config read instead of degrading gracefully.
Used by ``_get_env_config`` to detect env vars whose remainder claims
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
owned by ``git-hooks`` when it is co-installed with ``git``).
"""
extensions_dir = self.project_root / ".specify" / "extensions"
try:
return list(ExtensionRegistry(extensions_dir).keys())
except (OSError, UnicodeError):
return []
def _get_env_config(self) -> Dict[str, Any]:
"""Get configuration from environment variables.
@@ -2786,49 +2756,15 @@ class ConfigManager:
ext_id_upper = self.extension_id.replace("-", "_").upper()
prefix = f"SPECKIT_{ext_id_upper}_"
# Cross-extension prefix collision: because ``_`` doubles as both the
# separator between the extension ID and the config path *and* the
# substitute for ``-`` inside an extension ID, an env var like
# ``SPECKIT_GIT_HOOKS_URL`` begins with *both* the ``SPECKIT_GIT_``
# prefix of the ``git`` extension and the ``SPECKIT_GIT_HOOKS_`` prefix
# of a co-installed ``git-hooks`` extension. It logically belongs to
# the extension whose normalized ID is the longer, more specific match
# — otherwise config intended for one extension silently surfaces
# inside another and can drive hooks that only inspect
# ``config.<field> is set``. Build the list of sibling-owned
# remainder-prefixes here so a later env var can be skipped if it
# matches one.
sibling_prefixes: list[str] = []
for sibling_id in self._sibling_extension_ids():
if sibling_id == self.extension_id:
continue
sib_upper = sibling_id.replace("-", "_").upper()
# A sibling collides only when its normalized ID *extends* our own
# (i.e. starts with ``<US>_``). ``git`` vs ``not-git`` is not a
# collision; ``git`` vs ``git-hooks`` is.
if sib_upper.startswith(ext_id_upper + "_"):
# The portion of the env-var *remainder* the sibling claims,
# including the trailing ``_`` so a shorter ID that shares a
# non-boundary prefix cannot false-positive (e.g. sibling
# ``hook`` would not eat env vars under key ``hooks``).
sibling_prefixes.append(sib_upper[len(ext_id_upper) + 1 :] + "_")
for key, value in os.environ.items():
if not key.startswith(prefix):
continue
remainder = key[len(prefix) :]
# Skip when a longer sibling ID claims this var — see the block
# above. Keeps ``SPECKIT_GIT_HOOKS_URL`` out of the ``git``
# extension's config when ``git-hooks`` is co-installed.
if any(remainder.startswith(sp) for sp in sibling_prefixes):
continue
# Remove prefix and split into parts. Drop empty components from a
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
# entry under an empty key.
config_path = [p for p in remainder.lower().split("_") if p]
config_path = [p for p in key[len(prefix) :].lower().split("_") if p]
if not config_path:
continue

View File

@@ -1122,17 +1122,6 @@ class TomlIntegration(IntegrationBase):
# YamlIntegration — YAML-format agents (Goose)
# ---------------------------------------------------------------------------
# Characters a YAML literal block scalar cannot carry: C0 controls other
# than tab/LF (a bare CR acts as a line break inside the scalar), DEL, the
# C1 range, lone UTF-16 surrogates, and the non-characters U+FFFE/U+FFFF.
# NEL (U+0085) is YAML-printable but, like LS/PS (U+2028/U+2029), YAML 1.1
# treats it as a line break, which corrupts the block scalar's structure
# just the same, so all three are included.
_YAML_BLOCK_SCALAR_UNSAFE = re.compile(
r"[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff\ufffe\uffff]"
)
class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
@@ -1238,9 +1227,9 @@ class YamlIntegration(IntegrationBase):
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar for
normal prompt content, or an escaped quoted scalar when control
characters require it. Uses ``yaml.safe_dump()`` for the header fields.
Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
"""
header = cls._build_yaml_header(title, description)
@@ -1251,23 +1240,6 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).strip()
# YAML forbids C0 control characters (except tab and newline) and
# DEL in every scalar form, and a bare CR acts as a line break
# inside a block scalar. A literal block scalar emits such bytes
# verbatim, producing a recipe the YAML parser rejects, so fall
# back to an escaped double-quoted scalar for those bodies.
if _YAML_BLOCK_SCALAR_UNSAFE.search(body):
prompt_yaml = yaml.safe_dump(
{"prompt": body}, allow_unicode=True, default_style='"', width=sys.maxsize
).strip()
lines = [
header_yaml,
prompt_yaml,
"",
f"# Source: {source_id}",
]
return "\n".join(lines) + "\n"
# Indent the body for YAML block scalar. Use an explicit indentation
# indicator ("|2") rather than a bare "|": YAML infers a plain block
# scalar's indentation from its first non-empty line, so a body whose

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,6 @@ from __future__ import annotations
import hashlib
import json
import os
import stat
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
@@ -73,180 +71,48 @@ class WorkflowRegistry:
self.registry_path = self.workflows_dir / self.REGISTRY_FILE
self.data = self._load()
def _has_symlinked_parent(self) -> bool:
"""Return True if any directory under .specify/workflows is a symlink."""
current = self.project_root
for part in (".specify", "workflows"):
current = current / part
if current.is_symlink():
return True
return False
def _load(self) -> dict[str, Any]:
"""Load registry from disk or create default."""
default_registry: dict[str, Any] = {
"schema_version": self.SCHEMA_VERSION,
"workflows": {},
}
# Defense-in-depth: refuse to read through symlinked parents or a
# symlinked registry file. Unlike StepRegistry (read-only best-effort
# elsewhere), a fabricated empty registry here is not safe: read-only
# callers (notably the bundler's remove path) query is_installed()
# before ever writing, and would otherwise conclude an installed
# workflow is absent, skip removing it, then delete the bundle
# record -- leaving the workflow untracked but still on disk. Fail
# closed here just like the unreadable-file case below.
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise OSError(
f"Refusing to read workflow registry at {self.registry_path}: "
"a parent directory or the registry file itself is a symlink"
)
if self.registry_path.exists():
try:
with open(self.registry_path, encoding="utf-8") as f:
data = json.load(f)
except OSError as exc:
# The real data may still be intact on disk. Fail closed at
# construction rather than fabricating an empty registry that
# a read-only caller could mistake for "nothing installed."
raise OSError(
f"Failed to read workflow registry at {self.registry_path}: {exc}"
) from exc
except (
json.JSONDecodeError,
ValueError,
UnicodeError,
) as exc:
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
f"{exc}"
) from exc
# Validate shape: must be a dict with a dict "workflows" field.
if not isinstance(data, dict):
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
"top-level value must be an object"
)
if not isinstance(data.get("workflows"), dict):
raise OSError(
f"Workflow registry at {self.registry_path} is corrupted: "
"'workflows' must be an object"
)
return data
return default_registry
# Validate shape: must be a dict with a dict "workflows" field,
# otherwise every method that indexes data["workflows"] crashes.
# Mirrors StepRegistry._load.
if not isinstance(data, dict):
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
if not isinstance(data.get("workflows"), dict):
data["workflows"] = {}
return data
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
# Corrupted registry file — reset to default
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
def save(self) -> None:
"""Persist registry to disk atomically."""
# Refuse to write through symlinked parents (mirrors StepRegistry.save
# and the CLI-level _reject_unsafe_dir guard).
if self._has_symlinked_parent() or self.registry_path.is_symlink():
raise OSError(
"Refusing to write workflow registry through a symlinked path."
)
"""Persist registry to disk."""
self.workflows_dir.mkdir(parents=True, exist_ok=True)
# Unique, exclusive temp then replace: a failed dump cannot truncate
# the registry, a pre-created symlink cannot redirect the write, and
# concurrent CLI processes cannot collide on the same temp path.
fd, tmp = tempfile.mkstemp(
dir=str(self.registry_path.parent),
prefix=f".{self.registry_path.name}.",
suffix=".tmp",
)
try:
# Write through a duplicate so the exclusive mkstemp descriptor
# stays open for fd-based metadata updates and inode verification.
with os.fdopen(os.dup(fd), "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
# mkstemp creates the temp file at 0600. A pre-existing registry
# may be shared more permissively (e.g. 0640/0644); preserve its
# mode across the replace so a save doesn't silently lock other
# project users out. A brand-new registry has no prior mode to
# preserve, so mkstemp's secure 0600 default stands. Mirrors
# _utils.py's atomic_write_json (best-effort; data safety over
# metadata preservation).
try:
if self.registry_path.exists():
existing_stat = self.registry_path.stat(
follow_symlinks=False
)
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
os, "fchmod"
):
os.fchmod(fd, stat.S_IMODE(existing_stat.st_mode))
if stat.S_ISREG(existing_stat.st_mode) and hasattr(
os, "fchown"
):
try:
os.fchown(
fd, existing_stat.st_uid, existing_stat.st_gid
)
except PermissionError:
pass
except OSError:
pass
staged_stat = os.stat(tmp, follow_symlinks=False)
open_stat = os.fstat(fd)
if (
not stat.S_ISREG(staged_stat.st_mode)
or staged_stat.st_dev != open_stat.st_dev
or staged_stat.st_ino != open_stat.st_ino
):
raise OSError(
"Refusing to replace workflow registry: "
"staged file changed before commit"
)
os.close(fd)
fd = -1
os.replace(tmp, self.registry_path)
except BaseException:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
pass
raise
with open(self.registry_path, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
"""Add or update an installed workflow entry."""
from datetime import datetime, timezone
raw_existing = self.data["workflows"].get(workflow_id)
had_entry = workflow_id in self.data["workflows"]
# Corrupted-but-parseable registries may hold non-dict entries.
existing = raw_existing if isinstance(raw_existing, dict) else {}
existing = self.data["workflows"].get(workflow_id, {})
metadata["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat()
)
metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
self.data["workflows"][workflow_id] = metadata
try:
self.save()
except (OSError, TypeError, ValueError):
# Roll back the in-memory mutation so a later successful save
# cannot persist metadata for a write that failed.
if had_entry:
self.data["workflows"][workflow_id] = raw_existing
else:
del self.data["workflows"][workflow_id]
raise
self.save()
def remove(self, workflow_id: str) -> bool:
"""Remove an installed workflow entry. Returns True if found."""
if workflow_id in self.data["workflows"]:
removed_entry = self.data["workflows"][workflow_id]
del self.data["workflows"][workflow_id]
try:
self.save()
except (OSError, TypeError, ValueError):
# Roll back the in-memory deletion so a save failure can't
# desync this instance from the untouched file on disk,
# mirroring add()'s rollback-on-save-failure.
self.data["workflows"][workflow_id] = removed_entry
raise
self.save()
return True
return False
@@ -299,20 +165,8 @@ class WorkflowCatalog:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
# A malformed authority (e.g. an unterminated IPv6 bracket
# "https://[::1") makes urlparse / hostname access raise ValueError.
# This validator's contract is to raise WorkflowValidationError for a
# bad URL, so surface that rather than leaking a raw ValueError past the
# command handler (which only catches WorkflowValidationError). Mirrors
# specify_cli.catalogs (#3435).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise WorkflowValidationError(
f"Catalog URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
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
):
@@ -320,7 +174,7 @@ class WorkflowCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not hostname:
if not parsed.hostname:
raise WorkflowValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -486,26 +340,15 @@ class WorkflowCatalog:
from specify_cli.authentication.http import open_url as _open_url
def _validate_catalog_url(url: str) -> None:
# A malformed authority (e.g. "https://[::1") makes urlparse /
# hostname access raise ValueError; treat it as a refused fetch
# rather than leaking a raw ValueError (this also validates the
# post-redirect resp.geturl(), so a hostile redirect target cannot
# crash the fetch either).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
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 WorkflowCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
if not hostname:
if not parsed.hostname:
raise WorkflowCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)
@@ -592,7 +435,6 @@ class WorkflowCatalog:
self,
query: str | None = None,
tag: str | None = None,
author: str | None = None,
) -> list[dict[str, Any]]:
"""Search workflows across all configured catalogs."""
merged = self._get_merged_workflows()
@@ -617,10 +459,6 @@ class WorkflowCatalog:
normalized_tags = [t.lower() for t in tags if isinstance(t, str)]
if tag.lower() not in normalized_tags:
continue
if author:
wf_author = wf_data.get("author", "")
if not isinstance(wf_author, str) or wf_author.lower() != author.lower():
continue
results.append(wf_data)
return results
@@ -944,20 +782,8 @@ class StepCatalog:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
# A malformed authority (e.g. an unterminated IPv6 bracket
# "https://[::1") makes urlparse / hostname access raise ValueError.
# This validator's contract is to raise StepValidationError for a bad
# URL, so surface that rather than leaking a raw ValueError past the
# command handler (which only catches StepValidationError). Mirrors
# specify_cli.catalogs (#3435).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise StepValidationError(
f"Catalog URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
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
):
@@ -965,7 +791,7 @@ class StepCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not hostname:
if not parsed.hostname:
raise StepValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -1131,26 +957,15 @@ class StepCatalog:
from specify_cli.authentication.http import open_url as _open_url
def _validate_url(url: str) -> None:
# A malformed authority (e.g. "https://[::1") makes urlparse /
# hostname access raise ValueError; treat it as a refused fetch
# rather than leaking a raw ValueError (this also validates the
# post-redirect resp.geturl(), so a hostile redirect target cannot
# crash the fetch either).
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise StepCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
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 StepCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
if not hostname:
if not parsed.hostname:
raise StepCatalogError(
f"Refusing to fetch catalog from URL with no hostname: {url}"
)

View File

@@ -150,7 +150,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"'workflow.id' must be a string, got "
f"{type(definition.id).__name__} ({definition.id!r})."
)
elif not _ID_PATTERN.fullmatch(definition.id):
elif not _ID_PATTERN.match(definition.id):
errors.append(
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
f"with hyphens."
@@ -172,7 +172,7 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"{type(definition.version).__name__} ({definition.version!r}) — "
f'quote it in YAML (version: "1.0.0").'
)
elif not re.fullmatch(r"\d+\.\d+\.\d+", definition.version):
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
errors.append(
f"Workflow version {definition.version!r} is not valid "
f"semantic versioning (expected X.Y.Z)."
@@ -416,57 +416,18 @@ class RunState:
ID into a path so a malicious value cannot probe or read files
outside ``.specify/workflows/runs/<run_id>/``.
"""
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.fullmatch(run_id):
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
raise ValueError(
f"Invalid run_id {run_id!r}: must be alphanumeric with "
"hyphens/underscores only (and must start with an "
"alphanumeric character)."
)
@staticmethod
def _validate_installed_origin(
installed_workflow_id: str | None,
installed_registry_root: str | None,
) -> None:
"""Validate persisted installed-workflow ownership metadata."""
if installed_workflow_id is not None:
if not isinstance(installed_workflow_id, str):
raise ValueError(
"Invalid run state: 'installed_workflow_id' must be a "
f"string or null, got {type(installed_workflow_id).__name__}"
)
if not _ID_PATTERN.fullmatch(installed_workflow_id):
raise ValueError(
"Invalid run state: 'installed_workflow_id' must be a "
"lowercase alphanumeric workflow ID with hyphens"
)
if installed_registry_root is not None:
if not isinstance(installed_registry_root, str):
raise ValueError(
"Invalid run state: 'installed_registry_root' must be a "
f"string or null, got {type(installed_registry_root).__name__}"
)
if not installed_registry_root or not Path(
installed_registry_root
).is_absolute():
raise ValueError(
"Invalid run state: 'installed_registry_root' must be "
"an absolute path or null"
)
if installed_workflow_id is None:
raise ValueError(
"Invalid run state: 'installed_registry_root' requires "
"'installed_workflow_id'"
)
def __init__(
self,
run_id: str | None = None,
workflow_id: str = "",
project_root: Path | None = None,
installed_workflow_id: str | None = None,
installed_registry_root: str | None = None,
installed_origin_tracked: bool = True,
) -> None:
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
# string is *not* the same as "omitted" and must be validated like
@@ -478,22 +439,8 @@ class RunState:
else:
self.run_id = run_id
self._validate_run_id(self.run_id)
self._validate_installed_origin(
installed_workflow_id, installed_registry_root
)
self.workflow_id = workflow_id
self.project_root = project_root or Path(".")
# Identifies the installed workflow (if any) this run was started
# from, and the project root that owns its registry — set by
# execute() when the source was resolved to an installed ID (see
# workflow_run's ownership mapping). None for a direct/non-installed
# YAML source. ``installed_origin_tracked`` distinguishes those
# explicit None values from legacy state files that predate both
# fields, allowing the CLI to conservatively infer same-project
# registry ownership before resuming.
self.installed_workflow_id = installed_workflow_id
self.installed_registry_root = installed_registry_root
self.installed_origin_tracked = installed_origin_tracked
self.status = RunStatus.CREATED
self.current_step_index = 0
self.current_step_id: str | None = None
@@ -556,8 +503,6 @@ class RunState:
state_data = {
"run_id": self.run_id,
"workflow_id": self.workflow_id,
"installed_workflow_id": self.installed_workflow_id,
"installed_registry_root": self.installed_registry_root,
"status": self.status.value,
"current_step_index": self.current_step_index,
"current_step_id": self.current_step_id,
@@ -609,46 +554,11 @@ class RunState:
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
if not isinstance(state_data, dict):
raise ValueError("Invalid run state: expected a JSON object")
missing_fields = [
field
for field in ("run_id", "workflow_id", "status")
if field not in state_data
]
if missing_fields:
raise ValueError(
"Invalid run state: missing required field(s): "
+ ", ".join(missing_fields)
)
workflow_id = state_data["workflow_id"]
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
workflow_id
):
raise ValueError(
"Invalid run state: 'workflow_id' must be a lowercase "
"alphanumeric workflow ID with hyphens"
)
has_installed_workflow_id = "installed_workflow_id" in state_data
has_installed_registry_root = "installed_registry_root" in state_data
if has_installed_workflow_id != has_installed_registry_root:
raise ValueError(
"Invalid run state: installed workflow origin fields must "
"either both be present or both be absent"
)
installed_workflow_id = state_data.get("installed_workflow_id")
installed_registry_root = state_data.get("installed_registry_root")
state = cls(
run_id=state_data["run_id"],
workflow_id=workflow_id,
workflow_id=state_data["workflow_id"],
project_root=project_root,
installed_workflow_id=installed_workflow_id,
installed_registry_root=installed_registry_root,
installed_origin_tracked=has_installed_workflow_id,
)
state.status = RunStatus(state_data["status"])
state.current_step_index = state_data.get("current_step_index", 0)
@@ -661,16 +571,7 @@ class RunState:
if inputs_path.exists():
with open(inputs_path, encoding="utf-8") as f:
inputs_data = json.load(f)
if not isinstance(inputs_data, dict):
raise ValueError(
"Invalid run inputs: expected a JSON object"
)
inputs = inputs_data.get("inputs", {})
if not isinstance(inputs, dict):
raise ValueError(
"Invalid run inputs: 'inputs' must be a JSON object"
)
state.inputs = inputs
state.inputs = inputs_data.get("inputs", {})
return state
@@ -753,8 +654,6 @@ class WorkflowEngine:
definition: WorkflowDefinition,
inputs: dict[str, Any] | None = None,
run_id: str | None = None,
installed_workflow_id: str | None = None,
installed_registry_root: Path | None = None,
) -> RunState:
"""Execute a workflow definition.
@@ -766,12 +665,6 @@ class WorkflowEngine:
User-provided input values.
run_id:
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
installed_workflow_id, installed_registry_root:
When the run was started from an installed workflow (as opposed
to a direct/non-installed YAML source), identifies it and its
owning registry root so a later ``resume`` can re-check the
registry's current disabled state before continuing — see
``workflow_resume``.
Returns
-------
@@ -789,12 +682,6 @@ class WorkflowEngine:
run_id=effective_run_id,
workflow_id=definition.id,
project_root=self.project_root,
installed_workflow_id=installed_workflow_id,
installed_registry_root=(
str(installed_registry_root)
if installed_registry_root is not None
else None
),
)
# Persist a copy of the workflow definition so resume can

View File

@@ -6,7 +6,6 @@ contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error)
"""
from __future__ import annotations
import io
import json
from pathlib import Path
from unittest.mock import patch
@@ -64,42 +63,6 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch
assert "Spec Kit project" in result.output
def test_remove_reports_clean_error_when_primitive_raises_raw_exception(
project: Path,
):
"""A raw exception from a primitive installer (e.g. an OSError from an
unreadable workflow registry surfacing through _WorkflowKindManager's
fail-closed construction) must not propagate uncaught through
`specify bundle remove` -- the command only catches BundlerError, so
without a conversion at the remove_bundle boundary this would exit
with an unhandled exception and empty/raw output instead of a clean,
actionable message, and no removal side effects should occur either."""
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
from specify_cli.bundler.services.installer import install_bundle
from specify_cli.bundler.services.resolver import resolve_install_plan
from tests.bundler_helpers import FakeInstaller
manifest = BundleManifest.from_dict(valid_manifest_dict())
plan = resolve_install_plan(
manifest, speckit_version="0.11.2", active_integration="copilot"
)
install_bundle(project, plan, FakeInstaller(), manifest=manifest)
def boom(self, project_root, component):
raise OSError("workflow registry unreadable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom)
result = runner.invoke(app, ["bundle", "remove", "demo-bundle"])
assert result.exit_code != 0
assert result.output.strip() != ""
assert result.exception is None or isinstance(result.exception, SystemExit)
assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"}
def test_fail_writes_error_to_stderr_not_stdout(capsys):
"""_fail must write to stderr, not stdout: every bundle command routes errors
through it, and under --json the error would otherwise corrupt the JSON payload
@@ -469,16 +432,25 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
# ===== Private GitHub release asset URL resolution =====
class FakeBundleResponse(io.BytesIO):
class FakeBundleResponse:
"""Minimal context-manager response stub for open_url fakes."""
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
super().__init__(data)
self._data = data
self._url = url
def read(self) -> bytes:
return self._data
def geturl(self) -> str:
return self._url
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""

View File

@@ -11,7 +11,7 @@ import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records, records_path
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
@@ -97,212 +97,6 @@ def test_remove_unknown_bundle_errors(tmp_path: Path):
remove_bundle(tmp_path, "ghost", FakeInstaller())
def test_remove_converts_raw_installer_exception_to_bundler_error(tmp_path: Path):
"""A raw exception from a primitive installer (e.g. an OSError from an
unreadable workflow registry surfacing through _WorkflowKindManager's
fail-closed construction) must not propagate uncaught out of
remove_bundle: install_bundle already converts any non-BundlerError
exception into a clean BundlerError, but remove_bundle had no such
conversion, so the CLI's `bundle remove` (which only catches
BundlerError) would let a raw exception through with no clean message
and no removal side effects should occur either."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
raise OSError("workflow registry unreadable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "is_installed", boom)
with pytest.raises(BundlerError):
remove_bundle(tmp_path, "demo-bundle", installer)
# No removal side effects: the bundle record must still be present.
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_partial_failure_message_reflects_partial_state(tmp_path: Path):
"""A failure can occur after earlier components in the same bundle have
already been removed from disk. The bundle record is left unchanged
(save_records never runs on this path), so it still claims the bundle
fully installed -- but the message must not claim "No changes were
recorded" when components were, in fact, already removed."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
real_remove = installer.remove
calls = {"n": 0}
def remove_then_fail(project_root, component):
calls["n"] += 1
if calls["n"] == 1:
return real_remove(project_root, component)
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", remove_then_fail)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no changes were recorded" not in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_bundler_error_from_installer_after_partial_removal_reports_partial_state(
tmp_path: Path,
):
"""If the primitive installer itself raises BundlerError (not a raw/
unexpected exception) after an earlier component in the same bundle was
already removed, the surfaced message must still carry the same
partial-removal detail as the generic-exception path -- a bare
``except BundlerError: raise`` would re-raise the installer's original
message verbatim with no mention that the project may now be partially
uninstalled."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
real_remove = installer.remove
calls = {"n": 0}
def remove_then_raise_bundler_error(project_root, component):
calls["n"] += 1
if calls["n"] == 1:
return real_remove(project_root, component)
raise BundlerError("kind manager refused removal")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", remove_then_raise_bundler_error)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no changes were recorded" not in message.lower()
assert "kind manager refused removal" in message
assert "partially uninstalled" in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_bundler_error_from_installer_with_zero_removed_reports_no_changes(
tmp_path: Path,
):
"""When the installer raises BundlerError before anything was actually
removed, the message should not misleadingly claim partial state."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
raise BundlerError("kind manager unavailable")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "is_installed", boom)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no components were removed" in message.lower()
assert "no removal was attempted" in message.lower()
assert "partially uninstalled" not in message.lower()
assert "kind manager unavailable" in message
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_zero_completed_removals_still_cautions_about_partial_changes(
tmp_path: Path,
):
"""`result.uninstalled` only records a component after its `remove()`
call returns successfully. If the very first `remove()` call itself
raises after already deleting some files, zero completed removals are
recorded even though the project may already be partially uninstalled --
the zero-count message must not claim "No components were removed" as
an unqualified fact; it must caution that the failing component may
have made partial changes before raising."""
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
def boom(project_root, component):
# Simulates a remove() that deletes some files before raising --
# from the caller's perspective this component was never recorded
# as completed, but disk state may already be partially changed.
raise OSError("disk full partway through removal")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(installer, "remove", boom)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no components were removed" in message.lower()
assert "partial" in message.lower()
assert "partially uninstalled" in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_record_save_failure_reports_partial_state(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
record_file = records_path(tmp_path)
original_record = record_file.read_bytes()
def fail_dump(_data, handle, *_args, **_kwargs):
handle.write('{"partial":')
handle.flush()
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.bundler.lib.yamlio.json.dump",
fail_dump,
)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "disk full" in message
assert "partially uninstalled" in message.lower()
assert installer.installed == set()
assert record_file.read_bytes() == original_record
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_record_save_failure_without_remove_attempt_is_not_partial(
tmp_path: Path,
):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
installer.installed.clear()
def fail_save(*_args, **_kwargs):
raise OSError("disk full")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"specify_cli.bundler.services.installer.save_records",
fail_save,
)
with pytest.raises(BundlerError) as exc_info:
remove_bundle(tmp_path, "demo-bundle", installer)
message = str(exc_info.value)
assert "no removal was attempted" in message.lower()
assert "partially uninstalled" not in message.lower()
assert {r.bundle_id for r in load_records(tmp_path)} == {"demo-bundle"}
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
@@ -334,7 +128,7 @@ def test_remove_counts_only_components_actually_removed(tmp_path: Path):
assert len(result.uninstalled) == 3
assert (gone.kind, gone.id) not in installer.remove_calls
assert gone not in result.skipped
assert gone in result.skipped
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()

View File

@@ -201,36 +201,6 @@ class YamlIntegrationTests:
parsed = yaml.safe_load("\n".join(yaml_lines))
assert parsed["prompt"].rstrip("\n") == body
def test_yaml_prompt_with_control_characters_stays_valid(self):
"""A body containing control characters must still produce parseable YAML.
YAML forbids C0 control characters (except tab and newline), DEL,
C1 controls, lone surrogates and U+FFFE/U+FFFF in every scalar form,
and YAML 1.1 treats NEL (U+0085), LS (U+2028) and PS (U+2029) as
line breaks that corrupt a literal block scalar's structure. The
renderer falls back to an escaped double-quoted scalar for such
bodies."""
for ch in (
"\x08", "\x0c", "\x1b", "\x7f",
"\x80", "\x84", "\x85", "\x86", "\x9f",
"\u2028", "\u2029",
"\ud800", "\udfff", "\ufffe", "\uffff",
):
body = f"before{ch}after\nsecond line"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
parsed = yaml.safe_load(rendered)
assert parsed["prompt"].rstrip("\n") == body, f"char {ch!r} round-trip"
def test_yaml_prompt_with_bare_carriage_return_stays_valid(self):
"""A bare CR (not part of CRLF) must not break the generated YAML.
Inside a block scalar a lone \r acts as a line break, corrupting
the document structure."""
body = "line1\rstill line1\nline2"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
parsed = yaml.safe_load(rendered)
assert parsed["prompt"].rstrip("\n") == body
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.

View File

@@ -7896,135 +7896,3 @@ class TestConfigManagerEnvPrefixCollision:
cfg = cm._get_env_config()
assert "" not in cfg
assert cfg == {"a": {"b": "z"}}
class TestConfigManagerCrossExtensionEnvLeak:
"""Cross-extension env-var leak: a longer, co-installed sibling ID must
own its own env vars instead of leaking them into a shorter-prefix sibling.
Before the fix, ``SPECKIT_GIT_HOOKS_URL`` (intended for a ``git-hooks``
extension) also surfaced inside the ``git`` extension's config as
``{'hooks': {'url': ...}}`` because ``SPECKIT_GIT_`` is a strict prefix of
``SPECKIT_GIT_HOOKS_``.
"""
def _install(self, project_root, ext_id):
extensions_dir = project_root / ".specify" / "extensions"
(extensions_dir / ext_id).mkdir(parents=True)
# Register in the extension registry — the registry is the source of
# truth for "installed" (a bare directory can be a config-only leftover
# from ``ExtensionManager.remove(..., keep_config=True)``).
ExtensionRegistry(extensions_dir).add(ext_id, {})
def test_sibling_owns_longer_prefix_env(self, tmp_path, monkeypatch):
"""SPECKIT_GIT_HOOKS_URL belongs to git-hooks when co-installed with git."""
self._install(tmp_path, "git")
self._install(tmp_path, "git-hooks")
monkeypatch.setenv("SPECKIT_GIT_URL", "for_git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
git_cfg = ConfigManager(tmp_path, "git")._get_env_config()
gh_cfg = ConfigManager(tmp_path, "git-hooks")._get_env_config()
# 'git' must NOT see the git-hooks var — no cross-extension leak.
assert git_cfg == {"url": "for_git"}
# 'git-hooks' still receives its own var (unchanged behaviour).
assert gh_cfg == {"url": "for_git_hooks"}
def test_no_sibling_installed_keeps_legacy_absorption(self, tmp_path, monkeypatch):
"""Without a longer-prefix sibling installed, the legacy behaviour is
preserved: ``SPECKIT_GIT_HOOKS_URL`` is absorbed as a nested key of
the ``git`` extension. This keeps the fix strictly to the *collision*
case and avoids surprising users who deliberately set a nested key
via env with no sibling to disambiguate against.
"""
self._install(tmp_path, "git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_hooks"}}
def test_non_prefix_sibling_ignored(self, tmp_path, monkeypatch):
"""A sibling whose ID does not extend our own is not a collision.
e.g. current='git' and sibling='not-git''not-git' normalized to
'NOT_GIT' does not start with 'GIT_', so its presence must not
influence git's env-var interpretation.
"""
self._install(tmp_path, "git")
self._install(tmp_path, "not-git")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_hooks")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_hooks"}}
def test_boundary_prevents_false_positive(self, tmp_path, monkeypatch):
"""Sibling ID 'hook' (not 'hooks') must NOT eat env keys starting
with 'hooks'. The trailing-underscore boundary in the sibling prefix
prevents this false positive.
"""
self._install(tmp_path, "git")
self._install(tmp_path, "git-hook")
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git_key_hooks")
# git-hook's prefix is 'HOOK_', which does not match 'HOOKS_URL',
# so 'git' keeps the env var (single-installed semantics).
cfg = ConfigManager(tmp_path, "git")._get_env_config()
assert cfg == {"hooks": {"url": "for_git_key_hooks"}}
def test_missing_extensions_dir_does_not_crash(self, tmp_path, monkeypatch):
"""A ConfigManager built against a project without ``.specify/extensions``
(fresh project, ad-hoc test harness) must still evaluate env config
rather than raising from the sibling scan.
"""
# Note: no _install call — extensions dir intentionally absent.
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
assert cfg == {"url": "v"}
def test_config_only_leftover_not_treated_as_sibling(self, tmp_path, monkeypatch):
"""A directory left behind by ``remove(..., keep_config=True)`` must
NOT be treated as an installed sibling.
``ExtensionManager.remove(keep_config=True)`` preserves the extension
directory (config files remain, dormant, for a possible reinstall) but
removes the registry entry. The sibling scan is sourced from the
registry, so a leftover ``git-hooks/`` directory without a registry
entry must not silently discard ``SPECKIT_GIT_HOOKS_*`` from ``git``.
"""
self._install(tmp_path, "git")
# Simulate ``remove('git-hooks', keep_config=True)``: dir present,
# config file preserved, but no registry entry.
gh_dir = tmp_path / ".specify" / "extensions" / "git-hooks"
gh_dir.mkdir(parents=True)
(gh_dir / "git-hooks-config.yml").write_text("url: leftover\n")
# Sanity: git-hooks is NOT registered.
registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
assert "git-hooks" not in registry.keys()
monkeypatch.setenv("SPECKIT_GIT_HOOKS_URL", "for_git")
cfg = ConfigManager(tmp_path, "git")._get_env_config()
# git absorbs the var (no registered sibling owns it).
assert cfg == {"hooks": {"url": "for_git"}}
def test_non_utf8_registry_does_not_crash(self, tmp_path, monkeypatch):
"""A registry file with invalid text encoding must NOT propagate
``UnicodeDecodeError`` out of the sibling scan and abort every
config read. ``ExtensionRegistry._load()`` catches ``JSONDecodeError``
/ ``FileNotFoundError`` only, so ``_sibling_extension_ids`` must
additionally swallow ``UnicodeError`` and degrade to the documented
pre-fix behaviour.
"""
extensions_dir = tmp_path / ".specify" / "extensions"
extensions_dir.mkdir(parents=True)
# Write bytes that are not valid UTF-8 to the registry file.
(extensions_dir / ExtensionRegistry.REGISTRY_FILE).write_bytes(
b"\xff\xfe invalid utf-8 registry \xc3\x28"
)
monkeypatch.setenv("SPECKIT_TESTEXT_URL", "v")
# Must not raise; must fall back to the "no siblings" path.
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
assert cfg == {"url": "v"}

View File

@@ -152,44 +152,6 @@ class TestResolveGitHubReleaseAssetApiUrl:
)
assert result is None
def test_metadata_lookup_is_bounded_and_redirect_validated(self):
"""Release metadata reads stay bounded and use the caller's policy."""
captured = {}
class OversizedResponse:
def read(self, amount=None):
captured["read_amount"] = amount
return b"x" * amount
def __enter__(self):
return self
def __exit__(self, *args):
return False
def redirect_validator(old_url, new_url):
return None
def fake_open(
url,
timeout=None,
extra_headers=None,
redirect_validator=None,
):
captured["redirect_validator"] = redirect_validator
return OversizedResponse()
result = resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/v1/pack.zip",
fake_open,
redirect_validator=redirect_validator,
max_metadata_bytes=8,
)
assert result is None
assert captured["read_amount"] == 9
assert captured["redirect_validator"] is redirect_validator
def test_tag_with_special_characters_is_url_encoded(self):
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
captured_urls = []

View File

@@ -5016,13 +5016,26 @@ class TestPresetAddFromUrlResolution:
captured_urls = []
class FakeResponse:
def __init__(self, data):
self._data = data
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers))
if "releases/tags/" in url:
return io.BytesIO(json.dumps({
return FakeResponse(json.dumps({
"assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}]
}).encode())
return io.BytesIO(zip_bytes)
return FakeResponse(zip_bytes)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
@@ -5061,9 +5074,22 @@ class TestPresetAddFromUrlResolution:
captured_urls = []
class FakeResponse:
def __init__(self, data):
self._data = data
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers))
return io.BytesIO(zip_bytes)
return FakeResponse(zip_bytes)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
@@ -5105,13 +5131,26 @@ class TestPresetAddFromUrlResolution:
captured_urls = []
class FakeResponse:
def __init__(self, data):
self._data = data
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers))
if "releases/tags/" in url:
return io.BytesIO(json.dumps({
return FakeResponse(json.dumps({
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
}).encode())
return io.BytesIO(zip_bytes)
return FakeResponse(zip_bytes)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \

File diff suppressed because it is too large Load Diff