From b3e04d37cfb319e553f16d33f11b9c29e21d7a54 Mon Sep 17 00:00:00 2001 From: Parman Mohammadalizadeh Date: Sat, 24 Oct 2026 19:34:01 +0200 Subject: [PATCH 1/2] fix(extensions): skip catalog entries without a version on update `specify extension update` read the catalog version with `ext_info["version"]`, so an entry with no `version` raised KeyError, and a null or list value raised TypeError or AttributeError. Any of these aborted the command before the remaining installed extensions were checked. Read the value the way `specify workflow update` does, so these entries take the existing "Invalid catalog version ... (skipping)" path. Fixes #5082 --- .../extensions/_command_update_discovery.py | 2 +- .../extensions/test_command_update.py | 29 +++++++++++++++++++ .../test_command_update_discovery.py | 24 +++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/_command_update_discovery.py b/src/specify_cli/extensions/_command_update_discovery.py index c4bbd5241d..cde3d814d5 100644 --- a/src/specify_cli/extensions/_command_update_discovery.py +++ b/src/specify_cli/extensions/_command_update_discovery.py @@ -98,7 +98,7 @@ def discover_updates( continue try: - catalog_version = pkg_version.Version(ext_info["version"]) + catalog_version = pkg_version.Version(str(ext_info.get("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..9994252be9 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,29 @@ def test_discovery_returns_typed_candidate(): assert updates[0].catalog_name == "test" +@pytest.mark.parametrize("version_field", [{}, {"version": None}, {"version": []}]) +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"}], From 7cef29a0bbc26fba4a94b0d5230456c8a8e2eb6f Mon Sep 17 00:00:00 2001 From: Parman Mohammadalizadeh Date: Mon, 26 Oct 2026 15:47:19 +0100 Subject: [PATCH 2/2] fix(extensions): skip catalog entries with a non-string version on update A numeric catalog version such as `"version": 2` passed discovery once coerced with str(), but the download rejects non-string versions, so the update was offered and then always failed. Require a string and take the existing "Invalid catalog version ... (skipping)" path otherwise. --- src/specify_cli/extensions/_command_update_discovery.py | 5 ++++- .../specify_cli/extensions/test_command_update_discovery.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/extensions/_command_update_discovery.py b/src/specify_cli/extensions/_command_update_discovery.py index cde3d814d5..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(str(ext_info.get("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_discovery.py b/tests/specify_cli/extensions/test_command_update_discovery.py index 9994252be9..6eaa18e242 100644 --- a/tests/specify_cli/extensions/test_command_update_discovery.py +++ b/tests/specify_cli/extensions/test_command_update_discovery.py @@ -75,7 +75,9 @@ def test_discovery_returns_typed_candidate(): assert updates[0].catalog_name == "test" -@pytest.mark.parametrize("version_field", [{}, {"version": None}, {"version": []}]) +@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"}],