Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/specify_cli/extensions/_command_update_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +102 to +103
catalog_version = pkg_version.Version(raw_catalog_version)
except pkg_version.InvalidVersion:
console.print(
f"⚠ {safe_ext_id}: Invalid catalog version "
Expand Down
29 changes: 29 additions & 0 deletions tests/specify_cli/extensions/test_command_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/specify_cli/extensions/test_command_update_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from pathlib import Path

import pytest
from packaging import version as pkg_version

from specify_cli.extensions import _commands
Expand Down Expand Up @@ -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"}],
Expand Down
Loading