From dc2ab17cf588fb51e9d56b7d5ec0252848584e68 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:26:34 -0500 Subject: [PATCH 1/3] fix: bundled extensions should not have download URLs (#2151) - Remove selftest from default catalog (not a published extension) - Replace download_url with 'bundled: true' flag for git extension - Add bundled check in extension add flow with clear error message when bundled extension is missing from installed package - Add bundled check in download_extension() with specific error - Direct users to reinstall via uv with full GitHub URL - Add 3 regression tests for bundled extension handling --- extensions/catalog.json | 18 +------- src/specify_cli/__init__.py | 13 ++++++ src/specify_cli/extensions.py | 6 +++ tests/test_extensions.py | 86 +++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/extensions/catalog.json b/extensions/catalog.json index a039883ba..de9372e2b 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { "git": { @@ -10,27 +10,13 @@ "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", - "download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip", + "bundled": true, "tags": [ "git", "branching", "workflow", "core" ] - }, - "selftest": { - "name": "Spec Kit Self-Test Utility", - "id": "selftest", - "version": "1.0.0", - "description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.", - "author": "spec-kit-core", - "repository": "https://github.com/github/spec-kit", - "download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip", - "tags": [ - "testing", - "core", - "utility" - ] } } } \ No newline at end of file diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 11b6e0eda..225314ff5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3103,6 +3103,19 @@ def extension_add( manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) if bundled_path is None: + # Bundled extensions must be installed from the local package + if ext_info.get("bundled"): + console.print( + f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(" uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git") + raise typer.Exit(1) + # Enforce install_allowed policy if not ext_info.get("_install_allowed", True): catalog_name = ext_info.get("_catalog_name", "community") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index da1a5f447..df1808e39 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1872,6 +1872,12 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non download_url = ext_info.get("download_url") if not download_url: + if ext_info.get("bundled"): + raise ExtensionError( + f"Extension '{extension_id}' is bundled with spec-kit and cannot be downloaded. " + f"It should be installed from the local package. " + f"Try reinstalling: uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" + ) raise ExtensionError(f"Extension '{extension_id}' has no download URL") # Validate download URL requires HTTPS (prevent man-in-the-middle attacks) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c5aed03dc..537e90679 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2995,6 +2995,92 @@ def mock_download(extension_id): f"but was called with '{download_called_with[0]}'" ) + def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path): + """extension add should give a clear error when a bundled extension is not found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + + runner = CliRunner() + + # Create project structure + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Mock catalog that returns a bundled extension without download_url + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = { + "id": "git", + "name": "Git Branching Workflow", + "version": "1.0.0", + "description": "Git branching extension", + "bundled": True, + "_install_allowed": True, + } + mock_catalog.search.return_value = [] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch("specify_cli._locate_bundled_extension", return_value=None), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "git"], + catch_exceptions=True, + ) + + assert result.exit_code != 0 + assert "bundled with spec-kit" in result.output + assert "reinstall" in result.output.lower() + + +class TestDownloadExtensionBundled: + """Tests for download_extension handling of bundled extensions.""" + + def test_download_extension_raises_for_bundled(self, temp_dir): + """download_extension should raise a clear error for bundled extensions.""" + from unittest.mock import patch + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + bundled_ext_info = { + "name": "Git Branching Workflow", + "id": "git", + "version": "1.0.0", + "description": "Git workflow", + "bundled": True, + } + + with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info): + with pytest.raises(ExtensionError, match="bundled with spec-kit"): + catalog.download_extension("git") + + def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir): + """download_extension should raise 'no download URL' for non-bundled extensions without URL.""" + from unittest.mock import patch + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + non_bundled_ext_info = { + "name": "Some Extension", + "id": "some-ext", + "version": "1.0.0", + "description": "Test", + } + + with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info): + with pytest.raises(ExtensionError, match="has no download URL"): + catalog.download_extension("some-ext") + class TestExtensionUpdateCLI: """CLI integration tests for extension update command.""" From d4aabf136b2d873767d897798fbc3f70a13e9f95 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:04:53 -0500 Subject: [PATCH 2/3] refactor: address review - move bundled check up-front, extract reinstall constant - Move bundled check before download_url inspection in download_extension() so bundled extensions can never be downloaded even with a URL present - Extract REINSTALL_COMMAND constant to avoid duplicated install strings --- src/specify_cli/__init__.py | 4 ++-- src/specify_cli/extensions.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 225314ff5..ebfce40aa 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3001,7 +3001,7 @@ def extension_add( priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), ): """Install an extension.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError + from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND project_root = Path.cwd() @@ -3113,7 +3113,7 @@ def extension_add( "\nThis usually means the spec-kit installation is incomplete or corrupted." ) console.print("Try reinstalling spec-kit:") - console.print(" uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git") + console.print(f" {REINSTALL_COMMAND}") raise typer.Exit(1) # Enforce install_allowed policy diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index df1808e39..34c8ac34b 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -38,6 +38,8 @@ }) EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") +REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" + def _load_core_command_names() -> frozenset[str]: """Discover bundled core command names from the packaged templates. @@ -1870,14 +1872,16 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non if not ext_info: raise ExtensionError(f"Extension '{extension_id}' not found in catalog") + # Bundled extensions must never be downloaded, even if a URL is present + if ext_info.get("bundled"): + raise ExtensionError( + f"Extension '{extension_id}' is bundled with spec-kit and cannot be downloaded. " + f"It should be installed from the local package. " + f"Try reinstalling: {REINSTALL_COMMAND}" + ) + download_url = ext_info.get("download_url") if not download_url: - if ext_info.get("bundled"): - raise ExtensionError( - f"Extension '{extension_id}' is bundled with spec-kit and cannot be downloaded. " - f"It should be installed from the local package. " - f"Try reinstalling: uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" - ) raise ExtensionError(f"Extension '{extension_id}' has no download URL") # Validate download URL requires HTTPS (prevent man-in-the-middle attacks) From 502bf6b906777b3605a2a30822979d1a70ca5252 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:06:09 -0500 Subject: [PATCH 3/3] fix: allow bundled extensions with download_url to be updated Bundled extensions should only be blocked from download when they have no download_url. If a newer version is published to the catalog with a URL, users should be able to install it to get bug fixes. Add test for bundled-with-URL download path. --- src/specify_cli/__init__.py | 4 ++-- src/specify_cli/extensions.py | 6 +++--- tests/test_extensions.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ebfce40aa..86a76a0b8 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3103,8 +3103,8 @@ def extension_add( manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) if bundled_path is None: - # Bundled extensions must be installed from the local package - if ext_info.get("bundled"): + # Bundled extensions without a download URL must come from the local package + if ext_info.get("bundled") and not ext_info.get("download_url"): console.print( f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " f"but could not be found in the installed package." diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 34c8ac34b..53fc97d6e 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1872,10 +1872,10 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non if not ext_info: raise ExtensionError(f"Extension '{extension_id}' not found in catalog") - # Bundled extensions must never be downloaded, even if a URL is present - if ext_info.get("bundled"): + # Bundled extensions without a download URL must be installed locally + if ext_info.get("bundled") and not ext_info.get("download_url"): raise ExtensionError( - f"Extension '{extension_id}' is bundled with spec-kit and cannot be downloaded. " + f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. " f"It should be installed from the local package. " f"Try reinstalling: {REINSTALL_COMMAND}" ) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 537e90679..a6ddff8e1 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3039,7 +3039,7 @@ class TestDownloadExtensionBundled: """Tests for download_extension handling of bundled extensions.""" def test_download_extension_raises_for_bundled(self, temp_dir): - """download_extension should raise a clear error for bundled extensions.""" + """download_extension should raise a clear error for bundled extensions without a URL.""" from unittest.mock import patch project_dir = temp_dir / "project" @@ -3060,6 +3060,36 @@ def test_download_extension_raises_for_bundled(self, temp_dir): with pytest.raises(ExtensionError, match="bundled with spec-kit"): catalog.download_extension("git") + def test_download_extension_allows_bundled_with_url(self, temp_dir): + """download_extension should allow bundled extensions that have a download_url (newer version).""" + from unittest.mock import patch, MagicMock + import urllib.request + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + bundled_with_url = { + "name": "Git Branching Workflow", + "id": "git", + "version": "2.0.0", + "description": "Git workflow", + "bundled": True, + "download_url": "https://example.com/git-2.0.0.zip", + } + + mock_response = MagicMock() + mock_response.read.return_value = b"fake zip data" + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \ + patch.object(urllib.request, "urlopen", return_value=mock_response): + result = catalog.download_extension("git") + assert result.name == "git-2.0.0.zip" + def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir): """download_extension should raise 'no download URL' for non-bundled extensions without URL.""" from unittest.mock import patch