Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
108 changes: 71 additions & 37 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,10 @@ def register_commands_for_claude(
class ExtensionCatalog:
"""Manages extension catalog fetching, caching, and searching."""

DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
DEFAULT_CATALOG_URLS = [
"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
"https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
]
CACHE_DURATION = 3600 # 1 hour in seconds

def __init__(self, project_root: Path):
Expand All @@ -984,15 +987,15 @@ def __init__(self, project_root: Path):
self.cache_file = self.cache_dir / "catalog.json"
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"

def get_catalog_url(self) -> str:
"""Get catalog URL from config or use default.
def get_catalog_urls(self) -> List[str]:
"""Get catalog URLs from config or use default.

Checks in order:
1. SPECKIT_CATALOG_URL environment variable
2. Default catalog URL
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated

Returns:
URL to fetch catalog from
List of URLs to fetch catalogs from

Raises:
ValidationError: If custom URL is invalid (non-HTTPS)
Expand Down Expand Up @@ -1021,7 +1024,7 @@ def get_catalog_url(self) -> str:
)

# Warn users when using a non-default catalog (once per instance)
if catalog_url != self.DEFAULT_CATALOG_URL:
if catalog_url not in self.DEFAULT_CATALOG_URLS:
if not getattr(self, "_non_default_catalog_warning_shown", False):
print(
"Warning: Using non-default extension catalog. "
Expand All @@ -1030,39 +1033,59 @@ def get_catalog_url(self) -> str:
)
self._non_default_catalog_warning_shown = True

return catalog_url
return [catalog_url]

# TODO: Support custom catalogs from .specify/extension-catalogs.yml
return self.DEFAULT_CATALOG_URL
return self.DEFAULT_CATALOG_URLS

def is_cache_valid(self) -> bool:
"""Check if cached catalog is still valid.
"""Check if cached catalog is still valid and matches current URL settings.

Returns:
True if cache exists and is within cache duration
True if cache exists, is within cache duration, and matches current URLs
"""
if not self.cache_file.exists() or not self.cache_metadata_file.exists():
return False

try:
metadata = json.loads(self.cache_metadata_file.read_text())
# Normalize cached URLs for backward compatibility:
# - New schema: "catalog_urls": [url1, url2, ...]
# - Old schema: "catalog_url": "single-url"
cached_urls = metadata.get("catalog_urls")
if isinstance(cached_urls, list) and cached_urls:
normalized_cached_urls = cached_urls
else:
# Fallback to legacy single-url schema if present
legacy_url = metadata.get("catalog_url")
if isinstance(legacy_url, str) and legacy_url:
normalized_cached_urls = [legacy_url]
else:
# No usable URL information in cache metadata
return False

# Check if the currently requested URLs match the cached configuration
current_urls = self.get_catalog_urls()
if normalized_cached_urls != current_urls:
return False

cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError):
return False

def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
"""Fetch extension catalog from URL or cache.
"""Fetch extension catalogs from URLs or cache and merge them.

Args:
force_refresh: If True, bypass cache and fetch from network

Returns:
Catalog data dictionary
Merged catalog data dictionary

Raises:
ExtensionError: If catalog cannot be fetched
ExtensionError: If catalogs cannot be fetched
"""
# Check cache first unless force refresh
if not force_refresh and self.is_cache_valid():
Expand All @@ -1072,36 +1095,47 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
pass # Fall through to network fetch

# Fetch from network
catalog_url = self.get_catalog_url()

try:
import urllib.request
import urllib.error
catalog_urls = self.get_catalog_urls()

merged_catalog = {
"schema_version": "1.0",
"extensions": {}
}
Comment thread
dhilipkumars marked this conversation as resolved.

with urllib.request.urlopen(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read())
import urllib.request
import urllib.error

for catalog_url in catalog_urls:
try:
with urllib.request.urlopen(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read())
except urllib.error.URLError as e:
raise ExtensionError(f"Failed to fetch catalog from network at {catalog_url}: {e}")
except json.JSONDecodeError as e:
raise ExtensionError(f"Invalid JSON in catalog payload from {catalog_url}: {e}")
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated

# Validate catalog structure
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
raise ExtensionError("Invalid catalog format")

# Save to cache
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file.write_text(json.dumps(catalog_data, indent=2))

# Save cache metadata
metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog_url,
}
self.cache_metadata_file.write_text(json.dumps(metadata, indent=2))

return catalog_data
raise ExtensionError(f"Invalid catalog format from {catalog_url}")

# Merge extensions into the aggregated catalog, preserving precedence for the first catalog URL
# that defines a given extension ID.
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
if ext_id not in merged_catalog["extensions"]:
merged_catalog["extensions"][ext_id] = ext_data

# Save to cache
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file.write_text(json.dumps(merged_catalog, indent=2))

# Save cache metadata
metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_urls": catalog_urls,
}
self.cache_metadata_file.write_text(json.dumps(metadata, indent=2))
Comment thread
dhilipkumars marked this conversation as resolved.
Outdated

except urllib.error.URLError as e:
raise ExtensionError(f"Failed to fetch catalog from {catalog_url}: {e}")
except json.JSONDecodeError as e:
raise ExtensionError(f"Invalid JSON in catalog: {e}")
return merged_catalog

