Skip to content

Commit 51e6a14

Browse files
WOLIKIMCHENGroot
andauthored
refactor: migrate extension catalog stack parsing to shared base (#2576)
Co-authored-by: root <1647273252@qq.com>
1 parent 81e9ecd commit 51e6a14

2 files changed

Lines changed: 140 additions & 110 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 33 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from packaging import version as pkg_version
2626
from packaging.specifiers import SpecifierSet, InvalidSpecifier
2727

28+
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
29+
2830
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
2931
"analyze",
3032
"checklist",
@@ -107,13 +109,8 @@ def normalize_priority(value: Any, default: int = 10) -> int:
107109

108110

109111
@dataclass
110-
class CatalogEntry:
112+
class CatalogEntry(BaseCatalogEntry):
111113
"""Represents a single catalog entry in the catalog stack."""
112-
url: str
113-
name: str
114-
priority: int
115-
install_allowed: bool
116-
description: str = ""
117114

118115

119116
class ExtensionManifest:
@@ -1666,12 +1663,16 @@ def register_commands_for_claude(
16661663
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
16671664

16681665

1669-
class ExtensionCatalog:
1666+
class ExtensionCatalog(CatalogStackBase):
16701667
"""Manages extension catalog fetching, caching, and searching."""
16711668

16721669
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
16731670
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
16741671
CACHE_DURATION = 3600 # 1 hour in seconds
1672+
CONFIG_FILENAME = "extension-catalogs.yml"
1673+
ENTRY_CLASS = CatalogEntry
1674+
ERROR_TYPE = ValidationError
1675+
VALIDATION_ERROR_TYPE = ValidationError
16751676

16761677
def __init__(self, project_root: Path):
16771678
"""Initialize extension catalog manager.
@@ -1685,27 +1686,6 @@ def __init__(self, project_root: Path):
16851686
self.cache_file = self.cache_dir / "catalog.json"
16861687
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
16871688

1688-
def _validate_catalog_url(self, url: str) -> None:
1689-
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
1690-
1691-
Args:
1692-
url: URL to validate
1693-
1694-
Raises:
1695-
ValidationError: If URL is invalid or uses non-HTTPS scheme
1696-
"""
1697-
from urllib.parse import urlparse
1698-
1699-
parsed = urlparse(url)
1700-
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
1701-
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
1702-
raise ValidationError(
1703-
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
1704-
"HTTP is only allowed for localhost."
1705-
)
1706-
if not parsed.netloc:
1707-
raise ValidationError("Catalog URL must be a valid URL with a host.")
1708-
17091689
def _make_request(self, url: str):
17101690
"""Build a urllib Request, adding auth headers when a provider matches.
17111691
@@ -1722,81 +1702,6 @@ def _open_url(self, url: str, timeout: int = 10):
17221702
from specify_cli.authentication.http import open_url
17231703
return open_url(url, timeout)
17241704

1725-
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
1726-
"""Load catalog stack configuration from a YAML file.
1727-
1728-
Args:
1729-
config_path: Path to extension-catalogs.yml
1730-
1731-
Returns:
1732-
Ordered list of CatalogEntry objects, or None if file doesn't exist.
1733-
1734-
Raises:
1735-
ValidationError: If any catalog entry has an invalid URL,
1736-
the file cannot be parsed, a priority value is invalid,
1737-
or the file exists but contains no valid catalog entries
1738-
(fail-closed for security).
1739-
"""
1740-
if not config_path.exists():
1741-
return None
1742-
try:
1743-
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
1744-
except (yaml.YAMLError, OSError, UnicodeError) as e:
1745-
raise ValidationError(
1746-
f"Failed to read catalog config {config_path}: {e}"
1747-
)
1748-
catalogs_data = data.get("catalogs", [])
1749-
if not catalogs_data:
1750-
# File exists but has no catalogs key or empty list - fail closed
1751-
raise ValidationError(
1752-
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
1753-
f"Remove the file to use built-in defaults, or add valid catalog entries."
1754-
)
1755-
if not isinstance(catalogs_data, list):
1756-
raise ValidationError(
1757-
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
1758-
)
1759-
entries: List[CatalogEntry] = []
1760-
skipped_entries: List[int] = []
1761-
for idx, item in enumerate(catalogs_data):
1762-
if not isinstance(item, dict):
1763-
raise ValidationError(
1764-
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
1765-
)
1766-
url = str(item.get("url", "")).strip()
1767-
if not url:
1768-
skipped_entries.append(idx)
1769-
continue
1770-
self._validate_catalog_url(url)
1771-
try:
1772-
priority = int(item.get("priority", idx + 1))
1773-
except (TypeError, ValueError):
1774-
raise ValidationError(
1775-
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
1776-
f"expected integer, got {item.get('priority')!r}"
1777-
)
1778-
raw_install = item.get("install_allowed", False)
1779-
if isinstance(raw_install, str):
1780-
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
1781-
else:
1782-
install_allowed = bool(raw_install)
1783-
entries.append(CatalogEntry(
1784-
url=url,
1785-
name=str(item.get("name", f"catalog-{idx + 1}")),
1786-
priority=priority,
1787-
install_allowed=install_allowed,
1788-
description=str(item.get("description", "")),
1789-
))
1790-
entries.sort(key=lambda e: e.priority)
1791-
if not entries:
1792-
# All entries were invalid (missing URLs) - fail closed for security
1793-
raise ValidationError(
1794-
f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs "
1795-
f"(entries at indices {skipped_entries} were skipped). "
1796-
f"Each catalog entry must have a 'url' field."
1797-
)
1798-
return entries
1799-
18001705
def get_active_catalogs(self) -> List[CatalogEntry]:
18011706
"""Get the ordered list of active catalogs.
18021707
@@ -1826,24 +1731,44 @@ def get_active_catalogs(self) -> List[CatalogEntry]:
18261731
file=sys.stderr,
18271732
)
18281733
self._non_default_catalog_warning_shown = True
1829-
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
1734+
return [
1735+
self._entry(
1736+
url=catalog_url,
1737+
name="custom",
1738+
priority=1,
1739+
install_allowed=True,
1740+
description="Custom catalog via SPECKIT_CATALOG_URL",
1741+
)
1742+
]
18301743

18311744
# 2. Project-level config overrides all defaults
1832-
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
1745+
project_config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
18331746
catalogs = self._load_catalog_config(project_config_path)
18341747
if catalogs is not None:
18351748
return catalogs
18361749

18371750
# 3. User-level config
1838-
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
1751+
user_config_path = Path.home() / ".specify" / self.CONFIG_FILENAME
18391752
catalogs = self._load_catalog_config(user_config_path)
18401753
if catalogs is not None:
18411754
return catalogs
18421755

18431756
# 4. Built-in default stack
18441757
return [
1845-
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
1846-
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
1758+
self._entry(
1759+
url=self.DEFAULT_CATALOG_URL,
1760+
name="default",
1761+
priority=1,
1762+
install_allowed=True,
1763+
description="Built-in catalog of installable extensions",
1764+
),
1765+
self._entry(
1766+
url=self.COMMUNITY_CATALOG_URL,
1767+
name="community",
1768+
priority=2,
1769+
install_allowed=False,
1770+
description="Community-contributed extensions (discovery only)",
1771+
),
18471772
]
18481773

18491774
def get_catalog_url(self) -> str:

tests/test_extensions.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1846,7 +1846,7 @@ def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir):
18461846
registrar = CommandRegistrar()
18471847
from specify_cli.extensions import ExtensionManifest
18481848
manifest = ExtensionManifest(ext_dir / "extension.yml")
1849-
registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
1849+
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
18501850

