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..86a76a0b8 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() @@ -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 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." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + 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..53fc97d6e 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,6 +1872,14 @@ 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 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 has no download URL. " + 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: raise ExtensionError(f"Extension '{extension_id}' has no download URL") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c5aed03dc..a6ddff8e1 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2995,6 +2995,122 @@ 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 without a URL.""" + 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_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 + + 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."""