Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/reference/bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ specify bundle catalog add <url>

Registers a project-scoped catalog source and persists it.

Re-adding the same source with the same ID, URL, policy, and priority succeeds without changing the configuration; different settings are rejected.

### Remove a Catalog Source

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ specify extension catalog add <url>

Adds a catalog to the project's `.specify/extension-catalogs.yml`.

Re-adding the same named catalog with identical settings succeeds without changing the configuration; different settings are rejected.

### Remove a Catalog

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ specify integration catalog add <url>

Adds a custom catalog URL to the project's `.specify/integration-catalogs.yml`. The URL must use HTTPS (except `http://localhost`, `http://127.0.0.1`, or `http://[::1]` for local testing).

Re-adding the same URL with the same name succeeds without changing the configuration; a different name is rejected.

### Remove a Catalog

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ specify preset catalog add <url>

Adds a catalog to the project's `.specify/preset-catalogs.yml`.

Re-adding the same named catalog with identical settings succeeds without changing the configuration; different settings are rejected.

### Remove a Catalog

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@ specify workflow catalog add <url>

Adds a custom catalog URL to the project's `.specify/workflow-catalogs.yml`.

Re-adding the same workflow or step catalog URL with the same name succeeds without changing the configuration; a different name is rejected.

### Remove a Catalog

