mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 02:43:08 +00:00
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:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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**:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user