18511851
skill_subdir = skills_dir / "speckit-cleanup-ext-run"
18521852
assert skill_subdir.exists(), "Skill subdirectory should exist after registration"
@@ -2580,7 +2580,8 @@ def fake_open(req, timeout=None):
25802580
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
25812581
"""download_extension passes Authorization header when a provider is configured."""
25822582
from unittest.mock import patch, MagicMock
2583-
import zipfile, io
2583+
import zipfile
2584+
import io
25842585

25852586
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
25862587
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
@@ -2854,6 +2855,110 @@ def test_load_catalog_config_localhost_allowed(self, temp_dir):
28542855
assert len(entries) == 1
28552856
assert entries[0].url == "http://localhost:8000/catalog.json"
28562857

2858+
@pytest.mark.parametrize(
2859+
"config_content", ["[]\n", "false\n", "0\n", "''\n", "- item\n"]
2860+
)
2861+
def test_load_catalog_config_rejects_non_mapping_roots(
2862+
self, temp_dir, config_content
2863+
):
2864+
"""Malformed roots raise ValidationError, not fallback or AttributeError."""
2865+
project_dir = self._make_project(temp_dir)
2866+
config_path = project_dir / ".specify" / "extension-catalogs.yml"
2867+
config_path.write_text(config_content, encoding="utf-8")
2868+
2869+
catalog = ExtensionCatalog(project_dir)
2870+
2871+
with pytest.raises(
2872+
ValidationError, match="expected a YAML mapping at the root"
2873+
) as exc_info:
2874+
catalog.get_active_catalogs()
2875+
assert str(config_path) in str(exc_info.value)
2876+
2877+
def test_load_catalog_config_rejects_boolean_priority(self, temp_dir):
2878+
"""Boolean priorities are rejected instead of being coerced to 1 or 0."""
2879+
import yaml as yaml_module
2880+
2881+
project_dir = self._make_project(temp_dir)
2882+
config_path = project_dir / ".specify" / "extension-catalogs.yml"
2883+
config_path.write_text(
2884+
yaml_module.dump(
2885+
{
2886+
"catalogs": [
2887+
{
2888+
"name": "bad-priority",
2889+
"url": "https://example.com/catalog.json",
2890+
"priority": True,
2891+
}
2892+
]
2893+
}
2894+
),
2895+
encoding="utf-8",
2896+
)
2897+
2898+
catalog = ExtensionCatalog(project_dir)
2899+
2900+
with pytest.raises(
2901+
ValidationError, match="Invalid priority|expected integer"
2902+
) as exc_info:
2903+
catalog.get_active_catalogs()
2904+
assert str(config_path) in str(exc_info.value)
2905+
2906+
def test_load_catalog_config_defaults_blank_names(self, temp_dir):
2907+
"""Blank and null names normalize by valid catalog order."""
2908+
import yaml as yaml_module
2909+
2910+
project_dir = self._make_project(temp_dir)
2911+
config_path = project_dir / ".specify" / "extension-catalogs.yml"
2912+
config_path.write_text(
2913+
yaml_module.dump(
2914+
{
2915+
"catalogs": [
2916+
{"name": "skipped", "url": " "},
2917+
{"name": None, "url": "https://one.example.com/catalog.json"},
2918+
{"name": " ", "url": "https://two.example.com/catalog.json"},
2919+
]
2920+
}
2921+
),
2922+
encoding="utf-8",
2923+
)
2924+
2925+
catalog = ExtensionCatalog(project_dir)
2926+
2927+
assert [entry.name for entry in catalog.get_active_catalogs()] == [
2928+
"catalog-1",
2929+
"catalog-2",
2930+
]
2931+
2932+
@pytest.mark.parametrize(
2933+
("url", "expected_detail"),
2934+
[
2935+
("relative/catalog.json", "HTTPS"),
2936+
("https:///no-host", "valid URL with a host"),
2937+
],
2938+
)
2939+
def test_load_catalog_config_invalid_url_includes_context(
2940+
self, temp_dir, url, expected_detail
2941+
):
2942+
"""Invalid catalog URLs include the config path and entry index."""
2943+
import yaml as yaml_module
2944+
2945+
project_dir = self._make_project(temp_dir)
2946+
config_path = project_dir / ".specify" / "extension-catalogs.yml"
2947+
config_path.write_text(
2948+
yaml_module.dump({"catalogs": [{"name": "bad", "url": url}]}),
2949+
encoding="utf-8",
2950+
)
2951+
2952+
catalog = ExtensionCatalog(project_dir)
2953+
2954+
with pytest.raises(ValidationError) as exc_info:
2955+
catalog.get_active_catalogs()
2956+
message = str(exc_info.value)
2957+
assert "Invalid catalog URL" in message
2958+
assert str(config_path) in message
2959+
assert "index 0" in message
2960+
assert expected_detail in message
2961+
28572962
# --- Merge conflict resolution ---
28582963

28592964
def test_merge_conflict_higher_priority_wins(self, temp_dir):

0 commit comments

Comments
 (0)