fix: address PR review feedback for multi-catalog support

- Rename 'org-approved' catalog to 'default'
- Move 'catalogs' command to 'catalog list' for consistency
- Add 'description' field to CatalogEntry dataclass
- Add --description option to 'catalog add' CLI command
- Align install_allowed default to False in _load_catalog_config
- Add user-level config detection in catalog list footer
- Fix _load_catalog_config docstring (document ValidationError)
- Fix test isolation for test_search_by_tag, test_search_by_query,
  test_search_verified_only, test_get_extension_info
- Update version to 0.1.14 and CHANGELOG
- Update all docs (RFC, User Guide, API Reference)
This commit is contained in:
Manfred Riem
2026-03-09 13:04:55 -05:00
parent 9a82f098e8
commit 990a1513c2
7 changed files with 204 additions and 59 deletions

View File

@@ -12,6 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- feat: add Tabnine CLI agent support - feat: add Tabnine CLI agent support
- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack
- New `specify extension catalog list` command lists all active catalogs with name, URL, priority, and `install_allowed` status
- New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management
- Default built-in stack includes `catalog.json` (default, installable) and `catalog.community.json` (community, discovery only) — community extensions are now surfaced in search results out of the box
- `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog
- `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly
- Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence
- `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog)
- All catalog URLs require HTTPS (HTTP allowed for localhost development)
- New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation
- Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog
- Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)
- 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement
- Updated RFC, Extension User Guide, and Extension API Reference documentation
## [0.1.13] - 2026-03-03 ## [0.1.13] - 2026-03-03

View File

@@ -254,9 +254,10 @@ from specify_cli.extensions import CatalogEntry
entry = CatalogEntry( entry = CatalogEntry(
url="https://example.com/catalog.json", url="https://example.com/catalog.json",
name="org-approved", name="default",
priority=1, priority=1,
install_allowed=True, install_allowed=True,
description="Built-in catalog of installable extensions",
) )
``` ```
@@ -268,6 +269,7 @@ entry = CatalogEntry(
| `name` | `str` | Human-readable catalog name | | `name` | `str` | Human-readable catalog name |
| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) | | `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) |
| `install_allowed` | `bool` | Whether extensions from this catalog can be installed | | `install_allowed` | `bool` | Whether extensions from this catalog can be installed |
| `description` | `str` | Optional human-readable description of the catalog (default: empty) |
### ExtensionCatalog ### ExtensionCatalog
@@ -282,7 +284,7 @@ catalog = ExtensionCatalog(project_root)
**Class attributes**: **Class attributes**:
```python ```python
ExtensionCatalog.DEFAULT_CATALOG_URL # org-approved catalog URL ExtensionCatalog.DEFAULT_CATALOG_URL # default catalog URL
ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL
``` ```
@@ -328,14 +330,16 @@ Each extension dict returned by `search()` and `get_extension_info()` includes:
```yaml ```yaml
catalogs: catalogs:
- name: "org-approved" - name: "default"
url: "https://example.com/catalog.json" url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1 priority: 1
install_allowed: true install_allowed: true
description: "Built-in catalog of installable extensions"
- name: "community" - name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 2 priority: 2
install_allowed: false install_allowed: false
description: "Community-contributed extensions (discovery only)"
``` ```
### HookExecutor ### HookExecutor
@@ -604,11 +608,11 @@ EXECUTE_COMMAND: {command}
**Output**: List of installed extensions with metadata **Output**: List of installed extensions with metadata
### extension catalogs ### extension catalog list
**Usage**: `specify extension catalogs` **Usage**: `specify extension catalog list`
Lists all active catalogs in the current catalog stack, showing name, URL, priority, and `install_allowed` status. Lists all active catalogs in the current catalog stack, showing name, description, URL, priority, and `install_allowed` status.
### extension catalog add ### extension catalog add
@@ -619,6 +623,7 @@ Lists all active catalogs in the current catalog stack, showing name, URL, prior
- `--name NAME` - Catalog name (required) - `--name NAME` - Catalog name (required)
- `--priority INT` - Priority (lower = higher priority, default: 10) - `--priority INT` - Priority (lower = higher priority, default: 10)
- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false) - `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false)
- `--description TEXT` - Optional description of the catalog
**Arguments**: **Arguments**:

View File

@@ -84,7 +84,7 @@ vim .specify/extensions/jira/jira-config.yml
specify extension search specify extension search
``` ```
Shows all extensions across all active catalogs (org-approved and community by default). Shows all extensions across all active catalogs (default and community by default).
### Search by Keyword ### Search by Keyword
@@ -423,13 +423,13 @@ Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simul
| Priority | Catalog | Install Allowed | Purpose | | Priority | Catalog | Install Allowed | Purpose |
|----------|---------|-----------------|---------| |----------|---------|-----------------|---------|
| 1 | `catalog.json` (org-approved) | ✅ Yes | Extensions your org approves for installation | | 1 | `catalog.json` (default) | ✅ Yes | Curated extensions available for installation |
| 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions | | 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions |
### Listing Active Catalogs ### Listing Active Catalogs
```bash ```bash
specify extension catalogs specify extension catalog list
``` ```
### Adding a Catalog (Project-scoped) ### Adding a Catalog (Project-scoped)
@@ -463,23 +463,26 @@ You can also edit `.specify/extension-catalogs.yml` directly:
```yaml ```yaml
catalogs: catalogs:
- name: "org-approved" - name: "default"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1 priority: 1
install_allowed: true install_allowed: true
description: "Built-in catalog of installable extensions"
- name: "internal" - name: "internal"
url: "https://internal.company.com/spec-kit/catalog.json" url: "https://internal.company.com/spec-kit/catalog.json"
priority: 2 priority: 2
install_allowed: true install_allowed: true
description: "Internal company extensions"
- name: "community" - name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 3 priority: 3
install_allowed: false install_allowed: false
description: "Community-contributed extensions (discovery only)"
``` ```
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when present. A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when it contains one or more catalog entries. An empty `catalogs: []` list falls back to built-in defaults.
## Organization Catalog Customization ## Organization Catalog Customization
@@ -569,7 +572,7 @@ Add to `.specify/extension-catalogs.yml` in your project:
```yaml ```yaml
catalogs: catalogs:
- name: "org-approved" - name: "my-org"
url: "https://your-org.com/spec-kit/catalog.json" url: "https://your-org.com/spec-kit/catalog.json"
priority: 1 priority: 1
install_allowed: true install_allowed: true
@@ -579,7 +582,7 @@ Or use the CLI:
```bash ```bash
specify extension catalog add \ specify extension catalog add \
--name "org-approved" \ --name "my-org" \
--install-allowed \ --install-allowed \
https://your-org.com/spec-kit/catalog.json https://your-org.com/spec-kit/catalog.json
``` ```
@@ -595,7 +598,7 @@ export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json"
```bash ```bash
# List active catalogs # List active catalogs
specify extension catalogs specify extension catalog list
# Search should now show your catalog's extensions # Search should now show your catalog's extensions
specify extension search specify extension search

View File

@@ -961,7 +961,7 @@ specify extension info jira
### Custom Catalogs ### Custom Catalogs
Spec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to benefit from org-approved extensions, an internal catalog, and community discovery all at once. Spec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to maintain their own org-approved extensions alongside an internal catalog and community discovery, all at once.
#### Catalog Stack Resolution #### Catalog Stack Resolution
@@ -978,38 +978,41 @@ When no config file exists, the CLI uses:
| Priority | Catalog | install_allowed | Purpose | | Priority | Catalog | install_allowed | Purpose |
|----------|---------|-----------------|---------| |----------|---------|-----------------|---------|
| 1 | `catalog.json` (org-approved) | `true` | Extensions your org approves for installation | | 1 | `catalog.json` (default) | `true` | Curated extensions available for installation |
| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install | | 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install |
This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to org-approved entries. This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to entries from catalogs with `install_allowed: true`.
#### `.specify/extension-catalogs.yml` Config File #### `.specify/extension-catalogs.yml` Config File
```yaml ```yaml
catalogs: catalogs:
- name: "org-approved" - name: "default"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json" url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1 # Highest — only approved entries can be installed priority: 1 # Highest — only approved entries can be installed
install_allowed: true install_allowed: true
description: "Built-in catalog of installable extensions"
- name: "internal" - name: "internal"
url: "https://internal.company.com/spec-kit/catalog.json" url: "https://internal.company.com/spec-kit/catalog.json"
priority: 2 priority: 2
install_allowed: true install_allowed: true
description: "Internal company extensions"
- name: "community" - name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json" url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 3 # Lowest — discovery only, not installable priority: 3 # Lowest — discovery only, not installable
install_allowed: false install_allowed: false
description: "Community-contributed extensions (discovery only)"
``` ```
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present, it takes full control and the built-in defaults are not applied. A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present with one or more catalog entries, it takes full control and the built-in defaults are not applied. An empty `catalogs: []` list is treated the same as no config file, falling back to defaults.
#### Catalog CLI Commands #### Catalog CLI Commands
```bash ```bash
# List active catalogs with name, URL, priority, and install_allowed # List active catalogs with name, URL, priority, and install_allowed
specify extension catalogs specify extension catalog list
# Add a catalog (project-scoped) # Add a catalog (project-scoped)
specify extension catalog add --name "internal" --install-allowed \ specify extension catalog add --name "internal" --install-allowed \
@@ -1024,7 +1027,7 @@ specify extension catalog remove internal
# Show which catalog an extension came from # Show which catalog an extension came from
specify extension info jira specify extension info jira
# → Source catalog: org-approved # → Source catalog: default
``` ```
#### Merge Conflict Resolution #### Merge Conflict Resolution

View File

@@ -1844,8 +1844,8 @@ def extension_list(
console.print(" [cyan]specify extension add <name>[/cyan]") console.print(" [cyan]specify extension add <name>[/cyan]")
@extension_app.command("catalogs") @catalog_app.command("list")
def extension_catalogs(): def catalog_list():
"""List all active extension catalogs.""" """List all active extension catalogs."""
from .extensions import ExtensionCatalog, ValidationError from .extensions import ExtensionCatalog, ValidationError
@@ -1873,15 +1873,20 @@ def extension_catalogs():
else "[yellow]discovery only[/yellow]" else "[yellow]discovery only[/yellow]"
) )
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
if entry.description:
console.print(f" {entry.description}")
console.print(f" URL: {entry.url}") console.print(f" URL: {entry.url}")
console.print(f" Install: {install_str}") console.print(f" Install: {install_str}")
console.print() console.print()
config_path = project_root / ".specify" / "extension-catalogs.yml" config_path = project_root / ".specify" / "extension-catalogs.yml"
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
if config_path.exists() and catalog._load_catalog_config(config_path) is not None: if config_path.exists() and catalog._load_catalog_config(config_path) is not None:
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
elif os.environ.get("SPECKIT_CATALOG_URL"): elif os.environ.get("SPECKIT_CATALOG_URL"):
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
elif user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None:
console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]")
else: else:
console.print("[dim]Using built-in default catalog stack.[/dim]") console.print("[dim]Using built-in default catalog stack.[/dim]")
console.print( console.print(
@@ -1898,6 +1903,7 @@ def catalog_add(
False, "--install-allowed/--no-install-allowed", False, "--install-allowed/--no-install-allowed",
help="Allow extensions from this catalog to be installed", help="Allow extensions from this catalog to be installed",
), ),
description: str = typer.Option("", "--description", help="Description of the catalog"),
): ):
"""Add a catalog to .specify/extension-catalogs.yml.""" """Add a catalog to .specify/extension-catalogs.yml."""
from .extensions import ExtensionCatalog, ValidationError from .extensions import ExtensionCatalog, ValidationError
@@ -1924,16 +1930,20 @@ def catalog_add(
if config_path.exists(): if config_path.exists():
try: try:
config = yaml.safe_load(config_path.read_text()) or {} config = yaml.safe_load(config_path.read_text()) or {}
except Exception: except Exception as e:
config = {} console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}")
raise typer.Exit(1)
else: else:
config = {} config = {}
catalogs = config.get("catalogs", []) catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
# Check for duplicate name # Check for duplicate name
for existing in catalogs: for existing in catalogs:
if existing.get("name") == name: if isinstance(existing, dict) and existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.") console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
console.print("Use 'specify extension catalog remove' first, or choose a different name.") console.print("Use 'specify extension catalog remove' first, or choose a different name.")
raise typer.Exit(1) raise typer.Exit(1)
@@ -1943,6 +1953,7 @@ def catalog_add(
"url": url, "url": url,
"priority": priority, "priority": priority,
"install_allowed": install_allowed, "install_allowed": install_allowed,
"description": description,
}) })
config["catalogs"] = catalogs config["catalogs"] = catalogs
@@ -1980,8 +1991,11 @@ def catalog_remove(
raise typer.Exit(1) raise typer.Exit(1)
catalogs = config.get("catalogs", []) catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
original_count = len(catalogs) original_count = len(catalogs)
catalogs = [c for c in catalogs if c.get("name") != name] catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
if len(catalogs) == original_count: if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{name}' not found.") console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
@@ -2268,8 +2282,8 @@ def extension_search(
else: else:
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.") console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
console.print( console.print(
" Add to an approved catalog with install_allowed: true, " f" Add to an approved catalog with install_allowed: true, "
"or use: specify extension add --from <url>" f"or install from a ZIP URL: specify extension add {ext['id']} --from <zip-url>"
) )
console.print() console.print()

View File

@@ -45,6 +45,7 @@ class CatalogEntry:
name: str name: str
priority: int priority: int
install_allowed: bool install_allowed: bool
description: str = ""
class ExtensionManifest: class ExtensionManifest:
@@ -1032,30 +1033,57 @@ class ExtensionCatalog:
Returns: Returns:
Ordered list of CatalogEntry objects, or None if file doesn't exist Ordered list of CatalogEntry objects, or None if file doesn't exist
or contains no valid catalog entries. or contains no valid catalog entries.
Raises:
ValidationError: If any catalog entry has an invalid URL,
the file cannot be parsed, or a priority value is invalid.
""" """
if not config_path.exists(): if not config_path.exists():
return None return None
try: try:
data = yaml.safe_load(config_path.read_text()) or {} data = yaml.safe_load(config_path.read_text()) or {}
except (yaml.YAMLError, OSError) as e:
raise ValidationError(
f"Failed to read catalog config {config_path}: {e}"
)
catalogs_data = data.get("catalogs", []) catalogs_data = data.get("catalogs", [])
if not catalogs_data: if not catalogs_data:
return None return None
if not isinstance(catalogs_data, list):
raise ValidationError(
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
)
entries: List[CatalogEntry] = [] entries: List[CatalogEntry] = []
for idx, item in enumerate(catalogs_data): for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise ValidationError(
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip() url = str(item.get("url", "")).strip()
if not url: if not url:
continue continue
self._validate_catalog_url(url) self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise ValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
else:
install_allowed = bool(raw_install)
entries.append(CatalogEntry( entries.append(CatalogEntry(
url=url, url=url,
name=str(item.get("name", f"catalog-{idx + 1}")), name=str(item.get("name", f"catalog-{idx + 1}")),
priority=int(item.get("priority", idx + 1)), priority=priority,
install_allowed=bool(item.get("install_allowed", True)), install_allowed=install_allowed,
description=str(item.get("description", "")),
)) ))
entries.sort(key=lambda e: e.priority) entries.sort(key=lambda e: e.priority)
return entries if entries else None return entries if entries else None
except (yaml.YAMLError, OSError):
return None
def get_active_catalogs(self) -> List[CatalogEntry]: def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs. """Get the ordered list of active catalogs.
@@ -1064,7 +1092,7 @@ class ExtensionCatalog:
1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults 1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults
2. Project-level .specify/extension-catalogs.yml 2. Project-level .specify/extension-catalogs.yml
3. User-level ~/.specify/extension-catalogs.yml 3. User-level ~/.specify/extension-catalogs.yml
4. Built-in default stack (org-approved + community) 4. Built-in default stack (default + community)
Returns: Returns:
List of CatalogEntry objects sorted by priority (ascending) List of CatalogEntry objects sorted by priority (ascending)
@@ -1086,7 +1114,7 @@ class ExtensionCatalog:
file=sys.stderr, file=sys.stderr,
) )
self._non_default_catalog_warning_shown = True self._non_default_catalog_warning_shown = True
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True)] return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
# 2. Project-level config overrides all defaults # 2. Project-level config overrides all defaults
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml" project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
@@ -1102,8 +1130,8 @@ class ExtensionCatalog:
# 4. Built-in default stack # 4. Built-in default stack
return [ return [
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="org-approved", priority=1, install_allowed=True), CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False), CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
] ]
def get_catalog_url(self) -> str: def get_catalog_url(self) -> str:
@@ -1155,9 +1183,11 @@ class ExtensionCatalog:
try: try:
metadata = json.loads(cache_meta_file.read_text()) metadata = json.loads(cache_meta_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - cached_at).total_seconds() age = (datetime.now(timezone.utc) - cached_at).total_seconds()
is_valid = age < self.CACHE_DURATION is_valid = age < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError): except (json.JSONDecodeError, ValueError, KeyError, TypeError):
# If metadata is invalid or missing expected fields, treat cache as invalid # If metadata is invalid or missing expected fields, treat cache as invalid
pass pass
@@ -1231,8 +1261,8 @@ class ExtensionCatalog:
for ext_id, ext_data in catalog_data.get("extensions", {}).items(): for ext_id, ext_data in catalog_data.get("extensions", {}).items():
if ext_id not in merged: # Higher-priority catalog wins if ext_id not in merged: # Higher-priority catalog wins
merged[ext_id] = { merged[ext_id] = {
"id": ext_id,
**ext_data, **ext_data,
"id": ext_id,
"_catalog_name": catalog_entry.name, "_catalog_name": catalog_entry.name,
"_install_allowed": catalog_entry.install_allowed, "_install_allowed": catalog_entry.install_allowed,
} }
@@ -1254,9 +1284,11 @@ class ExtensionCatalog:
try: try:
metadata = json.loads(self.cache_metadata_file.read_text()) metadata = json.loads(self.cache_metadata_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds() age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
return age_seconds < self.CACHE_DURATION return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError): except (json.JSONDecodeError, ValueError, KeyError, TypeError):
return False return False
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:

View File

@@ -950,10 +950,29 @@ class TestExtensionCatalog:
def test_search_by_query(self, temp_dir): def test_search_by_query(self, temp_dir):
"""Test searching by query text.""" """Test searching by query text."""
import yaml as yaml_module
project_dir = temp_dir / "project" project_dir = temp_dir / "project"
project_dir.mkdir() project_dir.mkdir()
(project_dir / ".specify").mkdir() (project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir) catalog = ExtensionCatalog(project_dir)
# Create mock catalog # Create mock catalog
@@ -995,10 +1014,29 @@ class TestExtensionCatalog:
def test_search_by_tag(self, temp_dir): def test_search_by_tag(self, temp_dir):
"""Test searching by tag.""" """Test searching by tag."""
import yaml as yaml_module
project_dir = temp_dir / "project" project_dir = temp_dir / "project"
project_dir.mkdir() project_dir.mkdir()
(project_dir / ".specify").mkdir() (project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir) catalog = ExtensionCatalog(project_dir)
# Create mock catalog # Create mock catalog
@@ -1047,10 +1085,29 @@ class TestExtensionCatalog:
def test_search_verified_only(self, temp_dir): def test_search_verified_only(self, temp_dir):
"""Test searching verified extensions only.""" """Test searching verified extensions only."""
import yaml as yaml_module
project_dir = temp_dir / "project" project_dir = temp_dir / "project"
project_dir.mkdir() project_dir.mkdir()
(project_dir / ".specify").mkdir() (project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir) catalog = ExtensionCatalog(project_dir)
# Create mock catalog # Create mock catalog
@@ -1092,10 +1149,29 @@ class TestExtensionCatalog:
def test_get_extension_info(self, temp_dir): def test_get_extension_info(self, temp_dir):
"""Test getting specific extension info.""" """Test getting specific extension info."""
import yaml as yaml_module
project_dir = temp_dir / "project" project_dir = temp_dir / "project"
project_dir.mkdir() project_dir.mkdir()
(project_dir / ".specify").mkdir() (project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir) catalog = ExtensionCatalog(project_dir)
# Create mock catalog # Create mock catalog
@@ -1214,7 +1290,7 @@ class TestCatalogStack:
# --- get_active_catalogs --- # --- get_active_catalogs ---
def test_default_stack(self, temp_dir): def test_default_stack(self, temp_dir):
"""Default stack includes org-approved and community catalogs.""" """Default stack includes default and community catalogs."""
project_dir = self._make_project(temp_dir) project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir) catalog = ExtensionCatalog(project_dir)
@@ -1222,7 +1298,7 @@ class TestCatalogStack:
assert len(entries) == 2 assert len(entries) == 2
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
assert entries[0].name == "org-approved" assert entries[0].name == "default"
assert entries[0].priority == 1 assert entries[0].priority == 1
assert entries[0].install_allowed is True assert entries[0].install_allowed is True
assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL
@@ -1372,8 +1448,6 @@ class TestCatalogStack:
def test_merge_conflict_higher_priority_wins(self, temp_dir): def test_merge_conflict_higher_priority_wins(self, temp_dir):
"""When same extension id is in two catalogs, higher priority wins.""" """When same extension id is in two catalogs, higher priority wins."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir) project_dir = self._make_project(temp_dir)
# Write project config with two catalogs # Write project config with two catalogs