diff --git a/.github/scripts/check_extension_version_bump.py b/.github/scripts/check_extension_version_bump.py new file mode 100644 index 0000000000..3cb6e5371e --- /dev/null +++ b/.github/scripts/check_extension_version_bump.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Fail a PR that changes bundled extension content without a version bump. + +Update offers from `specify extension update` are version-driven: an +extension is offered (and installed) only when the semver in +`extensions/catalog.json` exceeds the installed copy's registered +version. A content change shipped without a version bump is therefore +never delivered automatically (#4345). The command's content-hash check +can detect such unbumped drift on bundled extensions, but only as an +advisory stale-content warning pointing at a manual `--force` reinstall — +a bump is what makes a change actually reach existing installs, and this +guard is what makes the bump non-optional. + +This check enforces two invariants on the extensions listed in +`extensions/catalog.json`: + +1. Any change to a file under `extensions//` must increase the + `version:` in that extension's `extension.yml` (PEP 440 comparison, + the same semantics `extension update` uses). +2. The `version` in `extensions/catalog.json` must equal the manifest's + `extension.version` (the catalog is what update checks compare + against, and the update preflight rejects a manifest whose version + differs from the catalog's). + +Usage: + check_extension_version_bump.py BASE_REF [HEAD_REF] + +BASE_REF is a git ref/SHA for the PR base (must be fetchable with +`git show`). HEAD_REF defaults to the working tree's HEAD. Exits 0 when +all invariants hold, 1 otherwise, printing one line per violation. + +Extensions under `extensions/` that are not in the catalog (the +`selftest` fixture and the `template` scaffold) are exempt: no update +flow is driven by their versions. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import yaml +from packaging.version import InvalidVersion, Version + +EXTENSIONS_ROOT = "extensions" +CATALOG_PATH = f"{EXTENSIONS_ROOT}/catalog.json" + + +def _git(*args: str) -> str: + return subprocess.run( + ["git", *args], check=True, capture_output=True, text=True + ).stdout + + +def _show(ref: str, path: str) -> str | None: + """Return the file's content at *ref*, or None when absent there.""" + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], capture_output=True, text=True + ) + return result.stdout if result.returncode == 0 else None + + +def _manifest_version(manifest_text: str, origin: str) -> str: + data = yaml.safe_load(manifest_text) + if not isinstance(data, dict) or not isinstance(data.get("extension"), dict): + raise ValueError(f"{origin}: manifest is not a mapping with an 'extension' block") + version = data["extension"].get("version") + if not isinstance(version, str) or not version.strip(): + raise ValueError(f"{origin}: extension.version is missing or not a string") + return version.strip() + + +def main(argv: list[str]) -> int: + if len(argv) < 2 or len(argv) > 3: + print(__doc__, file=sys.stderr) + return 2 + base_ref = argv[1] + head_ref = argv[2] if len(argv) == 3 else "HEAD" + + catalog_text = _show(head_ref, CATALOG_PATH) + if catalog_text is None: + print(f"::error::{CATALOG_PATH} is missing at {head_ref}") + return 1 + catalog = json.loads(catalog_text) + catalog_entries = catalog.get("extensions", {}) + + errors: list[str] = [] + + # -- Invariant 1: content change requires a version bump --------------- + changed = _git( + "diff", "--name-only", "--no-renames", base_ref, head_ref, "--", EXTENSIONS_ROOT + ).splitlines() + changed_ids = { + parts[1] + for line in changed + if len(parts := Path(line.strip()).parts) >= 3 and parts[0] == EXTENSIONS_ROOT + } + + for ext_id in sorted(changed_ids): + if ext_id not in catalog_entries: + continue # not driven by `extension update` (selftest, template) + manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml" + head_manifest = _show(head_ref, manifest_path) + if head_manifest is None: + continue # extension removed in this PR + base_manifest = _show(base_ref, manifest_path) + if base_manifest is None: + continue # new extension; any initial version is fine + try: + base_version = _manifest_version(base_manifest, f"{base_ref}:{manifest_path}") + head_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}") + except ValueError as exc: + errors.append(str(exc)) + continue + + # Compare with the same PEP 440 semantics the extension update and + # install code use (packaging.version), so prereleases and other + # accepted forms cannot bypass the guard (e.g. 2.0.0 -> 1.0.0rc1 is + # a downgrade). Unparseable versions fail closed. + try: + base_parsed = Version(base_version) + head_parsed = Version(head_version) + except InvalidVersion as exc: + errors.append( + f"{manifest_path}: could not compare versions " + f"{base_version!r} -> {head_version!r}: {exc}" + ) + continue + if head_parsed <= base_parsed: + errors.append( + f"{manifest_path}: files under {EXTENSIONS_ROOT}/{ext_id}/ changed but " + f"extension.version did not increase ({base_version} -> {head_version}). " + f"Installed copies only receive changes when the version is bumped." + ) + + # -- Invariant 2: catalog.json version matches the manifest ------------ + for ext_id, entry in sorted(catalog_entries.items()): + manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml" + head_manifest = _show(head_ref, manifest_path) + if head_manifest is None: + continue # catalog-only entry (e.g. hosted elsewhere) + try: + manifest_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}") + except ValueError as exc: + errors.append(str(exc)) + continue + catalog_version = entry.get("version") + if catalog_version != manifest_version: + errors.append( + f"{CATALOG_PATH}: entry '{ext_id}' has version {catalog_version!r} but " + f"{manifest_path} declares {manifest_version!r}. `extension update` " + f"compares against the catalog, so the two must move together." + ) + + for error in errors: + print(f"::error::{error}") + if not errors: + print("Extension version guard: all invariants hold.") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/extension-version-guard.yml b/.github/workflows/extension-version-guard.yml new file mode 100644 index 0000000000..ec9426304e --- /dev/null +++ b/.github/workflows/extension-version-guard.yml @@ -0,0 +1,43 @@ +name: Extension Version Guard + +permissions: + contents: read + +# Bundled extensions only reach existing installs through a version bump: +# `specify extension update` compares the semver in extensions/catalog.json +# against the installed copy and reports "Up to date" whenever they match. +# Content changes shipped without a bump go silently stale on every +# project that already installed the extension (#4345). This guard turns +# "please remember to bump" into a merge requirement. +on: + pull_request: + paths: + - "extensions/**" + +jobs: + version-bump: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install check dependencies + run: python -m pip install --quiet pyyaml packaging + + # For pull_request events the checkout is the merge of the PR head + # into the base tip, so diffing base.sha against HEAD yields exactly + # the PR's changes (same fetch pattern as lint.yml). + - name: Check bundled extension version bumps + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/checks/pr-base" + python .github/scripts/check_extension_version_bump.py refs/checks/pr-base diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8de2c18c86..f5bbacf438 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -75,6 +75,16 @@ specify extension update [] Updates a specific extension, or all installed extensions if no name is given. +Bundled extensions (such as `agent-context` and `git`) have no download URL; their updates install from the copy shipped with the running spec-kit release. When the catalog advertises a newer version than your spec-kit release ships, the update is reported as requiring a spec-kit upgrade first. + +When an installed bundled extension's files differ from the copy shipped with your spec-kit release even though the versions match (content that shipped without a version bump), the check flags it as stale content and points to the refresh command: + +```bash +specify extension add --force +``` + +Extension config files (`*-config.yml`, `*-config.local.yml`) are preserved across updates and forced reinstalls, and user edits to them are never counted as stale content. + ## Enable / Disable an Extension ```bash diff --git a/examples/bundles/business-analyst/bundle.yml b/examples/bundles/business-analyst/bundle.yml index b03875a22e..90d35ce87d 100644 --- a/examples/bundles/business-analyst/bundle.yml +++ b/examples/bundles/business-analyst/bundle.yml @@ -17,7 +17,7 @@ requires: provides: extensions: - id: "agent-context" - version: "1.0.0" + version: "1.1.0" presets: - id: "requirements-elicitation" version: "1.0.0" diff --git a/examples/bundles/developer/bundle.yml b/examples/bundles/developer/bundle.yml index 3a365534e5..3f4dce5465 100644 --- a/examples/bundles/developer/bundle.yml +++ b/examples/bundles/developer/bundle.yml @@ -17,7 +17,7 @@ requires: provides: extensions: - id: "agent-context" - version: "1.0.0" + version: "1.1.0" presets: - id: "implementation-planning" version: "1.0.0" diff --git a/examples/bundles/product-manager/bundle.yml b/examples/bundles/product-manager/bundle.yml index 9abba40bd4..c5f96ab186 100644 --- a/examples/bundles/product-manager/bundle.yml +++ b/examples/bundles/product-manager/bundle.yml @@ -19,7 +19,7 @@ requires: provides: extensions: - id: "agent-context" - version: "1.0.0" + version: "1.1.0" presets: - id: "product-discovery" version: "1.0.0" diff --git a/examples/bundles/security-researcher/bundle.yml b/examples/bundles/security-researcher/bundle.yml index d0b289e872..e017071e94 100644 --- a/examples/bundles/security-researcher/bundle.yml +++ b/examples/bundles/security-researcher/bundle.yml @@ -17,7 +17,7 @@ requires: provides: extensions: - id: "agent-context" - version: "1.0.0" + version: "1.1.0" presets: - id: "security-compliance" version: "1.0.0" diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index ac78029f2a..1ddf37e774 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -620,6 +620,17 @@ See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed - **MAJOR**: Breaking changes - **MINOR**: New features - **PATCH**: Bug fixes +- **Bump on every content change**: update offers from `specify extension + update` are version-driven, so a content change shipped without a + version bump is never delivered automatically to already-installed + copies. For bundled extensions the command can detect such unbumped + drift and flag it as stale content, but that is only an advisory + warning pointing at a manual `--force` reinstall — a bump is still + required for the change to be offered and installed. For the bundled + extensions in this repository the bump is enforced by CI + (`extension-version-guard.yml`): a PR that changes files under + `extensions//` must also bump that extension's `extension.yml` + version and keep `extensions/catalog.json` in sync. ### Security diff --git a/extensions/agent-context/extension.yml b/extensions/agent-context/extension.yml index 191069e32c..2846b4d9a6 100644 --- a/extensions/agent-context/extension.yml +++ b/extensions/agent-context/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: agent-context name: "Coding Agent Context" - version: "1.0.0" + version: "1.1.0" description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers" author: spec-kit-core repository: https://github.com/github/spec-kit diff --git a/extensions/assess/extension.yml b/extensions/assess/extension.yml index 9161b268fb..42b281b8ee 100644 --- a/extensions/assess/extension.yml +++ b/extensions/assess/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: assess name: "Idea Assessment Pipeline" - version: "1.0.0" + version: "1.0.1" description: "Assess an idea before Spec-Driven Development via intake, research, define, shape, and decide. A go verdict hands off to /speckit.specify; a kill closes it. Lives under .specify/assessments//" category: "process" effect: "read-write" diff --git a/extensions/catalog.json b/extensions/catalog.json index d05c48e0e5..af0aae7701 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,12 +1,12 @@ { "schema_version": "1.0", - "updated_at": "2026-07-17T00:00:00Z", + "updated_at": "2026-08-27T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { "agent-context": { "name": "Coding Agent Context", "id": "agent-context", - "version": "1.0.0", + "version": "1.1.0", "description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", @@ -20,7 +20,7 @@ "assess": { "name": "Idea Assessment Pipeline", "id": "assess", - "version": "1.0.0", + "version": "1.0.1", "description": "Assess an idea before Spec-Driven Development via intake, research, define, shape, and decide. A go verdict hands off to /speckit.specify; a kill closes it. Lives under .specify/assessments//", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", @@ -51,7 +51,7 @@ "git": { "name": "Git Branching Workflow", "id": "git", - "version": "1.0.0", + "version": "1.1.0", "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", diff --git a/extensions/git/extension.yml b/extensions/git/extension.yml index c92322d8b1..84e2dc35a5 100644 --- a/extensions/git/extension.yml +++ b/extensions/git/extension.yml @@ -3,7 +3,7 @@ schema_version: "1.0" extension: id: git name: "Git Branching Workflow" - version: "1.0.0" + version: "1.1.0" description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection" author: spec-kit-core repository: https://github.com/github/spec-kit diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3968e4fcbe..4b5bfd91f1 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -734,6 +734,57 @@ def get_hash(self) -> str: return f"sha256:{h.hexdigest()}" +def compute_extension_content_hash(ext_dir: Path) -> str: + """Calculate a SHA256 hash over an extension directory's shipped files. + + The registry's ``manifest_hash`` only covers ``extension.yml``, so + content changes shipped without a manifest edit are invisible to it + (#4345). This hash covers every regular file an install would copy: + it folds in the sorted POSIX-style relative path and raw bytes of each + file, excluding exactly what installs treat as user-owned or skip — + top-level ``*-config.yml`` / ``*-config.local.yml`` (the only config + paths the remove/backup/restore machinery preserves, see + ``_target_follows_preserved_convention``) and ``.extensionignore`` + plus whatever it ignores. Nested config-suffixed files are hashed: + installs overwrite them, so their changes are real staleness. The + same function therefore yields comparable hashes for a bundled source + directory and an installation made from it. + + Symlinks are never followed: an entry linking outside the extension + directory must not pull external bytes into the hash. Bundled + extensions ship none, so both sides of a comparison stay symmetric. + + Raises OSError when the directory cannot be read. + """ + ignore_fn = ExtensionManager._load_extensionignore(ext_dir) + h = hashlib.sha256() + + def walk(directory: Path) -> None: + entries = sorted(directory.iterdir(), key=lambda p: p.name) + ignored = ignore_fn(str(directory), [e.name for e in entries]) if ignore_fn else set() + for entry in entries: + if entry.name in ignored or entry.name == ".extensionignore": + continue + if entry.is_symlink(): + continue + if entry.is_dir(): + walk(entry) + elif entry.is_file(): + if entry.parent == ext_dir and ( + entry.name.endswith("-config.yml") + or entry.name.endswith("-config.local.yml") + ): + continue + data = entry.read_bytes() + h.update(entry.relative_to(ext_dir).as_posix().encode("utf-8")) + h.update(b"\x00") + h.update(len(data).to_bytes(8, "big")) + h.update(data) + + walk(ext_dir) + return f"sha256:{h.hexdigest()}" + + class ExtensionRegistry: """Manages the registry of installed extensions.""" @@ -2611,19 +2662,25 @@ def _restore_stranded_config_file( elif backup_config_dir.exists(): backup_config_dir.unlink() - # Update registry - self.registry.add( - manifest.id, - { - "version": manifest.version, - "source": "local", - "manifest_hash": manifest.get_hash(), - "enabled": True, - "priority": priority, - "registered_commands": registered_commands, - "registered_skills": registered_skills, - }, - ) + # Update registry. content_hash records what was shipped at install + # time so `extension update` can detect content that changed without + # a version bump (#4345); a hash failure must not fail the install. + try: + content_hash = compute_extension_content_hash(source_dir) + except OSError: + content_hash = None + registry_entry = { + "version": manifest.version, + "source": "local", + "manifest_hash": manifest.get_hash(), + "enabled": True, + "priority": priority, + "registered_commands": registered_commands, + "registered_skills": registered_skills, + } + if content_hash is not None: + registry_entry["content_hash"] = content_hash + self.registry.add(manifest.id, registry_entry) # Post-commit cleanup: the registry now records this extension as # installed, so the rescue guard (`not self.registry.is_installed`) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7f7933e934..6f1b593ac4 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -15,9 +15,14 @@ import stat import tempfile from pathlib import Path -from typing import Optional +from typing import Any, Optional, TYPE_CHECKING from uuid import uuid4 +if TYPE_CHECKING: + from packaging.version import Version + + from . import ExtensionManager + import typer import yaml from rich.markup import escape as _escape_markup @@ -106,6 +111,106 @@ def _command_safe_id(raw_id: object, placeholder: str = "") -> str return placeholder +def _bundled_content_is_stale( + ext_id: str, + metadata: dict[str, Any], + ext_info: dict[str, Any], + manager: ExtensionManager, + installed_version: Version, +) -> bool: + """Report whether a bundled extension's installed content is stale. + + A bundled extension whose catalog version equals the installed version + can still be out of date: its content may have changed upstream without + a version bump, and the semver comparison alone then reports "Up to + date" forever (#4345). Compare the content hash recorded at install + time (falling back to hashing the installed directory for registry + entries that predate content_hash) against the copy bundled with the + running spec-kit version. Only meaningful for bundled extensions with + no download URL — anything downloadable is served by the normal + version flow. + + The comparison requires the local bundled copy to declare the same + version as the installed one: a hash difference against an older or + newer local copy is version skew, not unbumped content drift, and a + `--force` refresh recommendation against an older copy would downgrade + the installation. + """ + from . import compute_extension_content_hash + + if not ext_info.get("bundled") or ext_info.get("download_url"): + return False + bundled_path, bundled_version = _bundled_update_source(ext_id) + if bundled_path is None or bundled_version != installed_version: + return False + try: + bundled_hash = compute_extension_content_hash(bundled_path) + installed_hash = metadata.get("content_hash") + if not isinstance(installed_hash, str) or not installed_hash: + installed_dir = manager.extensions_dir / ext_id + if not installed_dir.is_dir(): + return False + installed_hash = compute_extension_content_hash(installed_dir) + except OSError: + # Unreadable content must not break the update check; the version + # comparison already ran, so fall back to its verdict. + return False + return installed_hash != bundled_hash + + +def _bundled_update_source(ext_id: str) -> tuple[Path, Version] | tuple[None, None]: + """Locate the local bundled copy of *ext_id* and its parsed version. + + Bundled extensions have no download URL, so an update can only come + from the copy shipped with the running spec-kit release — which may + lag the version the catalog on main advertises. Returns + ``(path, Version)`` when a valid local copy exists, ``(None, None)`` + otherwise. + """ + from . import ExtensionManifest, ValidationError + from packaging import version as pkg_version + + bundled_dir = _locate_bundled_extension(ext_id) + if bundled_dir is None: + return None, None + try: + manifest = ExtensionManifest(bundled_dir / "extension.yml") + return bundled_dir, pkg_version.Version(manifest.version) + except (ValidationError, pkg_version.InvalidVersion, OSError): + return None, None + + +def _archive_extension_directory(source_dir: Path) -> Path: + """Package an extension directory as a ZIP archive for the update flow. + + The update pipeline validates and installs archives (bounded + extraction, manifest preflight, ID/version checks, backup/rollback), + so a locally bundled extension is fed through that identical hardened + path rather than growing a second install code path. The caller + deletes the archive after the update, the same as a downloaded one. + """ + import zipfile + + fd, tmp_name = tempfile.mkstemp(prefix="speckit-bundled-update-", suffix=".zip") + try: + with os.fdopen(fd, "wb") as archive_file: + with zipfile.ZipFile(archive_file, "w", zipfile.ZIP_DEFLATED) as zf: + for path in sorted(source_dir.rglob("*")): + # Never follow symlinks: is_file() follows the target + # and ZipFile.write() reads its bytes, which would turn + # an out-of-tree target into a regular archive member + # before the hardened extractor ever sees it. Matches + # the symlink rule in compute_extension_content_hash. + if path.is_symlink(): + continue + if path.is_file(): + zf.write(path, path.relative_to(source_dir).as_posix()) + except BaseException: + Path(tmp_name).unlink(missing_ok=True) + raise + return Path(tmp_name) + + def _refresh_events_and_warn(project_root: Path) -> None: """Refresh native event config and surface failures (R3). @@ -1622,6 +1727,8 @@ def extension_update( console.print("🔄 Checking for updates...\n") updates_available = [] + stale_content = [] + blocked_updates = [] for ext_id in extensions_to_update: safe_ext_id = _escape_markup(str(ext_id)) @@ -1658,20 +1765,77 @@ def extension_update( continue if catalog_version > installed_version: + download_url = ext_info.get("download_url") + bundled_dir = None + available_version = catalog_version + if ext_info.get("bundled") and not download_url: + # Bundled extensions cannot be downloaded; the update has + # to come from the copy shipped with the running spec-kit + # release, which may lag the catalog on main (#4345). + bundled_dir, bundled_version = _bundled_update_source(ext_id) + # Block whenever the local copy lags the catalog, not + # just when it lags the installation: installing an + # intermediate version would leave the project behind + # the catalog while reporting success, contrary to the + # documented "upgrade spec-kit first" behavior. + if bundled_dir is None or bundled_version < catalog_version: + local_desc = ( + f"only ships v{bundled_version}" + if bundled_dir is not None + else "does not ship a local copy" + ) + console.print( + f"⚠ {safe_ext_id}: v{catalog_version} is available, but this " + f"spec-kit release {local_desc} — upgrade spec-kit, then rerun " + f"'specify extension update'" + ) + blocked_updates.append(ext_id) + continue + available_version = bundled_version updates_available.append( { "id": ext_id, "name": ext_info.get("name", ext_id), # Display name for status messages "installed": str(installed_version), - "available": str(catalog_version), - "download_url": ext_info.get("download_url"), + "available": str(available_version), + "download_url": download_url, + "bundled_dir": bundled_dir, } ) + elif catalog_version == installed_version and _bundled_content_is_stale( + ext_id, metadata, ext_info, manager, installed_version + ): + # Bundled content changed without a version bump (#4345): + # the semver comparison alone would report "Up to date" + # while the installed copy keeps missing shipped fixes. + # Guarded to equal versions: an installed copy newer than + # the catalog (e.g. written by a newer CLI) must not be + # steered into a downgrading --force refresh. + stale_content.append(ext_id) + console.print( + f"⚠ {safe_ext_id}: v{installed_version} matches the catalog, but the " + f"installed files differ from the copy bundled with this spec-kit version" + ) + console.print( + f" Refresh with: specify extension add {_command_safe_id(ext_id)} --force" + ) else: console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})") if not updates_available: - console.print("\n[green]All extensions are up to date![/green]") + if blocked_updates: + console.print( + "\n[yellow]Update(s) exist but require a newer spec-kit " + "release — upgrade spec-kit, then rerun " + "'specify extension update'.[/yellow]" + ) + elif stale_content: + console.print( + "\n[yellow]No version updates available, but the extension(s) " + "flagged above have stale content.[/yellow]" + ) + else: + console.print("\n[green]All extensions are up to date![/green]") raise typer.Exit(0) # Show available updates @@ -1968,8 +2132,15 @@ def backup_extension_skills(skill_names, *, skills_dir=None): if ext_hooks: backup_hooks[hook_name] = ext_hooks - # 5. Download new version - archive_path = catalog.download_extension(extension_id) + # 5. Acquire the new version. Bundled extensions install from + # the copy shipped with the running spec-kit release (they + # have no download URL); everything else downloads. Both are + # packaged as archives so the identical validation, + # backup/rollback, and install pipeline below applies. + if update.get("bundled_dir") is not None: + archive_path = _archive_extension_directory(update["bundled_dir"]) + else: + archive_path = catalog.download_extension(extension_id) try: # 6. Validate the archive and extension ID before modifying # the existing installation. The shared extractor applies diff --git a/tests/bundler_helpers.py b/tests/bundler_helpers.py index 0ebaf2f1c7..2df11e03df 100644 --- a/tests/bundler_helpers.py +++ b/tests/bundler_helpers.py @@ -43,6 +43,24 @@ def valid_manifest_dict(**overrides) -> dict: return data +def bundled_extension_version(extension_id: str) -> str: + """Version declared by the bundled extension the primitives will install. + + Resolved through the same lookup ``BundleExtensionPrimitive`` uses, so + fixtures that pin a real bundled extension stay valid across legitimate + extension version bumps (#4345) instead of hardcoding a literal that + drifts out of sync and trips the exact-pin enforcement. + """ + from specify_cli._assets import _locate_bundled_extension + + bundled_dir = _locate_bundled_extension(extension_id) + assert bundled_dir is not None, f"bundled extension '{extension_id}' not found" + manifest = yaml.safe_load( + (bundled_dir / "extension.yml").read_text(encoding="utf-8") + ) + return manifest["extension"]["version"] + + def write_manifest(directory: Path, data: dict | None = None) -> Path: directory.mkdir(parents=True, exist_ok=True) manifest_path = directory / "bundle.yml" diff --git a/tests/contract/test_bundled_extension_versions.py b/tests/contract/test_bundled_extension_versions.py new file mode 100644 index 0000000000..f34a31fcfa --- /dev/null +++ b/tests/contract/test_bundled_extension_versions.py @@ -0,0 +1,64 @@ +"""Contract tests: bundled extension versions must stay in sync with the catalog. + +``specify extension update`` decides whether an installed extension needs +updating by comparing the semver in ``extensions/catalog.json`` against the +installed copy's registered version, and its preflight rejects a manifest +whose version differs from the catalog's. A catalog entry that drifts from +its ``extension.yml`` therefore either hides updates from every installed +copy or makes every offered update fail validation (#4345). + +The companion "content change requires a version bump" rule needs the git +diff of a PR and lives in CI +(``.github/scripts/check_extension_version_bump.py`` via the +``extension-version-guard.yml`` workflow); this test enforces the half that +is checkable from a plain working tree. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).parents[2] +EXTENSIONS_ROOT = REPO_ROOT / "extensions" + + +def _catalog_entries() -> dict[str, dict]: + catalog = json.loads((EXTENSIONS_ROOT / "catalog.json").read_text(encoding="utf-8")) + return catalog["extensions"] + + +def _manifest_version(ext_id: str) -> str: + manifest_path = EXTENSIONS_ROOT / ext_id / "extension.yml" + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + return data["extension"]["version"] + + +def test_catalog_lists_extensions(): + assert _catalog_entries(), "expected at least one extension in extensions/catalog.json" + + +@pytest.mark.parametrize("ext_id", sorted(_catalog_entries())) +def test_catalog_version_matches_manifest(ext_id: str): + entry = _catalog_entries()[ext_id] + manifest_path = EXTENSIONS_ROOT / ext_id / "extension.yml" + if not manifest_path.is_file(): + pytest.skip(f"'{ext_id}' has no in-repo extension directory") + assert entry.get("version") == _manifest_version(ext_id), ( + f"extensions/catalog.json entry '{ext_id}' and {manifest_path.relative_to(REPO_ROOT)} " + f"declare different versions - `specify extension update` compares against the " + f"catalog, so the two must move together" + ) + + +@pytest.mark.parametrize("ext_id", sorted(_catalog_entries())) +def test_bundled_entries_ship_an_extension_directory(ext_id: str): + entry = _catalog_entries()[ext_id] + if not entry.get("bundled"): + pytest.skip(f"'{ext_id}' is not marked bundled") + assert (EXTENSIONS_ROOT / ext_id / "extension.yml").is_file(), ( + f"catalog marks '{ext_id}' as bundled but extensions/{ext_id}/extension.yml is missing" + ) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index f6be51caf6..5bc25b9332 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -145,7 +145,7 @@ def test_manifest_validates(self): m = ExtensionManifest(EXT_DIR / "extension.yml") assert m.id == "git" - assert m.version == "1.0.0" + assert m.version == "1.1.0" def test_manifest_commands(self): """Manifest declares expected commands.""" diff --git a/tests/integration/test_bundler_init_install.py b/tests/integration/test_bundler_init_install.py index a13def5ff8..291c67871f 100644 --- a/tests/integration/test_bundler_init_install.py +++ b/tests/integration/test_bundler_init_install.py @@ -18,7 +18,7 @@ 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 +from tests.bundler_helpers import bundled_extension_version, valid_manifest_dict runner = CliRunner() @@ -75,7 +75,14 @@ def _build_mini(tmp_path: Path) -> Path: "license": "MIT", }, "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}, + "provides": { + "extensions": [ + { + "id": "agent-context", + "version": bundled_extension_version("agent-context"), + } + ] + }, } ), encoding="utf-8", diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py index 630c981a73..e9229e5c4d 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/integration/test_bundler_local_install.py @@ -18,7 +18,12 @@ 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 +from tests.bundler_helpers import ( + bundled_extension_version, + make_project, + valid_manifest_dict, + write_manifest, +) def test_local_source_none_for_non_path(): @@ -116,7 +121,12 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path): }, "requires": {"speckit_version": ">=0.1.0"}, "provides": { - "extensions": [{"id": "agent-context", "version": "1.0.0"}] + "extensions": [ + { + "id": "agent-context", + "version": bundled_extension_version("agent-context"), + } + ] }, } ), diff --git a/tests/test_extension_content_staleness.py b/tests/test_extension_content_staleness.py new file mode 100644 index 0000000000..7d649b0bd3 --- /dev/null +++ b/tests/test_extension_content_staleness.py @@ -0,0 +1,343 @@ +"""Tests for bundled-extension content staleness detection (#4345). + +The registry's manifest_hash only covers extension.yml, so bundled +extension content that changed upstream without a version bump used to be +undetectable: `specify extension update` compared semver only and reported +"Up to date" forever. These tests cover the content hash that closes that +gap and the update command's stale-content reporting. +""" + +from __future__ import annotations + +import pytest +import yaml +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ( + ExtensionCatalog, + ExtensionManager, + compute_extension_content_hash, +) + + +def _create_extension_source( + base_dir: Path, name: str = "test-ext", version: str = "1.0.0" +) -> Path: + """Create a minimal installable extension source directory.""" + ext_dir = base_dir / name + ext_dir.mkdir(parents=True, exist_ok=True) + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": version, + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.test-ext.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False)) + commands_dir = ext_dir / "commands" + commands_dir.mkdir(exist_ok=True) + (commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n") + scripts_dir = ext_dir / "scripts" + scripts_dir.mkdir(exist_ok=True) + (scripts_dir / "run.sh").write_text("#!/bin/sh\necho hello\n") + (ext_dir / "test-ext-config.yml").write_text("setting: default\n") + return ext_dir + + +def _make_project(tmp_path: Path) -> Path: + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + return project_dir + + +BUNDLED_CATALOG_INFO = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, +} + + +class TestComputeExtensionContentHash: + def test_deterministic(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + assert compute_extension_content_hash(ext_dir) == compute_extension_content_hash( + ext_dir + ) + + def test_identical_copies_hash_equal(self, tmp_path): + a = _create_extension_source(tmp_path / "a") + b = _create_extension_source(tmp_path / "b") + assert compute_extension_content_hash(a) == compute_extension_content_hash(b) + + def test_content_change_changes_hash(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + before = compute_extension_content_hash(ext_dir) + (ext_dir / "scripts" / "run.sh").write_text("#!/bin/sh\necho fixed\n") + assert compute_extension_content_hash(ext_dir) != before + + def test_new_file_changes_hash(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + before = compute_extension_content_hash(ext_dir) + (ext_dir / "scripts" / "extra.sh").write_text("#!/bin/sh\n") + assert compute_extension_content_hash(ext_dir) != before + + def test_user_config_files_excluded(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + before = compute_extension_content_hash(ext_dir) + (ext_dir / "test-ext-config.yml").write_text("setting: user-edited\n") + (ext_dir / "test-ext-config.local.yml").write_text("local: override\n") + assert compute_extension_content_hash(ext_dir) == before + + def test_nested_config_suffixed_files_are_hashed(self, tmp_path): + """Only top-level config files are preserved across installs + (_target_follows_preserved_convention); a nested *-config.yml is + overwritten by installation, so its changes are real staleness.""" + ext_dir = _create_extension_source(tmp_path) + templates = ext_dir / "templates" + templates.mkdir() + (templates / "scaffold-config.yml").write_text("shipped: v1\n") + before = compute_extension_content_hash(ext_dir) + (templates / "scaffold-config.yml").write_text("shipped: v2\n") + assert compute_extension_content_hash(ext_dir) != before + + def test_extensionignore_and_ignored_files_excluded(self, tmp_path): + ext_dir = _create_extension_source(tmp_path) + before = compute_extension_content_hash(ext_dir) + (ext_dir / ".extensionignore").write_text("*.log\n") + (ext_dir / "debug.log").write_text("noise\n") + assert compute_extension_content_hash(ext_dir) == before + + def test_symlinks_are_never_followed(self, tmp_path): + """A symlink inside the extension dir must not pull external bytes + into the hash (never follow symlinks out of the project root).""" + ext_dir = _create_extension_source(tmp_path) + outside = tmp_path / "outside.txt" + outside.write_text("external bytes\n") + before = compute_extension_content_hash(ext_dir) + try: + (ext_dir / "scripts" / "link.txt").symlink_to(outside) + except OSError: + pytest.skip("symlink creation requires privileges on this platform") + assert compute_extension_content_hash(ext_dir) == before + + def test_matches_between_source_and_installation(self, tmp_path): + """An install made from a source dir hashes identically to it.""" + project_dir = _make_project(tmp_path) + source = _create_extension_source(tmp_path) + manager = ExtensionManager(project_dir) + manager.install_from_directory(source, "0.1.0") + + installed_dir = project_dir / ".specify" / "extensions" / "test-ext" + # User edits to the preserved config file must not affect parity. + (installed_dir / "test-ext-config.yml").write_text("setting: user-edited\n") + assert compute_extension_content_hash( + installed_dir + ) == compute_extension_content_hash(source) + + +class TestArchiveExtensionDirectory: + def test_archive_contains_regular_files_only(self, tmp_path): + import zipfile + + from specify_cli.extensions._commands import _archive_extension_directory + + ext_dir = _create_extension_source(tmp_path) + archive_path = _archive_extension_directory(ext_dir) + try: + with zipfile.ZipFile(archive_path) as zf: + names = set(zf.namelist()) + assert "extension.yml" in names + assert "commands/hello.md" in names + finally: + archive_path.unlink() + + def test_archive_never_follows_symlinks(self, tmp_path): + """A symlink in the source must not pull out-of-tree bytes into the + archive before the hardened extractor sees it.""" + import zipfile + + from specify_cli.extensions._commands import _archive_extension_directory + + ext_dir = _create_extension_source(tmp_path) + outside = tmp_path / "outside.txt" + outside.write_text("external bytes\n") + try: + (ext_dir / "scripts" / "link.txt").symlink_to(outside) + except OSError: + pytest.skip("symlink creation requires privileges on this platform") + + archive_path = _archive_extension_directory(ext_dir) + try: + with zipfile.ZipFile(archive_path) as zf: + names = set(zf.namelist()) + assert "scripts/link.txt" not in names + finally: + archive_path.unlink() + + +class TestInstallStoresContentHash: + def test_registry_entry_records_source_content_hash(self, tmp_path): + project_dir = _make_project(tmp_path) + source = _create_extension_source(tmp_path) + manager = ExtensionManager(project_dir) + manager.install_from_directory(source, "0.1.0") + + entry = manager.registry.get("test-ext") + assert entry["content_hash"] == compute_extension_content_hash(source) + + +class TestUpdateStaleContentDetection: + def _install(self, tmp_path): + project_dir = _make_project(tmp_path) + source = _create_extension_source(tmp_path / "bundled") + manager = ExtensionManager(project_dir) + manager.install_from_directory(source, "0.1.0") + return project_dir, source + + @staticmethod + def _flat(result) -> str: + """Console output with Rich's line wrapping collapsed.""" + return " ".join(result.output.split()) + + def _run_update(self, project_dir, bundled_path, catalog_info=None): + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + ExtensionCatalog, + "get_extension_info", + return_value=dict(catalog_info or BUNDLED_CATALOG_INFO), + ), \ + patch( + "specify_cli._locate_bundled_extension", + return_value=bundled_path, + ): + return runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + def test_reports_stale_content_when_bundled_copy_changed(self, tmp_path): + project_dir, source = self._install(tmp_path) + # Upstream ships a fix without bumping the version. + (source / "scripts" / "run.sh").write_text("#!/bin/sh\necho fixed\n") + + result = self._run_update(project_dir, source) + + assert result.exit_code == 0, result.output + assert "differ from the copy bundled" in self._flat(result) + assert "extension add test-ext --force" in self._flat(result) + assert "All extensions are up to date!" not in self._flat(result) + + def test_up_to_date_when_bundled_copy_matches(self, tmp_path): + project_dir, source = self._install(tmp_path) + + result = self._run_update(project_dir, source) + + assert result.exit_code == 0, result.output + assert "Up to date (v1.0.0)" in self._flat(result) + assert "All extensions are up to date!" in self._flat(result) + assert "differ from the copy bundled" not in self._flat(result) + + def test_stale_check_covers_registry_entries_without_content_hash(self, tmp_path): + """Installs that predate content_hash fall back to hashing the installed dir.""" + project_dir, source = self._install(tmp_path) + manager = ExtensionManager(project_dir) + entry = manager.registry.get("test-ext") + del entry["content_hash"] + manager.registry.data["extensions"]["test-ext"] = entry + manager.registry._save() + (source / "scripts" / "run.sh").write_text("#!/bin/sh\necho fixed\n") + + result = self._run_update(project_dir, source) + + assert result.exit_code == 0, result.output + assert "differ from the copy bundled" in self._flat(result) + + def test_no_stale_flag_when_bundled_copy_is_older_version(self, tmp_path): + """An installed copy newer than the running release's bundled copy is + version skew, not content drift — flagging it would steer the user + into a downgrading --force refresh.""" + project_dir = _make_project(tmp_path) + v2_source = _create_extension_source(tmp_path / "installed-src", version="2.0.0") + ExtensionManager(project_dir).install_from_directory(v2_source, "0.1.0") + old_bundled = _create_extension_source(tmp_path / "bundled", version="1.0.0") + (old_bundled / "scripts" / "run.sh").write_text("#!/bin/sh\necho old\n") + catalog_info = dict(BUNDLED_CATALOG_INFO) + catalog_info["version"] = "2.0.0" + + result = self._run_update(project_dir, old_bundled, catalog_info) + + assert result.exit_code == 0, result.output + assert "Up to date (v2.0.0)" in self._flat(result) + assert "differ from the copy bundled" not in self._flat(result) + + def test_no_stale_flag_when_catalog_lags_installed_version(self, tmp_path): + """The stale check only runs when catalog and installed versions are + equal; a catalog behind the installed version must not trigger it.""" + project_dir = _make_project(tmp_path) + v2_source = _create_extension_source(tmp_path / "installed-src", version="2.0.0") + ExtensionManager(project_dir).install_from_directory(v2_source, "0.1.0") + old_bundled = _create_extension_source(tmp_path / "bundled", version="1.0.0") + (old_bundled / "scripts" / "run.sh").write_text("#!/bin/sh\necho old\n") + + result = self._run_update(project_dir, old_bundled) + + assert result.exit_code == 0, result.output + assert "Up to date (v2.0.0)" in self._flat(result) + assert "differ from the copy bundled" not in self._flat(result) + + def test_user_config_edits_are_not_reported_as_stale(self, tmp_path): + project_dir, source = self._install(tmp_path) + installed_config = ( + project_dir / ".specify" / "extensions" / "test-ext" / "test-ext-config.yml" + ) + installed_config.write_text("setting: user-edited\n") + + result = self._run_update(project_dir, source) + + assert result.exit_code == 0, result.output + assert "All extensions are up to date!" in self._flat(result) + + def test_non_bundled_extensions_skip_the_content_check(self, tmp_path): + project_dir, source = self._install(tmp_path) + (source / "scripts" / "run.sh").write_text("#!/bin/sh\necho fixed\n") + catalog_info = dict(BUNDLED_CATALOG_INFO) + catalog_info["bundled"] = False + catalog_info["download_url"] = "https://example.com/test-ext-1.0.0.zip" + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + ExtensionCatalog, "get_extension_info", return_value=catalog_info + ), \ + patch( + "specify_cli._locate_bundled_extension", + return_value=source, + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + assert result.exit_code == 0, result.output + assert "All extensions are up to date!" in self._flat(result) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..aec32dc4ba 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -9190,6 +9190,212 @@ def fake_install_from_zip(self_obj, _zip_path, speckit_version): ).read_text() assert restored_config_content == original_config_content + def test_update_installs_bundled_extension_from_local_copy(self, tmp_path): + """A bundled extension (no download URL) updates from the copy shipped + with the running spec-kit release instead of failing at download (#4345).""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v2_dir + ), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("bundled update must not download"), + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "Updated to v2.0.0" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "2.0.0" + + def test_update_bundled_blocked_when_local_copy_lags_catalog(self, tmp_path): + """When the catalog advertises a newer version than the running release + bundles, the update is reported as requiring a spec-kit upgrade instead + of being offered and then failing.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v1_dir + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "only ships v1.0.0" in flat + assert "upgrade spec-kit" in flat + assert "Update these extensions?" not in flat + assert "All extensions are up to date!" not in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_bundled_blocked_when_local_copy_is_intermediate_version(self, tmp_path): + """A bundled copy newer than the installation but older than the + catalog must be blocked, not installed: an intermediate version would + leave the project lagging the catalog while reporting success.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "3.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v2_dir + ), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("blocked bundled update must not download"), + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "only ships v2.0.0" in flat + assert "upgrade spec-kit" in flat + assert "Update these extensions?" not in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_installs_bundled_copy_newer_than_catalog(self, tmp_path): + """A dev/source checkout can ship a copy newer than the fetched + catalog advertises; the local copy is offered and installed.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v3_dir = self._create_extension_source(tmp_path, "3.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v3_dir + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "Updated to v3.0.0" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "3.0.0" + + def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path): + """A bundled catalog entry with no locally shipped copy points at a + spec-kit upgrade instead of failing the update at download time.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=None + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "does not ship a local copy" in flat + assert "upgrade spec-kit" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch): """Failed update should restore original registry, hooks, and command files.""" from typer.testing import CliRunner