def search(
self,
Expand Down
103 changes: 96 additions & 7 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ def test_cache_directory_creation(self, temp_dir):
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com/catalog.json",
"catalog_urls": catalog.get_catalog_urls(),
}
)
Comment thread
dhilipkumars marked this conversation as resolved.
)
Expand Down Expand Up @@ -870,7 +870,7 @@ def test_cache_expiration(self, temp_dir):
json.dumps(
{
"cached_at": expired_datetime.isoformat(),
"catalog_url": "http://test.com/catalog.json",
"catalog_urls": catalog.get_catalog_urls(),
}
)
Comment thread
dhilipkumars marked this conversation as resolved.
)
Expand Down Expand Up @@ -918,7 +918,7 @@ def test_search_all_extensions(self, temp_dir):
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
"catalog_urls": catalog.get_catalog_urls(),
}
)
Comment thread
dhilipkumars marked this conversation as resolved.
)
Expand Down Expand Up @@ -962,7 +962,7 @@ def test_search_by_query(self, temp_dir):
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
"catalog_urls": catalog.get_catalog_urls(),
}
)
Comment thread
dhilipkumars marked this conversation as resolved.
)
Expand Down Expand Up @@ -1014,7 +1014,7 @@ def test_search_by_tag(self, temp_dir):
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
"catalog_urls": catalog.get_catalog_urls(),
}
)
Comment thread
dhilipkumars marked this conversation as resolved.
)
Expand Down Expand Up @@ -1059,7 +1059,7 @@ def test_search_verified_only(self, temp_dir):
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
"catalog_urls": catalog.get_catalog_urls(),
}
)
Comment thread
dhilipkumars marked this conversation as resolved.
)
Expand Down Expand Up @@ -1097,7 +1097,7 @@ def test_get_extension_info(self, temp_dir):
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://test.com",
"catalog_urls": catalog.get_catalog_urls(),
}
)
Comment thread
dhilipkumars marked this conversation as resolved.
)
Expand Down Expand Up @@ -1133,3 +1133,92 @@ def test_clear_cache(self, temp_dir):

assert not catalog.cache_file.exists()
assert not catalog.cache_metadata_file.exists()

def test_fetch_catalog_merge_and_precedence(self, temp_dir, monkeypatch):
"""Test that multiple catalogs are fetched, merged, and earlier URLs have precedence."""
import json

project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()

catalog = ExtensionCatalog(project_dir)
catalog.DEFAULT_CATALOG_URLS = ["http://catalog1.test", "http://catalog2.test"]

Comment on lines +1145 to +1147
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new tests override catalog.DEFAULT_CATALOG_URLS, but get_catalog_urls() will ignore that override if SPECKIT_CATALOG_URL is set in the runner environment. Clear SPECKIT_CATALOG_URL at the start of the test (e.g., monkeypatch.delenv(..., raising=False)) to ensure the intended URLs are exercised.

Copilot uses AI. Check for mistakes.
# Mock responses
responses = {
"http://catalog1.test": {
"schema_version": "1.0",
"extensions": {
"ext-a": {"id": "ext-a", "version": "1.0.0", "source": "cat1"},
"ext-b": {"id": "ext-b", "version": "1.0.0", "source": "cat1"}
}
},
"http://catalog2.test": {
"schema_version": "1.0",
"extensions": {
"ext-a": {"id": "ext-a", "version": "2.0.0", "source": "cat2"}, # Duplicate
"ext-c": {"id": "ext-c", "version": "1.0.0", "source": "cat2"} # New
}
}
}

class MockResponse:
def __init__(self, url):
self.data = json.dumps(responses[url]).encode('utf-8')
def read(self):
return self.data
def __enter__(self):
return self
def __exit__(self, *args):
pass

def mock_urlopen(url, *args, **kwargs):
return MockResponse(url)

monkeypatch.setattr("urllib.request.urlopen", mock_urlopen)

# Force refresh to hit the network
merged = catalog.fetch_catalog(force_refresh=True)

assert "ext-a" in merged["extensions"]
assert "ext-b" in merged["extensions"]
assert "ext-c" in merged["extensions"]

# Precedence check: ext-a should come from catalog1 (version 1.0.0, source cat1)
assert merged["extensions"]["ext-a"]["source"] == "cat1"
assert merged["extensions"]["ext-a"]["version"] == "1.0.0"

# Cache correctly saved
assert catalog.cache_file.exists()
assert catalog.cache_metadata_file.exists()
metadata = json.loads(catalog.cache_metadata_file.read_text())
assert metadata["catalog_urls"] == catalog.DEFAULT_CATALOG_URLS

def test_cache_backward_compatibility(self, temp_dir):
"""Test that is_cache_valid handles the legacy 'catalog_url' string gracefully."""
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()

catalog = ExtensionCatalog(project_dir)
catalog.DEFAULT_CATALOG_URLS = ["http://legacy-url.test"]

catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}")

# Write legacy metadata with single string "catalog_url"
catalog.cache_metadata_file.write_text(
json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": "http://legacy-url.test"
})
)

# Cache should be valid because legacy URL matches DEFAULT_CATALOG_URLS exactly
# which evaluates to a normalized single-element list.
assert catalog.is_cache_valid()

# If url doesn't match, it should be invalid
catalog.DEFAULT_CATALOG_URLS = ["http://different.test"]
assert not catalog.is_cache_valid()
Loading