diff --git a/src/specify_cli/extensions/_command_update_discovery.py b/src/specify_cli/extensions/_command_update_discovery.py index c4bbd5241d..d028b505d1 100644 --- a/src/specify_cli/extensions/_command_update_discovery.py +++ b/src/specify_cli/extensions/_command_update_discovery.py @@ -98,7 +98,10 @@ def discover_updates( continue try: - catalog_version = pkg_version.Version(ext_info["version"]) + raw_catalog_version = ext_info.get("version") + if not isinstance(raw_catalog_version, str): + raise pkg_version.InvalidVersion(raw_catalog_version) + catalog_version = pkg_version.Version(raw_catalog_version) except pkg_version.InvalidVersion: console.print( f"⚠ {safe_ext_id}: Invalid catalog version " diff --git a/tests/specify_cli/extensions/test_command_update.py b/tests/specify_cli/extensions/test_command_update.py index c7428677a8..c454057994 100644 --- a/tests/specify_cli/extensions/test_command_update.py +++ b/tests/specify_cli/extensions/test_command_update.py @@ -667,6 +667,35 @@ def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path): assert "upgrade spec-kit" in flat assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + def test_update_skips_catalog_entry_without_version(self, tmp_path): + """A catalog entry with no version is skipped with a warning instead of + aborting the command with a KeyError.""" + 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", + "download_url": "https://example.com/test-ext.zip", + "_install_allowed": True, + }): + result = runner.invoke(app, ["extension", "update"], catch_exceptions=True) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "test-ext: Invalid catalog version 'None' (skipping)" 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 specify_cli import app diff --git a/tests/specify_cli/extensions/test_command_update_discovery.py b/tests/specify_cli/extensions/test_command_update_discovery.py index f9c3897ac2..6eaa18e242 100644 --- a/tests/specify_cli/extensions/test_command_update_discovery.py +++ b/tests/specify_cli/extensions/test_command_update_discovery.py @@ -3,6 +3,7 @@ from pathlib import Path +import pytest from packaging import version as pkg_version from specify_cli.extensions import _commands @@ -74,6 +75,31 @@ def test_discovery_returns_typed_candidate(): assert updates[0].catalog_name == "test" +@pytest.mark.parametrize( + "version_field", [{}, {"version": None}, {"version": []}, {"version": 2}] +) +def test_discovery_skips_catalog_entry_without_valid_version(capsys, version_field): + manager = _Manager( + [{"id": "broken-ext"}, {"id": "test-ext"}], + {"broken-ext": {"version": "1.0.0"}, "test-ext": {"version": "1.0.0"}}, + ) + catalog = _Catalog( + { + "broken-ext": {"id": "broken-ext", "name": "Broken", **version_field}, + "test-ext": {"id": "test-ext", "name": "Test Extension", "version": "2.0.0"}, + } + ) + + updates, blocked, has_installed = discover_updates(manager, catalog, None) + + output = " ".join(capsys.readouterr().out.split()) + assert "broken-ext: Invalid catalog version" in output + assert "(skipping)" in output + assert [update.extension_id for update in updates] == ["test-ext"] + assert blocked == [] + assert has_installed is True + + def test_discovery_blocks_stale_bundled_source(monkeypatch, tmp_path: Path): manager = _Manager( [{"id": "test-ext"}],