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 @@ -152,6 +152,8 @@ specify bundle catalog add <url>

Registers a project-scoped catalog source and persists it.

Adding a source is idempotent (identity is the source **id or url**): re-running `catalog add` with the same id/url and identical `--policy`/`--priority` is a successful no-op (exit code 0), so it is safe to include in a re-runnable workflow. Re-adding a matching id/url with *different* settings is rejected as a conflict rather than silently overwriting the existing source — remove it first to change it.

### 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 @@ -140,6 +140,8 @@ specify extension catalog add <url>

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

Adding a catalog is idempotent (identity is the catalog **name**): re-running `catalog add` with the same name and identical settings is a successful no-op (exit code 0), so it is safe to include in a re-runnable workflow. Re-adding the same name with *different* settings is rejected as a conflict rather than silently overwriting the existing entry — remove it first to change it.

### 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 @@ -213,6 +213,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).

Adding a catalog is idempotent (identity is the catalog **URL**): re-running `catalog add` with the same URL and the same (or no) `--name` is a successful no-op (exit code 0), so it is safe to include in a re-runnable workflow. Re-adding the same URL with a *different* `--name` is rejected as a conflict rather than silently overwriting the existing entry — remove it first to change it.

### 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 @@ -109,6 +109,8 @@ specify preset catalog add <url>

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

Adding a catalog is idempotent (identity is the catalog **name**): re-running `catalog add` with the same name and identical settings is a successful no-op (exit code 0), so it is safe to include in a re-runnable workflow. Re-adding the same name with *different* settings is rejected as a conflict rather than silently overwriting the existing entry — remove it first to change it.

### 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`.

Adding a catalog is idempotent (identity is the catalog **URL**): re-running `catalog add` with the same URL and the same (or no) `--name` is a successful no-op (exit code 0), so it is safe to include in a re-runnable workflow. Re-adding the same URL with a *different* `--name` is rejected as a conflict rather than silently overwriting the existing entry — remove it first to change it.

### Remove a Catalog