```bash
Expand Down
17 changes: 12 additions & 5 deletions src/specify_cli/bundles/catalog/command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,21 @@ def catalog_add(
project_root = require_project_root()
from ..catalog_config import add_source

source = add_source(
source, status = add_source(
project_root, url, policy=policy, priority=priority, source_id=source_id
)
except BundlerError as exc:
_fail(str(exc))
return

console.print(
f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' "
f"(priority {source.priority}, {source.install_policy.value})."
)
safe_id = _escape_markup(str(source.id))
if status == "unchanged":
console.print(
f"[green]✓[/green] Catalog '{safe_id}' already configured "
f"(priority {source.priority}, {source.install_policy.value})."
)
else:
console.print(
f"[green]✓[/green] Added catalog '{safe_id}' "
f"(priority {source.priority}, {source.install_policy.value})."
)
36 changes: 26 additions & 10 deletions src/specify_cli/bundles/catalog_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def add_source(
policy: str,
priority: int,
source_id: str | None = None,
) -> CatalogSource:
) -> tuple[CatalogSource, str]:
url = url.strip()
if not url:
raise BundlerError("A catalog url is required.")
Expand Down Expand Up @@ -183,24 +183,40 @@ def add_source(

url = _canonicalize_url(url)
install_policy = InstallPolicy.parse(policy)
resolved_id = (source_id or _derive_id(url)).strip()
requested_id = source_id.strip() if source_id is not None else ""
resolved_id = requested_id or _derive_id(url)

catalogs = _read(project_root)
requested_source = CatalogSource.from_dict(
{
"id": resolved_id,
"url": url,
"priority": priority,
"install_policy": install_policy.value,
},
Scope.PROJECT,
)
for existing in catalogs:
if existing.get("id") == resolved_id or existing.get("url") == url:
existing_source = CatalogSource.from_dict(existing, Scope.PROJECT)
if (
existing_source.id == requested_source.id
or existing_source.url == requested_source.url
):
Comment on lines +201 to +204
if (
existing_source.url == requested_source.url
and (not requested_id or existing_source.id == requested_source.id)
and existing_source.priority == requested_source.priority
and existing_source.install_policy is requested_source.install_policy
):
return existing_source, "unchanged"
raise BundlerError(
f"Catalog source '{resolved_id}' (or url) already exists in this project."
)

entry = {
"id": resolved_id,
"url": url,
"priority": int(priority),
"install_policy": install_policy.value,
}
entry = requested_source.to_dict()
catalogs.append(entry)
_write(project_root, catalogs)
return CatalogSource.from_dict(entry, Scope.PROJECT)
return requested_source, "added"


def remove_source(project_root: Path, id_or_url: str) -> str:
Expand Down
26 changes: 19 additions & 7 deletions src/specify_cli/extensions/catalog/command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,27 @@ def catalog_add(
safe_name = _escape_markup(name)
safe_url = _escape_markup(url)

entry = {
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
}

# Check for duplicate name
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
if all(
existing.get(field) == entry[field]
for field in (
"url",
"priority",
"install_allowed",
"description",
)
):
return
_commands.console.print(
f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists."
)
Expand All @@ -71,13 +89,7 @@ def catalog_add(
)
raise typer.Exit(1)

catalogs.append({
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
})
catalogs.append(entry)

config["catalogs"] = catalogs
config_path.write_text(
Expand Down
19 changes: 12 additions & 7 deletions src/specify_cli/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,14 +515,14 @@ def get_project_catalog_configs(self) -> Optional[List[Dict[str, Any]]]:
for e in entries
]

def add_catalog(self, url: str, name: Optional[str] = None) -> None:
def add_catalog(self, url: str, name: Optional[str] = None) -> str:
"""Add a catalog source to the project-level config file.

The URL is normalized (whitespace stripped) and validated before being
written. Duplicate URLs are rejected, including near-duplicates that
differ only by surrounding whitespace. Priority is derived as
``max(existing) + 1`` so the new entry sorts last in the resolution
order unless the user edits the file manually.
written. An existing URL is unchanged when no name is supplied or the
supplied name matches; a different explicit name is rejected. Priority
is derived as ``max(existing) + 1`` so the new entry sorts last in the
resolution order unless the user edits the file manually.
"""
url = url.strip()
if not url:
Expand Down Expand Up @@ -557,6 +557,7 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None:
# Validate each existing entry before mutating anything. Fail fast so
# we don't silently preserve a corrupt sibling entry or derive a new
# priority from a bogus value.
normalized_name = str(name).strip() if name is not None else ""
existing_priorities: List[int] = []
valid_catalog_count = 0
for idx, cat in enumerate(catalogs):
Expand All @@ -577,6 +578,11 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None:
f"Invalid catalog entry at index {idx} in {config_path}: {exc}"
) from exc
if existing_url == url:
generated_name = f"catalog-{valid_catalog_count + 1}"
existing_name = str(cat.get("name") or generated_name).strip()
if not normalized_name or existing_name == normalized_name:
self._load_catalog_config(config_path)
return "unchanged"
raise IntegrationValidationError(
f"Catalog URL already configured: {url}"
)
Expand All @@ -603,9 +609,7 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None:
# Match `_load_catalog_config()`'s defaulting rule so the new
# entry still sorts after implicit-priority siblings.
existing_priorities.append(idx + 1)

max_priority = max(existing_priorities, default=0)
normalized_name = str(name).strip() if name is not None else ""
generated_name = f"catalog-{valid_catalog_count + 1}"
catalogs.append(
{
Expand All @@ -627,6 +631,7 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None:
sort_keys=False,
allow_unicode=True,
)
return "added"

def remove_catalog(self, index: int) -> str:
"""Remove a catalog source by 0-based index.
Expand Down
11 changes: 9 additions & 2 deletions src/specify_cli/integrations/catalog/command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Optional

import typer
from rich.markup import escape as _rich_escape

from ..._console import console
from . import catalog_app
Expand Down Expand Up @@ -32,11 +33,17 @@ def integration_catalog_add(
normalized_url = url.strip()

try:
catalog.add_catalog(normalized_url, name)
status = catalog.add_catalog(normalized_url, name)
except IntegrationCatalogError as exc:
# Covers both URL validation (base class) and config-file validation
# (IntegrationValidationError subclass).
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)

console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")
safe_url = _rich_escape(normalized_url)
if status == "unchanged":
console.print(
f"[green]✓[/green] Catalog source already configured: {safe_url}"
)
else:
console.print(f"[green]✓[/green] Catalog source added: {safe_url}")
28 changes: 19 additions & 9 deletions src/specify_cli/presets/catalog/command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,27 @@ def preset_catalog_add(
safe_name = _escape_markup(str(name))
safe_url = _escape_markup(str(url))

entry = {
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
}

# Check for duplicate name
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
if all(
existing.get(field) == entry[field]
for field in (
"url",
"priority",
"install_allowed",
"description",
)
):
return
console.print(
f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists."
)
Expand All @@ -86,15 +104,7 @@ def preset_catalog_add(
)
raise typer.Exit(1)

catalogs.append(
{
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
}
)
catalogs.append(entry)

config["catalogs"] = catalogs
config_path.write_text(
Expand Down
19 changes: 15 additions & 4 deletions src/specify_cli/workflows/catalog/_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -714,10 +714,12 @@ def get_catalog_configs(self) -> list[dict[str, Any]]:
for e in entries
]

def add_catalog(self, url: str, name: str | None = None) -> None:
def add_catalog(self, url: str, name: str | None = None) -> str:
"""Add a catalog source to the project-level config."""
url = url.strip()
self._validate_catalog_url(url)
config_path = self.project_root / ".specify" / "workflow-catalogs.yml"
normalized_name = str(name).strip() if name is not None else ""

data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
Expand All @@ -741,8 +743,16 @@ def add_catalog(self, url: str, name: str | None = None) -> None:
"Catalog config 'catalogs' must be a list."
)
# Check for duplicate URL (guard against non-dict entries)
for cat in catalogs:
if isinstance(cat, dict) and cat.get("url") == url:
for idx, cat in enumerate(catalogs):
if (
isinstance(cat, dict)
and str(cat.get("url", "")).strip() == url
):
generated_name = f"catalog-{idx + 1}"
existing_name = str(cat.get("name") or generated_name).strip()
if not normalized_name or existing_name == normalized_name:
self._load_catalog_config(config_path)
return "unchanged"
raise WorkflowValidationError(
f"Catalog URL already configured: {url}"
)
Expand All @@ -768,7 +778,7 @@ def _coerce_priority(value: Any) -> int:
)
catalogs.append(
{
"name": name or f"catalog-{len(catalogs) + 1}",
"name": normalized_name or f"catalog-{len(catalogs) + 1}",
"url": url,
"priority": max_priority + 1,
"install_allowed": True,
Expand All @@ -785,6 +795,7 @@ def _coerce_priority(value: Any) -> int:
raise WorkflowValidationError(
f"Failed to write catalog config {config_path}: {exc}"
) from exc
return "added"

def remove_catalog(self, index: int) -> str:
"""Remove a catalog source by index (0-based). Returns the removed name."""
Expand Down
10 changes: 8 additions & 2 deletions src/specify_cli/workflows/catalog/command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@ def workflow_catalog_add(
project_root = cli._require_specify_project()
catalog = WorkflowCatalog(project_root)
try:
catalog.add_catalog(url, name)
status = catalog.add_catalog(url, name)
except WorkflowValidationError as exc:
cli.console.print(f"[red]Error:[/red] {exc}")
raise cli.typer.Exit(1)

cli.console.print(f"[green]✓[/green] Catalog source added: {url}")
safe_url = cli._escape_markup(url.strip())
if status == "unchanged":
cli.console.print(
f"[green]✓[/green] Catalog source already configured: {safe_url}"
Comment on lines +26 to +28
)
else:
cli.console.print(f"[green]✓[/green] Catalog source added: {safe_url}")
Loading
Loading