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
14 changes: 12 additions & 2 deletions src/specify_cli/extensions/_command_update_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,22 @@ def discover_updates(
)
continue

if "version" not in ext_info:
console.print(f"⚠ {safe_ext_id}: Missing catalog version (skipping)")
continue
raw_catalog_version = ext_info["version"]
if not isinstance(raw_catalog_version, str):
console.print(
f"⚠ {safe_ext_id}: Invalid catalog version: expected a string, "
f"got {type(raw_catalog_version).__name__} (skipping)"
)
continue
try:
catalog_version = pkg_version.Version(ext_info["version"])
catalog_version = pkg_version.Version(raw_catalog_version)
except pkg_version.InvalidVersion:
console.print(
f"⚠ {safe_ext_id}: Invalid catalog version "
f"'{_escape_markup(str(ext_info.get('version')))}' (skipping)"
f"'{_escape_markup(raw_catalog_version)}' (skipping)"
)
continue

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: Missing catalog version (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
34 changes: 34 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,39 @@ def test_discovery_returns_typed_candidate():
assert updates[0].catalog_name == "test"


@pytest.mark.parametrize(
("version_field", "warning"),
[
({}, "Missing catalog version"),
({"version": None}, "Invalid catalog version: expected a string, got NoneType"),
({"version": []}, "Invalid catalog version: expected a string, got list"),
({"version": 2}, "Invalid catalog version: expected a string, got int"),
({"version": "not-a-version"}, "Invalid catalog version 'not-a-version'"),
],
)
def test_discovery_skips_catalog_entry_without_valid_version(
capsys, version_field, warning
):
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 f"broken-ext: {warning} (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