```bash
Expand Down
31 changes: 21 additions & 10 deletions src/specify_cli/bundler/commands_impl/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 @@ -186,21 +186,32 @@ def add_source(
resolved_id = (source_id or _derive_id(url)).strip()

catalogs = _read(project_root)
for existing in catalogs:
if existing.get("id") == resolved_id or existing.get("url") == url:
raise BundlerError(
f"Catalog source '{resolved_id}' (or url) already exists in this project."
)

entry = {
desired = {
"id": resolved_id,
"url": url,
"priority": int(priority),
"install_policy": install_policy.value,
}
catalogs.append(entry)
for existing in catalogs:
if existing.get("id") == resolved_id or existing.get("url") == url:
# Idempotent add (#4505): identity is the source id or url. A rerun
# requesting the same settings is a successful no-op; differing
# settings are a conflict rather than a silent overwrite.
if (
existing.get("id") == resolved_id
and existing.get("url") == url
and int(existing.get("priority", 0)) == desired["priority"]
and str(existing.get("install_policy", "")) == desired["install_policy"]
):
return CatalogSource.from_dict(dict(existing), Scope.PROJECT), "unchanged"
Comment on lines +200 to +206
raise BundlerError(
f"Catalog source '{resolved_id}' (or url) already exists in this "
"project with different settings. Remove it first to change it."
)

catalogs.append(desired)
_write(project_root, catalogs)
return CatalogSource.from_dict(entry, Scope.PROJECT)
return CatalogSource.from_dict(desired, Scope.PROJECT), "added"


def remove_source(project_root: Path, id_or_url: str) -> str:
Expand Down
16 changes: 11 additions & 5 deletions src/specify_cli/commands/bundle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,15 +662,21 @@ def catalog_add(
project_root = require_project_root()
from ...bundler.commands_impl.catalog_config import add_source

source = add_source(project_root, url, policy=policy, priority=priority, source_id=source_id)
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})."
)
if status == "unchanged":
console.print(
f"[green]✓[/green] Catalog '{_escape_markup(str(source.id))}' is already "
f"configured (priority {source.priority}, {source.install_policy.value})."
)
else:
console.print(
f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' "
f"(priority {source.priority}, {source.install_policy.value})."
)


@bundle_catalog_app.command("remove")
Expand Down
22 changes: 20 additions & 2 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,10 +613,28 @@ def catalog_add(
safe_name = _escape_markup(name)
safe_url = _escape_markup(url)

# Check for duplicate name
# Idempotent add (#4505): a rerun that requests an identical entry is a
# successful no-op so the same `catalog add` can live in a re-runnable
# workflow without failing. A same-name entry whose settings differ is
# still a conflict — we refuse to silently change priority/install
# permissions and ask the user to remove it first.
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists.")
if (
str(existing.get("url", "")) == url
and existing.get("priority") == priority
and bool(existing.get("install_allowed", False)) == install_allowed
and str(existing.get("description", "")) == description
Comment on lines +624 to +627
):
console.print(
f"[green]✓[/green] Catalog '[bold]{safe_name}[/bold]' is already "
"configured with these settings; nothing to do."
)
return
console.print(
f"[red]Error:[/red] A catalog named '{safe_name}' already exists with "
"different settings."
)
console.print("Use 'specify extension catalog remove' first, or choose a different name.")
raise typer.Exit(1)

Expand Down
9 changes: 7 additions & 2 deletions src/specify_cli/integrations/_query_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,14 +543,19 @@ 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}")
if status == "unchanged":
console.print(
f"[green]✓[/green] Catalog source already configured: {normalized_url}"
)
else:
console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")


@integration_catalog_app.command("remove")
Expand Down
26 changes: 20 additions & 6 deletions src/specify_cli/integrations/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,14 +390,20 @@ 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. Identity for an integration catalog is the (normalized) URL.
Adding a URL that is already configured is idempotent (#4505): a rerun
that requests the same name (or no explicit name) is a successful
no-op, while a rerun that requests a *different* name is rejected as a
conflict rather than silently overwriting the stored entry. Priority is
derived as ``max(existing) + 1`` so a newly added entry sorts last in
the resolution order unless the user edits the file manually.

Returns ``"added"`` when a new entry is written, or ``"unchanged"``
when an equivalent entry already existed.
"""
url = url.strip()
if not url:
Expand Down Expand Up @@ -432,6 +438,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.
requested_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 @@ -452,8 +459,14 @@ 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:
# Idempotent add (#4505): same URL already configured.
existing_name = str(cat.get("name", "")).strip()
if not requested_name or requested_name == existing_name:
return "unchanged"
Comment on lines +463 to +465
raise IntegrationValidationError(
f"Catalog URL already configured: {url}"
f"Catalog URL already configured with a different name "
f"('{existing_name}'): {url}. Remove it first or pass "
f"--name '{existing_name}'."
)
valid_catalog_count += 1
if "priority" in cat:
Expand Down Expand Up @@ -502,6 +515,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
22 changes: 20 additions & 2 deletions src/specify_cli/presets/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,10 +902,28 @@ def preset_catalog_add(
safe_name = _escape_markup(str(name))
safe_url = _escape_markup(str(url))

# Check for duplicate name
# Idempotent add (#4505): a rerun that requests an identical entry is a
# successful no-op so the same `catalog add` can live in a re-runnable
# workflow without failing. A same-name entry whose settings differ is
# still a conflict — we refuse to silently change priority/install
# permissions and ask the user to remove it first.
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists.")
if (
str(existing.get("url", "")) == url
and existing.get("priority") == priority
and bool(existing.get("install_allowed", False)) == install_allowed
and str(existing.get("description", "")) == description
Comment on lines +913 to +916
):
console.print(
f"[green]✓[/green] Catalog '[bold]{safe_name}[/bold]' is already "
"configured with these settings; nothing to do."
)
return
console.print(
f"[red]Error:[/red] A catalog named '{safe_name}' already exists with "
"different settings."
)
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
raise typer.Exit(1)

Expand Down
14 changes: 10 additions & 4 deletions src/specify_cli/workflows/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3023,12 +3023,15 @@ def workflow_catalog_add(
project_root = _require_specify_project()
catalog = WorkflowCatalog(project_root)
try:
catalog.add_catalog(url, name)
status = catalog.add_catalog(url, name)
except WorkflowValidationError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)

console.print(f"[green]✓[/green] Catalog source added: {url}")
if status == "unchanged":
console.print(f"[green]✓[/green] Catalog source already configured: {url}")
else:
console.print(f"[green]✓[/green] Catalog source added: {url}")


@workflow_catalog_app.command("remove")
Expand Down Expand Up @@ -3750,12 +3753,15 @@ def workflow_step_catalog_add(

catalog = StepCatalog(project_root)
try:
catalog.add_catalog(url, name)
status = catalog.add_catalog(url, name)
except StepValidationError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)

console.print(f"[green]✓[/green] Step catalog source added: {url}")
if status == "unchanged":
console.print(f"[green]✓[/green] Step catalog source already configured: {url}")
else:
console.print(f"[green]✓[/green] Step catalog source added: {url}")


@workflow_step_catalog_app.command("remove")
Expand Down
45 changes: 38 additions & 7 deletions src/specify_cli/workflows/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,8 +705,15 @@ def get_catalog_configs(self) -> list[dict[str, Any]]:
for e in entries
]

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

Identity is the URL: adding a URL that is already configured is
idempotent (#4505). A rerun requesting the same name (or no explicit
name) is a no-op that returns ``"unchanged"``; a rerun requesting a
different name is rejected as a conflict. Returns ``"added"`` when a
new entry is written.
"""
self._validate_catalog_url(url)
config_path = self.project_root / ".specify" / "workflow-catalogs.yml"

Expand All @@ -731,11 +738,18 @@ def add_catalog(self, url: str, name: str | None = None) -> None:
raise WorkflowValidationError(
"Catalog config 'catalogs' must be a list."
)
# Check for duplicate URL (guard against non-dict entries)
# Idempotent add (#4505): identity is the URL. A rerun requesting the
# same name (or no explicit name) is a no-op; a different name conflicts.
requested_name = str(name).strip() if name is not None else ""
for cat in catalogs:
if isinstance(cat, dict) and cat.get("url") == url:
existing_name = str(cat.get("name", "")).strip()
if not requested_name or requested_name == existing_name:
return "unchanged"
Comment on lines +746 to +748
raise WorkflowValidationError(
f"Catalog URL already configured: {url}"
f"Catalog URL already configured with a different name "
f"('{existing_name}'): {url}. Remove it first or pass "
f"--name '{existing_name}'."
)

# Derive priority from the highest existing priority + 1.
Expand Down Expand Up @@ -776,6 +790,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 Expand Up @@ -1388,8 +1403,15 @@ def get_catalog_configs(self) -> list[dict[str, Any]]:
for e in entries
]

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

Identity is the URL: adding a URL that is already configured is
idempotent (#4505). A rerun requesting the same name (or no explicit
name) is a no-op that returns ``"unchanged"``; a rerun requesting a
different name is rejected as a conflict. Returns ``"added"`` when a
new entry is written.
"""
self._validate_catalog_url(url)
config_path = self.project_root / ".specify" / "step-catalogs.yml"

Expand All @@ -1414,10 +1436,18 @@ def add_catalog(self, url: str, name: str | None = None) -> None:
raise StepValidationError(
"Catalog config 'catalogs' must be a list."
)
# Idempotent add (#4505): identity is the URL. A rerun requesting the
# same name (or no explicit name) is a no-op; a different name conflicts.
requested_name = str(name).strip() if name is not None else ""
for cat in catalogs:
if isinstance(cat, dict) and cat.get("url") == url:
existing_name = str(cat.get("name", "")).strip()
if not requested_name or requested_name == existing_name:
return "unchanged"
raise StepValidationError(
f"Catalog URL already configured: {url}"
f"Catalog URL already configured with a different name "
f"('{existing_name}'): {url}. Remove it first or pass "
f"--name '{existing_name}'."
)

# Coerce existing priorities to int with a safe fallback so a user-edited
Expand Down Expand Up @@ -1459,6 +1489,7 @@ def _coerce_priority(value: Any) -> int:
raise StepValidationError(
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
Loading
Loading