From 6c7277d361c2653c9428d313a9ea4d815eda65f4 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 20 Aug 2026 16:03:06 +0800 Subject: [PATCH 1/3] feat(cli): add JSON output for installed lists --- docs/reference/extensions.md | 11 ++ docs/reference/presets.md | 8 + src/specify_cli/_installed_list_json.py | 47 +++++ src/specify_cli/_project.py | 59 ++++-- src/specify_cli/extensions/__init__.py | 30 ++++ src/specify_cli/extensions/_commands.py | 13 ++ src/specify_cli/presets/__init__.py | 26 +++ src/specify_cli/presets/_commands.py | 20 ++- tests/test_installed_list_json.py | 228 ++++++++++++++++++++++++ 9 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 src/specify_cli/_installed_list_json.py create mode 100644 tests/test_installed_list_json.py diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 8de2c18c86..70bd14ac28 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -50,15 +50,26 @@ Removes an installed extension. Configuration files are backed up by default; us ```bash specify extension list +specify extension list --json ``` | Option | Description | | ------------- | -------------------------------------------------- | | `--available` | Show available (uninstalled) extensions | | `--all` | Show both installed and available extensions | +| `--json` | Write installed extensions as JSON | Lists installed extensions with their status, version, and command counts. +`--json` writes a JSON array to stdout. Every item has the keys `id`, `name`, +`description`, `version`, `author`, `priority`, `enabled`, `source`, and +`provides`. `author` is `null` when absent; `source` is either +`{"kind":"local"}` or `{"kind":"catalog"}`. Extension `provides` contains +`commands`, `templates`, `scripts`, and `hooks` counts. `--available` and +`--all` do not broaden JSON output beyond installed extensions. For runtime +failures after option parsing, `--json` writes `{"error":"..."}` to stderr and +exits nonzero. + ## Extension Info ```bash diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 1098abfb42..deeb941c30 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -43,10 +43,18 @@ Removes an installed preset and cleans up its registered commands. ```bash specify preset list +specify preset list --json ``` Lists installed presets with their versions, descriptions, template counts, and current status. +`--json` writes a JSON array to stdout. Every item has the keys `id`, `name`, +`description`, `version`, `author`, `priority`, `enabled`, `source`, and +`provides`. `author` is `null` when absent; `source` is either +`{"kind":"local"}` or `{"kind":"catalog"}`. Preset `provides` contains +`commands`, `templates`, and `scripts` counts. For runtime failures after +option parsing, `--json` writes `{"error":"..."}` to stderr and exits nonzero. + Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files. ## Preset Info diff --git a/src/specify_cli/_installed_list_json.py b/src/specify_cli/_installed_list_json.py new file mode 100644 index 0000000000..836a71b67c --- /dev/null +++ b/src/specify_cli/_installed_list_json.py @@ -0,0 +1,47 @@ +"""Private JSON output helpers for installed preset and extension lists. + +This module intentionally serves only the two installed-list commands. Their +human-facing renderers retain the legacy manager records, while this adapter +defines the public machine-readable wire contract. +""" +from __future__ import annotations + +import json +from typing import Any, NoReturn + +import typer + + +def installed_list_item(record: dict[str, Any], *, include_hooks: bool) -> dict[str, Any]: + """Return the canonical public JSON object for one installed record.""" + provides = record["_json_provides"] + if not include_hooks: + provides = { + "commands": provides["commands"], + "templates": provides["templates"], + "scripts": provides["scripts"], + } + + return { + "id": record["id"], + "name": record["name"], + "description": record["description"], + "version": record["version"], + "author": record["_json_author"], + "priority": record["priority"], + "enabled": record["enabled"], + "source": {"kind": record["_json_source_kind"]}, + "provides": provides, + } + + +def emit_json(value: Any) -> None: + """Write one JSON value to stdout without Rich rendering.""" + typer.echo(json.dumps(value, ensure_ascii=False)) + + +def emit_json_error(error: Exception) -> NoReturn: + """Write the list-command error contract and terminate unsuccessfully.""" + message = str(error).strip() or error.__class__.__name__ + typer.echo(json.dumps({"error": message}, ensure_ascii=False), err=True) + raise typer.Exit(code=1) diff --git a/src/specify_cli/_project.py b/src/specify_cli/_project.py index 1a583809b5..9ed2fe1508 100644 --- a/src/specify_cli/_project.py +++ b/src/specify_cli/_project.py @@ -10,6 +10,28 @@ from ._console import err_console +class ProjectResolutionError(RuntimeError): + """A project-root error that callers can render for their own surface.""" + + +def _resolve_init_dir_override_unrendered() -> Path | None: + """Resolve ``SPECIFY_INIT_DIR`` without emitting user-facing output.""" + raw = os.environ.get("SPECIFY_INIT_DIR", "") + if not raw: + return None + init_root = (Path.cwd() / raw).resolve() + if not init_root.is_dir(): + raise ProjectResolutionError( + f"SPECIFY_INIT_DIR does not point to an existing directory: {raw}" + ) + if not (init_root / ".specify").is_dir(): + raise ProjectResolutionError( + "SPECIFY_INIT_DIR is not a Spec Kit project " + f"(no .specify/ directory): {init_root}" + ) + return init_root + + def _resolve_init_dir_override() -> Path | None: """Resolve the ``SPECIFY_INIT_DIR`` project override for the Python CLI. @@ -33,21 +55,24 @@ def _resolve_init_dir_override() -> Path | None: here (a stable project identity), so this is a deliberate, documented variance, not a parity guarantee on the resolved string. """ - raw = os.environ.get("SPECIFY_INIT_DIR", "") - if not raw: - return None - # Relative values resolve against cwd; an absolute value stands alone (Path's - # `/` drops the left operand when the right is absolute). resolve() also - # collapses a trailing slash and canonicalizes symlinks. - init_root = (Path.cwd() / raw).resolve() - if not init_root.is_dir(): - err_console.print( - f"[red]Error:[/red] SPECIFY_INIT_DIR does not point to an existing directory: {raw}" - ) + try: + return _resolve_init_dir_override_unrendered() + except ProjectResolutionError as error: + err_console.print(f"[red]Error:[/red] {error}") raise typer.Exit(1) - if not (init_root / ".specify").is_dir(): - err_console.print( - f"[red]Error:[/red] SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): {init_root}" - ) - raise typer.Exit(1) - return init_root + + +def resolve_specify_project_root() -> Path: + """Return the active project root without rendering errors. + + This is deliberately separate from ``_require_specify_project`` so the + installed-list JSON contract can send structured failures to stderr without + changing the Rich diagnostics used by every other project-scoped command. + """ + override = _resolve_init_dir_override_unrendered() + if override is not None: + return override + project_root = Path.cwd() + if not (project_root / ".specify").is_dir(): + raise ProjectResolutionError("Not a Spec Kit project (no .specify/ directory)") + return project_root diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index fb4a30519d..a853e07147 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3413,6 +3413,18 @@ def list_installed(self) -> List[Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) + source = metadata.get("source") + source_kind = source.get("kind") if isinstance(source, dict) else source + source_kind = ( + source_kind + if isinstance(source_kind, str) and source_kind in {"local", "catalog"} + else "local" + ) + author = manifest.data["extension"].get("author") + json_hook_count = sum( + len(coerce_hook_entries(hook_config)) + for hook_config in manifest.hooks.values() + ) result.append( { "id": ext_id, @@ -3424,10 +3436,25 @@ def list_installed(self) -> List[Dict[str, Any]]: "installed_at": metadata.get("installed_at"), "command_count": len(manifest.commands), "hook_count": len(manifest.hooks), + "_json_author": author if isinstance(author, str) and author else None, + "_json_source_kind": source_kind, + "_json_provides": { + "commands": len(manifest.commands), + "templates": len(manifest.templates), + "scripts": len(manifest.scripts), + "hooks": json_hook_count, + }, } ) except ValidationError: # Corrupted extension + source = metadata.get("source") + source_kind = source.get("kind") if isinstance(source, dict) else source + source_kind = ( + source_kind + if isinstance(source_kind, str) and source_kind in {"local", "catalog"} + else "local" + ) result.append( { "id": ext_id, @@ -3439,6 +3466,9 @@ def list_installed(self) -> List[Dict[str, Any]]: "installed_at": metadata.get("installed_at"), "command_count": 0, "hook_count": 0, + "_json_author": None, + "_json_source_kind": source_kind, + "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, } ) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7f7933e934..11ec17b8fb 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -25,6 +25,8 @@ from rich.table import Table from .._console import console +from .._installed_list_json import emit_json, emit_json_error, installed_list_item +from .._project import resolve_specify_project_root from .._assets import get_speckit_version from .._download_security import ( archive_format_from_name, @@ -419,10 +421,21 @@ def _resolve_catalog_extension( def extension_list( available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), + json_output: bool = typer.Option(False, "--json", help="Output installed extensions as JSON"), ): """List installed extensions.""" from . import ExtensionManager + if json_output: + try: + project_root = resolve_specify_project_root() + manager = ExtensionManager(project_root) + installed = manager.list_installed() + emit_json([installed_list_item(ext, include_hooks=True) for ext in installed]) + return + except Exception as error: + emit_json_error(error) + project_root = _require_specify_project() manager = ExtensionManager(project_root) installed = manager.list_installed() diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 3d37f6fb74..f30d289a73 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4063,6 +4063,19 @@ def list_installed(self) -> List[Dict[str, Any]]: try: manifest = PresetManifest(manifest_path) + provided_counts = {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0} + for template in manifest.templates: + provided_counts[f"{template['type']}s"] += 1 + source = metadata.get("source") + source_kind = ( + source.get("kind") if isinstance(source, dict) else source + ) + source_kind = ( + source_kind + if isinstance(source_kind, str) and source_kind in {"local", "catalog"} + else "local" + ) + author = manifest.author result.append({ "id": pack_id, "name": manifest.name, @@ -4073,8 +4086,18 @@ def list_installed(self) -> List[Dict[str, Any]]: "template_count": len(manifest.templates), "tags": manifest.tags, "priority": normalize_priority(metadata.get("priority")), + "_json_author": author if isinstance(author, str) and author else None, + "_json_source_kind": source_kind, + "_json_provides": provided_counts, }) except PresetValidationError: + source = metadata.get("source") + source_kind = source.get("kind") if isinstance(source, dict) else source + source_kind = ( + source_kind + if isinstance(source_kind, str) and source_kind in {"local", "catalog"} + else "local" + ) result.append({ "id": pack_id, "name": pack_id, @@ -4085,6 +4108,9 @@ def list_installed(self) -> List[Dict[str, Any]]: "template_count": 0, "tags": [], "priority": normalize_priority(metadata.get("priority")), + "_json_author": None, + "_json_source_kind": source_kind, + "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, }) return result diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 48d5c9f14f..87107c3539 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -17,6 +17,8 @@ from rich.markup import escape as _escape_markup from .._console import console +from .._installed_list_json import emit_json, emit_json_error, installed_list_item +from .._project import resolve_specify_project_root from .._download_security import ( archive_format_from_name, archive_suffix, @@ -44,11 +46,27 @@ @preset_app.command("list") -def preset_list(): +def preset_list( + json_output: bool = typer.Option(False, "--json", help="Output installed presets as JSON"), +): """List installed presets.""" from .. import _require_specify_project from . import PresetManager + if json_output: + try: + project_root = resolve_specify_project_root() + manager = PresetManager(project_root) + installed = manager.list_installed() + installed = sorted( + installed, + key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), + ) + emit_json([installed_list_item(pack, include_hooks=False) for pack in installed]) + return + except Exception as error: + emit_json_error(error) + project_root = _require_specify_project() manager = PresetManager(project_root) installed = manager.list_installed() diff --git a/tests/test_installed_list_json.py b/tests/test_installed_list_json.py new file mode 100644 index 0000000000..a6e5748d89 --- /dev/null +++ b/tests/test_installed_list_json.py @@ -0,0 +1,228 @@ +"""Public JSON contracts for installed preset and extension lists.""" + +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ExtensionManager +from specify_cli.presets import PresetManager + + +runner = CliRunner() + + +def _project(tmp_path): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + return project + + +def _preset(project, preset_id, *, author="Preset Author"): + preset_dir = project / ".specify" / "presets" / preset_id + preset_dir.mkdir(parents=True) + author_line = f' author: "{author}"\n' if author is not None else "" + (preset_dir / "preset.yml").write_text( + "schema_version: \"1.0\"\n" + "preset:\n" + f" id: {preset_id}\n" + f" name: {preset_id} name\n" + " version: \"1.0.0\"\n" + " description: preset description\n" + f"{author_line}" + "requires:\n" + " speckit_version: \">=0.1.0\"\n" + "provides:\n" + " templates:\n" + " - type: template\n" + " name: base-template\n" + " file: templates/base.md\n" + " - type: command\n" + " name: speckit.example\n" + " file: commands/example.md\n" + " - type: script\n" + " name: setup-script\n" + " file: scripts/setup.py\n", + encoding="utf-8", + ) + + +def _extension(project, extension_id, *, author=None): + extension_dir = project / ".specify" / "extensions" / extension_id + extension_dir.mkdir(parents=True) + author_line = f' author: "{author}"\n' if author is not None else "" + (extension_dir / "extension.yml").write_text( + "schema_version: \"1.0\"\n" + "extension:\n" + f" id: {extension_id}\n" + f" name: {extension_id} name\n" + " version: \"1.0.0\"\n" + " description: extension description\n" + f"{author_line}" + "requires:\n" + " speckit_version: \">=0.1.0\"\n" + "provides:\n" + " commands:\n" + " - name: speckit.example-ext.example\n" + " file: commands/example.md\n", + encoding="utf-8", + ) + + +def _json_result(result): + assert result.exit_code == 0, result.output + assert result.stderr == "" + return json.loads(result.stdout) + + +def test_preset_list_json_uses_canonical_wire_object_and_keeps_flat_manager_keys(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project, "test-preset") + manager = PresetManager(project) + manager.registry.add("test-preset", {"version": "1.0.0", "source": "catalog", "priority": 3}) + + record = manager.list_installed()[0] + assert record["template_count"] == 3 + assert record["_json_provides"] == {"commands": 1, "templates": 1, "scripts": 1, "hooks": 0} + + monkeypatch.chdir(project) + payload = _json_result(runner.invoke(app, ["preset", "list", "--json"])) + + assert len(payload) == 1 + item = payload[0] + assert set(item) == { + "id", "name", "description", "version", "author", "priority", "enabled", "source", "provides" + } + assert item["author"] == "Preset Author" + assert item["source"] == {"kind": "catalog"} + assert item["provides"] == {"commands": 1, "templates": 1, "scripts": 1} + assert "hooks" not in item["provides"] + + +def test_preset_list_json_defaults_legacy_source_and_author(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project, "legacy-preset", author=None) + PresetManager(project).registry.add("legacy-preset", {"version": "1.0.0"}) + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["preset", "list", "--json"]))[0] + + assert item["author"] is None + assert item["source"] == {"kind": "local"} + + +def test_extension_list_json_is_installed_only_for_available_and_all(tmp_path, monkeypatch): + project = _project(tmp_path) + _extension(project, "example-ext") + ExtensionManager(project).registry.add("example-ext", {"version": "1.0.0", "source": "unknown"}) + + monkeypatch.chdir(project) + expected = _json_result(runner.invoke(app, ["extension", "list", "--json"])) + available = _json_result(runner.invoke(app, ["extension", "list", "--json", "--available"])) + all_extensions = _json_result(runner.invoke(app, ["extension", "list", "--json", "--all"])) + + assert available == expected == all_extensions + item = expected[0] + assert set(item) == { + "id", "name", "description", "version", "author", "priority", "enabled", "source", "provides" + } + assert item["author"] is None + assert item["source"] == {"kind": "local"} + assert item["provides"] == {"commands": 1, "templates": 0, "scripts": 0, "hooks": 0} + + +def test_empty_json_lists_are_successful_arrays(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + assert _json_result(runner.invoke(app, ["preset", "list", "--json"])) == [] + assert _json_result(runner.invoke(app, ["extension", "list", "--json"])) == [] + + +def test_preset_list_json_degrades_corrupt_records_and_malformed_sources(tmp_path, monkeypatch): + project = _project(tmp_path) + PresetManager(project).registry.add("broken-preset", {"version": "1.0.0", "source": []}) + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["preset", "list", "--json"]))[0] + + assert item["author"] is None + assert item["source"] == {"kind": "local"} + assert item["provides"] == {"commands": 0, "templates": 0, "scripts": 0} + + +def test_extension_json_counts_multiple_hooks_for_one_event(tmp_path, monkeypatch): + project = _project(tmp_path) + extension_dir = project / ".specify" / "extensions" / "multi-hook" + extension_dir.mkdir(parents=True) + (extension_dir / "extension.yml").write_text( + "schema_version: \"1.0\"\n" + "extension:\n" + " id: multi-hook\n" + " name: Multi Hook\n" + " version: \"1.0.0\"\n" + " description: Multiple hooks on one event\n" + "requires:\n" + " speckit_version: \">=0.1.0\"\n" + "provides:\n" + " commands:\n" + " - name: speckit.multi-hook.one\n" + " file: commands/one.md\n" + "hooks:\n" + " after_plan:\n" + " - command: speckit.multi-hook.one\n" + " - command: speckit.multi-hook.two\n", + encoding="utf-8", + ) + manager = ExtensionManager(project) + manager.registry.add("multi-hook", {"version": "1.0.0"}) + + assert manager.list_installed()[0]["hook_count"] == 1 + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["extension", "list", "--json"]))[0] + + assert item["provides"]["hooks"] == 2 + + +def test_text_list_rendering_retains_legacy_flat_counts(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project, "text-preset") + PresetManager(project).registry.add("text-preset", {"version": "1.0.0"}) + _extension(project, "example-ext") + ExtensionManager(project).registry.add("example-ext", {"version": "1.0.0"}) + + monkeypatch.chdir(project) + preset_result = runner.invoke(app, ["preset", "list"]) + extension_result = runner.invoke(app, ["extension", "list"]) + + assert preset_result.exit_code == extension_result.exit_code == 0 + assert "Templates: 3" in preset_result.stdout + assert "Commands: 1 | Hooks: 0" in extension_result.stdout + + +def test_preset_json_project_resolution_error_is_stderr_only(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["preset", "list", "--json"]) + + assert result.exit_code != 0 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "Not a Spec Kit project (no .specify/ directory)"} + + +def test_extension_json_runtime_error_is_stderr_only(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + def fail_list(_self): + raise RuntimeError("list failed") + + monkeypatch.setattr(ExtensionManager, "list_installed", fail_list) + result = runner.invoke(app, ["extension", "list", "--json"]) + + assert result.exit_code != 0 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "list failed"} From 1e4f38c2e51dff5308c204740d7450cf6b52a5ee Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 14:18:43 +0800 Subject: [PATCH 2/3] fix(cli): preserve installed source provenance in JSON Preserve valid catalog provenance in installed preset and extension JSON output while retaining the local fallback for missing, legacy, unknown, and malformed records. Carry raw registry source metadata through healthy and corrupt manager records, whitelist the public kind/catalog shape in the shared adapter, and document and test the contract without changing provenance producers. --- docs/reference/extensions.md | 13 +++-- docs/reference/presets.md | 10 ++-- src/specify_cli/_installed_list_json.py | 18 +++++- src/specify_cli/extensions/__init__.py | 18 +----- src/specify_cli/presets/__init__.py | 20 +------ tests/test_installed_list_json.py | 74 ++++++++++++++++++++++--- 6 files changed, 101 insertions(+), 52 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 70bd14ac28..1811e5b498 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -63,12 +63,13 @@ Lists installed extensions with their status, version, and command counts. `--json` writes a JSON array to stdout. Every item has the keys `id`, `name`, `description`, `version`, `author`, `priority`, `enabled`, `source`, and -`provides`. `author` is `null` when absent; `source` is either -`{"kind":"local"}` or `{"kind":"catalog"}`. Extension `provides` contains -`commands`, `templates`, `scripts`, and `hooks` counts. `--available` and -`--all` do not broaden JSON output beyond installed extensions. For runtime -failures after option parsing, `--json` writes `{"error":"..."}` to stderr and -exits nonzero. +`provides`. `author` is `null` when absent; `source` is `{"kind":"local"}` +for local, legacy, or malformed provenance, or +`{"kind":"catalog","catalog":""}` for a valid catalog source. +Extension `provides` contains `commands`, `templates`, `scripts`, and `hooks` +counts. `--available` and `--all` do not broaden JSON output beyond installed +extensions. For runtime failures after option parsing, `--json` writes +`{"error":"..."}` to stderr and exits nonzero. ## Extension Info diff --git a/docs/reference/presets.md b/docs/reference/presets.md index deeb941c30..9b515a6555 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -50,10 +50,12 @@ Lists installed presets with their versions, descriptions, template counts, and `--json` writes a JSON array to stdout. Every item has the keys `id`, `name`, `description`, `version`, `author`, `priority`, `enabled`, `source`, and -`provides`. `author` is `null` when absent; `source` is either -`{"kind":"local"}` or `{"kind":"catalog"}`. Preset `provides` contains -`commands`, `templates`, and `scripts` counts. For runtime failures after -option parsing, `--json` writes `{"error":"..."}` to stderr and exits nonzero. +`provides`. `author` is `null` when absent; `source` is `{"kind":"local"}` +for local, legacy, or malformed provenance, or +`{"kind":"catalog","catalog":""}` for a valid catalog source. +Preset `provides` contains `commands`, `templates`, and `scripts` counts. For +runtime failures after option parsing, `--json` writes `{"error":"..."}` to +stderr and exits nonzero. Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files. diff --git a/src/specify_cli/_installed_list_json.py b/src/specify_cli/_installed_list_json.py index 836a71b67c..c0893d553c 100644 --- a/src/specify_cli/_installed_list_json.py +++ b/src/specify_cli/_installed_list_json.py @@ -12,6 +12,22 @@ import typer +def _normalized_source(source: Any) -> dict[str, str]: + """Return the stable public source shape for an installed record.""" + if not isinstance(source, dict): + return {"kind": "local"} + + kind = source.get("kind") + if kind == "local": + return {"kind": "local"} + if kind == "catalog": + catalog = source.get("catalog") + if isinstance(catalog, str) and catalog.strip(): + return {"kind": "catalog", "catalog": catalog} + + return {"kind": "local"} + + def installed_list_item(record: dict[str, Any], *, include_hooks: bool) -> dict[str, Any]: """Return the canonical public JSON object for one installed record.""" provides = record["_json_provides"] @@ -30,7 +46,7 @@ def installed_list_item(record: dict[str, Any], *, include_hooks: bool) -> dict[ "author": record["_json_author"], "priority": record["priority"], "enabled": record["enabled"], - "source": {"kind": record["_json_source_kind"]}, + "source": _normalized_source(record["_json_source"]), "provides": provides, } diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index a853e07147..614d5fe31d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3413,13 +3413,6 @@ def list_installed(self) -> List[Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) - source = metadata.get("source") - source_kind = source.get("kind") if isinstance(source, dict) else source - source_kind = ( - source_kind - if isinstance(source_kind, str) and source_kind in {"local", "catalog"} - else "local" - ) author = manifest.data["extension"].get("author") json_hook_count = sum( len(coerce_hook_entries(hook_config)) @@ -3437,7 +3430,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "command_count": len(manifest.commands), "hook_count": len(manifest.hooks), "_json_author": author if isinstance(author, str) and author else None, - "_json_source_kind": source_kind, + "_json_source": metadata.get("source"), "_json_provides": { "commands": len(manifest.commands), "templates": len(manifest.templates), @@ -3448,13 +3441,6 @@ def list_installed(self) -> List[Dict[str, Any]]: ) except ValidationError: # Corrupted extension - source = metadata.get("source") - source_kind = source.get("kind") if isinstance(source, dict) else source - source_kind = ( - source_kind - if isinstance(source_kind, str) and source_kind in {"local", "catalog"} - else "local" - ) result.append( { "id": ext_id, @@ -3467,7 +3453,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "command_count": 0, "hook_count": 0, "_json_author": None, - "_json_source_kind": source_kind, + "_json_source": metadata.get("source"), "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, } ) diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index f30d289a73..4738f05276 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4066,15 +4066,6 @@ def list_installed(self) -> List[Dict[str, Any]]: provided_counts = {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0} for template in manifest.templates: provided_counts[f"{template['type']}s"] += 1 - source = metadata.get("source") - source_kind = ( - source.get("kind") if isinstance(source, dict) else source - ) - source_kind = ( - source_kind - if isinstance(source_kind, str) and source_kind in {"local", "catalog"} - else "local" - ) author = manifest.author result.append({ "id": pack_id, @@ -4087,17 +4078,10 @@ def list_installed(self) -> List[Dict[str, Any]]: "tags": manifest.tags, "priority": normalize_priority(metadata.get("priority")), "_json_author": author if isinstance(author, str) and author else None, - "_json_source_kind": source_kind, + "_json_source": metadata.get("source"), "_json_provides": provided_counts, }) except PresetValidationError: - source = metadata.get("source") - source_kind = source.get("kind") if isinstance(source, dict) else source - source_kind = ( - source_kind - if isinstance(source_kind, str) and source_kind in {"local", "catalog"} - else "local" - ) result.append({ "id": pack_id, "name": pack_id, @@ -4109,7 +4093,7 @@ def list_installed(self) -> List[Dict[str, Any]]: "tags": [], "priority": normalize_priority(metadata.get("priority")), "_json_author": None, - "_json_source_kind": source_kind, + "_json_source": metadata.get("source"), "_json_provides": {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0}, }) diff --git a/tests/test_installed_list_json.py b/tests/test_installed_list_json.py index a6e5748d89..53c725f493 100644 --- a/tests/test_installed_list_json.py +++ b/tests/test_installed_list_json.py @@ -4,9 +4,11 @@ import json +import pytest from typer.testing import CliRunner from specify_cli import app +from specify_cli._installed_list_json import _normalized_source from specify_cli.extensions import ExtensionManager from specify_cli.presets import PresetManager @@ -81,10 +83,12 @@ def test_preset_list_json_uses_canonical_wire_object_and_keeps_flat_manager_keys project = _project(tmp_path) _preset(project, "test-preset") manager = PresetManager(project) - manager.registry.add("test-preset", {"version": "1.0.0", "source": "catalog", "priority": 3}) + source = {"kind": "catalog", "catalog": "speckit-official"} + manager.registry.add("test-preset", {"version": "1.0.0", "source": source, "priority": 3}) record = manager.list_installed()[0] assert record["template_count"] == 3 + assert record["_json_source"] == source assert record["_json_provides"] == {"commands": 1, "templates": 1, "scripts": 1, "hooks": 0} monkeypatch.chdir(project) @@ -96,7 +100,7 @@ def test_preset_list_json_uses_canonical_wire_object_and_keeps_flat_manager_keys "id", "name", "description", "version", "author", "priority", "enabled", "source", "provides" } assert item["author"] == "Preset Author" - assert item["source"] == {"kind": "catalog"} + assert item["source"] == source assert item["provides"] == {"commands": 1, "templates": 1, "scripts": 1} assert "hooks" not in item["provides"] @@ -116,7 +120,8 @@ def test_preset_list_json_defaults_legacy_source_and_author(tmp_path, monkeypatc def test_extension_list_json_is_installed_only_for_available_and_all(tmp_path, monkeypatch): project = _project(tmp_path) _extension(project, "example-ext") - ExtensionManager(project).registry.add("example-ext", {"version": "1.0.0", "source": "unknown"}) + source = {"kind": "catalog", "catalog": "speckit-official"} + ExtensionManager(project).registry.add("example-ext", {"version": "1.0.0", "source": source}) monkeypatch.chdir(project) expected = _json_result(runner.invoke(app, ["extension", "list", "--json"])) @@ -129,7 +134,7 @@ def test_extension_list_json_is_installed_only_for_available_and_all(tmp_path, m "id", "name", "description", "version", "author", "priority", "enabled", "source", "provides" } assert item["author"] is None - assert item["source"] == {"kind": "local"} + assert item["source"] == source assert item["provides"] == {"commands": 1, "templates": 0, "scripts": 0, "hooks": 0} @@ -141,18 +146,73 @@ def test_empty_json_lists_are_successful_arrays(tmp_path, monkeypatch): assert _json_result(runner.invoke(app, ["extension", "list", "--json"])) == [] -def test_preset_list_json_degrades_corrupt_records_and_malformed_sources(tmp_path, monkeypatch): +def test_preset_list_json_preserves_catalog_source_for_corrupt_records(tmp_path, monkeypatch): project = _project(tmp_path) - PresetManager(project).registry.add("broken-preset", {"version": "1.0.0", "source": []}) + source = {"kind": "catalog", "catalog": "speckit-official"} + PresetManager(project).registry.add("broken-preset", {"version": "1.0.0", "source": source}) monkeypatch.chdir(project) item = _json_result(runner.invoke(app, ["preset", "list", "--json"]))[0] assert item["author"] is None - assert item["source"] == {"kind": "local"} + assert item["source"] == source assert item["provides"] == {"commands": 0, "templates": 0, "scripts": 0} +def test_extension_list_json_preserves_catalog_source_for_corrupt_records(tmp_path, monkeypatch): + project = _project(tmp_path) + source = {"kind": "catalog", "catalog": "speckit-official"} + ExtensionManager(project).registry.add("broken-extension", {"version": "1.0.0", "source": source}) + + monkeypatch.chdir(project) + item = _json_result(runner.invoke(app, ["extension", "list", "--json"]))[0] + + assert item["source"] == source + assert item["provides"] == {"commands": 0, "templates": 0, "scripts": 0, "hooks": 0} + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + ({"kind": "local", "catalog": "ignored", "extra": "ignored"}, {"kind": "local"}), + ( + {"kind": "catalog", "catalog": "speckit-official", "extra": "ignored"}, + {"kind": "catalog", "catalog": "speckit-official"}, + ), + ([], {"kind": "local"}), + ({"kind": "catalog"}, {"kind": "local"}), + ({"kind": "catalog", "catalog": " "}, {"kind": "local"}), + ({"kind": "catalog", "catalog": 1}, {"kind": "local"}), + ], +) +def test_normalized_source_whitelists_valid_shapes_and_falls_back(source, expected): + assert _normalized_source(source) == expected + + +def test_installed_list_json_falls_back_for_legacy_unknown_and_malformed_sources(tmp_path, monkeypatch): + project = _project(tmp_path) + _preset(project, "legacy-preset") + _preset(project, "malformed-preset") + _extension(project, "unknown-ext") + PresetManager(project).registry.add("legacy-preset", {"version": "1.0.0", "source": "catalog"}) + PresetManager(project).registry.add( + "malformed-preset", {"version": "1.0.0", "source": {"kind": "catalog", "catalog": []}} + ) + ExtensionManager(project).registry.add( + "unknown-ext", {"version": "1.0.0", "source": {"kind": "remote", "catalog": "other"}} + ) + + monkeypatch.chdir(project) + presets = _json_result(runner.invoke(app, ["preset", "list", "--json"])) + extension = _json_result(runner.invoke(app, ["extension", "list", "--json"]))[0] + + assert {item["id"]: item["source"] for item in presets} == { + "legacy-preset": {"kind": "local"}, + "malformed-preset": {"kind": "local"}, + } + assert extension["source"] == {"kind": "local"} + + def test_extension_json_counts_multiple_hooks_for_one_event(tmp_path, monkeypatch): project = _project(tmp_path) extension_dir = project / ".specify" / "extensions" / "multi-hook" From dd8b34e3d891a18ec55c71fbbddefdac1622d85a Mon Sep 17 00:00:00 2001 From: root Date: Mon, 24 Aug 2026 21:40:25 +0800 Subject: [PATCH 3/3] fix(cli): persist catalog provenance across install paths Propagate normalized catalog names through preset and extension install, init, bundler refresh, archive, and update paths while preserving local fallbacks and deterministic JSON ordering. --- .../bundler/services/primitives.py | 12 +- src/specify_cli/commands/init.py | 10 +- src/specify_cli/extensions/__init__.py | 21 ++- src/specify_cli/extensions/_commands.py | 17 ++- src/specify_cli/presets/__init__.py | 32 ++++- src/specify_cli/presets/_commands.py | 1 + tests/integrations/test_cli.py | 76 +++++++++++ tests/test_extensions.py | 81 +++++++++-- tests/test_installed_list_json.py | 129 ++++++++++++++++++ tests/test_presets.py | 80 ++++++++++- tests/unit/test_bundler_primitives.py | 84 ++++++++++++ 11 files changed, 521 insertions(+), 22 deletions(-) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 31b1126a34..881330ed1e 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -209,7 +209,11 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: zip_path = catalog.download_pack(component.id) try: self._manager.install_from_zip( - zip_path, speckit_version, priority, **({"force": True} if force else {}) + zip_path, + speckit_version, + priority, + catalog_name=info.get("_catalog_name"), + **({"force": True} if force else {}), ) finally: with contextlib.suppress(Exception): @@ -294,7 +298,11 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: zip_path = catalog.download_extension(component.id) try: self._manager.install_from_zip( - zip_path, speckit_version, priority=priority, force=force + zip_path, + speckit_version, + priority=priority, + force=force, + catalog_name=info.get("_catalog_name"), ) finally: with contextlib.suppress(Exception): diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 4af9427bfa..7de67d3380 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -179,7 +179,11 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve zip_path = catalog.download_extension(resolved_id) try: - manifest = manager.install_from_zip(zip_path, speckit_version) + manifest = manager.install_from_zip( + zip_path, + speckit_version, + catalog_name=ext_info.get("_catalog_name"), + ) finally: zip_path.unlink(missing_ok=True) return f"{manifest.name} v{manifest.version} installed" @@ -862,7 +866,9 @@ def init( try: zip_path = preset_catalog.download_pack(preset) preset_manager.install_from_zip( - zip_path, speckit_ver + zip_path, + speckit_ver, + catalog_name=pack_info.get("_catalog_name"), ) except PresetError as preset_err: _print_cli_warning( diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 614d5fe31d..ac4907e73e 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2052,6 +2052,8 @@ def install_from_directory( priority: int = 10, link_commands: bool = False, force: bool = False, + *, + catalog_name: str | None = None, ) -> ExtensionManifest: """Install extension from a local directory. @@ -2612,11 +2614,19 @@ def _restore_stranded_config_file( backup_config_dir.unlink() # Update registry + normalized_catalog_name = ( + catalog_name.strip() if isinstance(catalog_name, str) else "" + ) + source = ( + {"kind": "catalog", "catalog": normalized_catalog_name} + if normalized_catalog_name + else "local" + ) self.registry.add( manifest.id, { "version": manifest.version, - "source": "local", + "source": source, "manifest_hash": manifest.get_hash(), "enabled": True, "priority": priority, @@ -2673,6 +2683,7 @@ def install_from_archive( archive_file: BinaryIO | None = None, source_name: str | None = None, content_type: str | None = None, + catalog_name: str | None = None, ) -> ExtensionManifest: """Install an extension from a supported archive. @@ -2724,7 +2735,11 @@ def install_from_archive( # Install from extracted directory return self.install_from_directory( - extension_dir, speckit_version, priority=priority, force=force + extension_dir, + speckit_version, + priority=priority, + force=force, + catalog_name=catalog_name, ) def _config_root_is_contained(self, specify_dir: Path) -> bool: @@ -2880,6 +2895,7 @@ def install_from_zip( archive_file: BinaryIO | None = None, source_name: str | None = None, content_type: str | None = None, + catalog_name: str | None = None, ) -> ExtensionManifest: """Backward-compatible wrapper for archive installation.""" return self.install_from_archive( @@ -2890,6 +2906,7 @@ def install_from_zip( archive_file=archive_file, source_name=source_name, content_type=content_type, + catalog_name=catalog_name, ) def remove(self, extension_id: str, keep_config: bool = False) -> bool: diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 11ec17b8fb..272839b79e 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -424,13 +424,20 @@ def extension_list( json_output: bool = typer.Option(False, "--json", help="Output installed extensions as JSON"), ): """List installed extensions.""" - from . import ExtensionManager + from . import ExtensionManager, normalize_priority if json_output: try: project_root = resolve_specify_project_root() manager = ExtensionManager(project_root) installed = manager.list_installed() + installed = sorted( + installed, + key=lambda extension: ( + normalize_priority(extension.get("priority")), + str(extension.get("id", "")), + ), + ) emit_json([installed_list_item(ext, include_hooks=True) for ext in installed]) return except Exception as error: @@ -1100,6 +1107,7 @@ def extension_add( speckit_version, priority=priority, force=force, + catalog_name=ext_info.get("_catalog_name"), ) finally: if archive_path.exists(): @@ -1678,6 +1686,7 @@ def extension_update( "installed": str(installed_version), "available": str(catalog_version), "download_url": ext_info.get("download_url"), + "catalog_name": ext_info.get("_catalog_name"), } ) else: @@ -2276,7 +2285,11 @@ def backup_extension_skills(skill_names, *, skills_dir=None): manager.remove(extension_id, keep_config=True) # 8. Install new version - _ = manager.install_from_zip(archive_path, speckit_version) + _ = manager.install_from_zip( + archive_path, + speckit_version, + catalog_name=update["catalog_name"], + ) # Restore user config files from backup after successful install. new_extension_dir = manager.extensions_dir / extension_id diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 4738f05276..b3b1e58635 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -3537,6 +3537,8 @@ def install_from_directory( speckit_version: str, priority: int = 10, force: bool = False, + *, + catalog_name: str | None = None, ) -> PresetManifest: """Install preset from a local directory. @@ -3578,9 +3580,17 @@ def install_from_directory( # Pre-register the preset so that composition resolution can see it # in the priority stack when resolving composed command content. + normalized_catalog_name = ( + catalog_name.strip() if isinstance(catalog_name, str) else "" + ) + source = ( + {"kind": "catalog", "catalog": normalized_catalog_name} + if normalized_catalog_name + else "local" + ) self.registry.add(manifest.id, { "version": manifest.version, - "source": "local", + "source": source, "manifest_hash": manifest.get_hash(), "enabled": True, "priority": priority, @@ -3719,6 +3729,8 @@ def install_from_archive( speckit_version: str, priority: int = 10, force: bool = False, + *, + catalog_name: str | None = None, ) -> PresetManifest: """Install a preset from a supported archive. @@ -3762,7 +3774,13 @@ def install_from_archive( "No preset.yml found in archive" ) - return self.install_from_directory(pack_dir, speckit_version, priority, force=force) + return self.install_from_directory( + pack_dir, + speckit_version, + priority, + force=force, + catalog_name=catalog_name, + ) def install_from_zip( self, @@ -3770,6 +3788,8 @@ def install_from_zip( speckit_version: str, priority: int = 10, force: bool = False, + *, + catalog_name: str | None = None, ) -> PresetManifest: """Backward-compatible wrapper for archive installation.""" return self.install_from_archive( @@ -3777,6 +3797,7 @@ def install_from_zip( speckit_version, priority, force=force, + catalog_name=catalog_name, ) def remove(self, pack_id: str) -> bool: @@ -4337,9 +4358,14 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalog install_allowed = raw_install.strip().lower() in ("true", "yes", "1") else: install_allowed = bool(raw_install) + raw_name = item.get("name") + name = str(raw_name).strip() if raw_name is not None else "" + if not name: + name = f"catalog-{len(entries) + 1}" + entries.append(PresetCatalogEntry( url=url, - name=str(item.get("name", f"catalog-{idx + 1}")), + name=name, priority=priority, install_allowed=install_allowed, description=str(item.get("description", "")), diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 87107c3539..b7d353e03a 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -292,6 +292,7 @@ def _validate_download_redirect(old_url, new_url): archive_path, speckit_version, priority, + catalog_name=pack_info.get("_catalog_name"), ) console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") finally: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 640d12a5fc..2beb411a2a 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -2710,6 +2710,82 @@ def test_bundled_extension_installed(self, tmp_path): normalized = _normalize_cli_output(result.output) assert "Install extension: git" in normalized + def test_catalog_extension_init_forwards_catalog_name(self, tmp_path, monkeypatch): + """The init catalog branch keeps the catalog provenance at install time.""" + from types import SimpleNamespace + + import specify_cli._assets as assets + import specify_cli.commands.init as init_module + from specify_cli.extensions import ExtensionCatalog, ExtensionManager + + project = tmp_path / "project" + project.mkdir() + archive = tmp_path / "extension.zip" + archive.write_bytes(b"archive") + captured = {} + + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda _id: None) + monkeypatch.setattr( + ExtensionCatalog, + "get_extension_info", + lambda _self, _id: { + "id": "catalog-extension", + "_install_allowed": True, + "_catalog_name": "init-catalog", + }, + ) + monkeypatch.setattr( + ExtensionCatalog, + "download_extension", + lambda _self, _id: archive, + ) + + def fake_install_from_zip(self, _archive, _version, *, catalog_name=None): + captured["catalog_name"] = catalog_name + return SimpleNamespace(name="Catalog Extension", version="1.0.0") + + monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install_from_zip) + + result = init_module._install_extension_during_init( + project, "catalog-extension", "1.0.0" + ) + + assert result == "Catalog Extension v1.0.0 installed" + assert captured == {"catalog_name": "init-catalog"} + + def test_catalog_preset_init_forwards_catalog_name(self, tmp_path, monkeypatch): + """The init preset catalog branch keeps resolved provenance.""" + import specify_cli._assets as assets + from specify_cli.presets import PresetCatalog, PresetManager + + captured = {} + + monkeypatch.setattr(assets, "_locate_bundled_preset", lambda _id: None) + monkeypatch.setattr( + PresetCatalog, + "get_pack_info", + lambda _self, _id: { + "_install_allowed": True, + "_catalog_name": "init-preset-catalog", + }, + ) + archive = tmp_path / "preset.zip" + archive.write_bytes(b"archive") + monkeypatch.setattr(PresetCatalog, "download_pack", lambda _self, _id: archive) + + def fake_install_from_zip(self, _archive, _version, *, catalog_name=None): + captured["catalog_name"] = catalog_name + + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + _project, result = self._run_init( + tmp_path, + ["--preset", "catalog-preset"], + project_name="preset-catalog", + ) + + assert result.exit_code == 0, result.output + assert captured == {"catalog_name": "init-preset-catalog"} + def test_multiple_extensions_installed(self, tmp_path): """--extension can be specified multiple times.""" project, result = self._run_init( diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6642da2b09..8eae43b1db 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2817,11 +2817,15 @@ def test_install_zip_force_reinstall(self, extension_dir, project_dir): # Force-reinstall from ZIP manifest = manager.install_from_zip( - zip_path, "0.1.0", force=True + zip_path, "0.1.0", force=True, catalog_name="extension-catalog" ) assert manifest.id == "test-ext" assert manager.registry.is_installed("test-ext") + assert manager.registry.get("test-ext")["source"] == { + "kind": "catalog", + "catalog": "extension-catalog", + } ext_dir = project_dir / ".specify" / "extensions" / "test-ext" assert ext_dir.exists() @@ -2892,10 +2896,16 @@ def test_install_from_tar_archive( archive.add(file_path, arcname=arcname) manager = ExtensionManager(project_dir) - manifest = manager.install_from_archive(archive_path, "0.1.0") + manifest = manager.install_from_archive( + archive_path, "0.1.0", catalog_name="extension-catalog" + ) assert manifest.id == "test-ext" assert manager.registry.is_installed("test-ext") + assert manager.registry.get("test-ext")["source"] == { + "kind": "catalog", + "catalog": "extension-catalog", + } def test_install_from_tar_rejects_symlink_entry( self, extension_dir, project_dir, temp_dir @@ -7909,6 +7919,46 @@ def mock_download(extension_id): f"but was called with '{download_called_with[0]}'" ) + def test_catalog_add_forwards_catalog_name(self, tmp_path): + """The extension catalog branch passes resolved provenance to the manager.""" + from types import SimpleNamespace + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + (project_dir / ".specify").mkdir(parents=True) + archive = tmp_path / "extension.zip" + archive.write_bytes(b"archive") + captured = {} + + def fake_install_from_zip(self, _archive, _version, **kwargs): + captured.update(kwargs) + return SimpleNamespace( + id="catalog-extension", + name="Catalog Extension", + version="1.0.0", + description="catalog extension", + warnings=[], + commands=[], + ) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "catalog-extension", + "name": "Catalog Extension", + "version": "1.0.0", + "_install_allowed": True, + "_catalog_name": "extension-catalog", + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=archive), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \ + patch("specify_cli.extensions._commands._refresh_events_and_warn"): + result = CliRunner().invoke(app, ["extension", "add", "catalog-extension"]) + + assert result.exit_code == 0, result.output + assert captured["catalog_name"] == "extension-catalog" + def test_add_discovery_only_error_suggests_resolved_id(self, tmp_path): """The not-installable error must suggest a copy-pasteable command using the resolved catalog ID, not a display name that may contain spaces.""" @@ -8930,7 +8980,9 @@ def test_update_rejects_unsafe_manifest_path_before_removal( manager = ExtensionManager(project_dir) v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") + manager.install_from_directory( + v1_dir, "0.1.0", catalog_name="previous-catalog" + ) installed_extension_dir = manager.extensions_dir / "test-ext" removed_paths = [] real_rmtree = shutil.rmtree @@ -9166,15 +9218,20 @@ def test_update_success_preserves_installed_at( ) v2_dir = self._create_extension_source(tmp_path, "2.0.0") - def fake_install_from_zip(self_obj, _zip_path, speckit_version): - return self_obj.install_from_directory(v2_dir, speckit_version) + def fake_install_from_zip( + self_obj, _zip_path, speckit_version, *, catalog_name=None + ): + return self_obj.install_from_directory( + v2_dir, speckit_version, catalog_name=catalog_name + ) with patch.object(Path, "cwd", return_value=project_dir), \ patch.object(ExtensionCatalog, "get_extension_info", return_value={ "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "_install_allowed": True, + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + "_catalog_name": "updated-catalog", }), \ patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): @@ -9185,6 +9242,10 @@ def fake_install_from_zip(self_obj, _zip_path, speckit_version): updated = ExtensionManager(project_dir).registry.get("test-ext") assert updated["version"] == "2.0.0" assert updated["installed_at"] == original_installed_at + assert updated["source"] == { + "kind": "catalog", + "catalog": "updated-catalog", + } restored_config_content = ( project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" ).read_text() @@ -9212,7 +9273,9 @@ def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, m manager = ExtensionManager(project_dir) v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") + manager.install_from_directory( + v1_dir, "0.1.0", catalog_name="original-catalog" + ) backup_registry_entry = manager.registry.get("test-ext") hooks_before = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) diff --git a/tests/test_installed_list_json.py b/tests/test_installed_list_json.py index 53c725f493..2519a0df16 100644 --- a/tests/test_installed_list_json.py +++ b/tests/test_installed_list_json.py @@ -117,6 +117,135 @@ def test_preset_list_json_defaults_legacy_source_and_author(tmp_path, monkeypatc assert item["source"] == {"kind": "local"} +def test_catalog_install_producers_persist_structured_source_and_keep_local_default( + tmp_path, monkeypatch +): + project = _project(tmp_path) + source_project = tmp_path / "sources" + (source_project / ".specify").mkdir(parents=True) + _preset(source_project, "catalog-preset") + _preset(source_project, "local-preset") + _extension(source_project, "catalog-extension") + _extension(source_project, "local-extension") + for extension_id in ("catalog-extension", "local-extension"): + manifest_path = ( + source_project / ".specify" / "extensions" / extension_id / "extension.yml" + ) + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8").replace( + "speckit.example-ext.example", f"speckit.{extension_id}.example" + ), + encoding="utf-8", + ) + + preset_manager = PresetManager(project) + preset_manager.install_from_directory( + source_project / ".specify" / "presets" / "catalog-preset", + "1.0.0", + catalog_name=" preset-catalog ", + ) + preset_manager.install_from_directory( + source_project / ".specify" / "presets" / "local-preset", + "1.0.0", + catalog_name=" ", + ) + + extension_manager = ExtensionManager(project) + extension_manager.install_from_directory( + source_project / ".specify" / "extensions" / "catalog-extension", + "1.0.0", + register_commands=False, + catalog_name=" extension-catalog ", + ) + extension_manager.install_from_directory( + source_project / ".specify" / "extensions" / "local-extension", + "1.0.0", + register_commands=False, + catalog_name=" ", + ) + + assert preset_manager.registry.get("catalog-preset")["source"] == { + "kind": "catalog", + "catalog": "preset-catalog", + } + assert preset_manager.registry.get("local-preset")["source"] == "local" + assert extension_manager.registry.get("catalog-extension")["source"] == { + "kind": "catalog", + "catalog": "extension-catalog", + } + assert extension_manager.registry.get("local-extension")["source"] == "local" + + monkeypatch.chdir(project) + presets = _json_result(runner.invoke(app, ["preset", "list", "--json"])) + extensions = _json_result(runner.invoke(app, ["extension", "list", "--json"])) + assert {item["id"]: item["source"] for item in presets} == { + "catalog-preset": {"kind": "catalog", "catalog": "preset-catalog"}, + "local-preset": {"kind": "local"}, + } + assert {item["id"]: item["source"] for item in extensions} == { + "catalog-extension": {"kind": "catalog", "catalog": "extension-catalog"}, + "local-extension": {"kind": "local"}, + } + + +def test_preset_json_order_uses_priority_then_id_despite_install_order(tmp_path, monkeypatch): + project = _project(tmp_path) + source_project = tmp_path / "sources" + (source_project / ".specify").mkdir(parents=True) + for preset_id in ("zebra", "later", "alpha"): + _preset(source_project, preset_id) + + manager = PresetManager(project) + manager.install_from_directory( + source_project / ".specify" / "presets" / "zebra", "1.0.0", priority=5 + ) + manager.install_from_directory( + source_project / ".specify" / "presets" / "later", "1.0.0", priority=9 + ) + manager.install_from_directory( + source_project / ".specify" / "presets" / "alpha", "1.0.0", priority=5 + ) + + monkeypatch.chdir(project) + payload = _json_result(runner.invoke(app, ["preset", "list", "--json"])) + assert [item["id"] for item in payload] == ["alpha", "zebra", "later"] + + +def test_extension_json_order_uses_priority_then_id_despite_install_order( + tmp_path, monkeypatch +): + project = _project(tmp_path) + source_project = tmp_path / "sources" + (source_project / ".specify").mkdir(parents=True) + for extension_id in ("zebra", "later", "alpha"): + _extension(source_project, extension_id) + manifest_path = ( + source_project / ".specify" / "extensions" / extension_id / "extension.yml" + ) + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8").replace( + "speckit.example-ext.example", f"speckit.{extension_id}.example" + ), + encoding="utf-8", + ) + + manager = ExtensionManager(project) + for extension_id, priority in (("zebra", 5), ("later", 9), ("alpha", 5)): + manager.install_from_directory( + source_project / ".specify" / "extensions" / extension_id, + "1.0.0", + register_commands=False, + priority=priority, + ) + manager.registry.update("zebra", {"enabled": False}) + + monkeypatch.chdir(project) + payload = _json_result(runner.invoke(app, ["extension", "list", "--json"])) + + assert [item["id"] for item in payload] == ["alpha", "zebra", "later"] + assert payload[1]["enabled"] is False + + def test_extension_list_json_is_installed_only_for_available_and_all(tmp_path, monkeypatch): project = _project(tmp_path) _extension(project, "example-ext") diff --git a/tests/test_presets.py b/tests/test_presets.py index 9775e0afa9..aa3bdf6d4e 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -834,9 +834,15 @@ def test_install_from_zip(self, project_dir, pack_dir, temp_dir): zf.write(file_path, arcname) manager = PresetManager(project_dir) - manifest = manager.install_from_zip(zip_path, "0.1.5") + manifest = manager.install_from_zip( + zip_path, "0.1.5", catalog_name="preset-catalog" + ) assert manifest.id == "test-pack" assert manager.registry.is_installed("test-pack") + assert manager.registry.get("test-pack")["source"] == { + "kind": "catalog", + "catalog": "preset-catalog", + } def test_install_from_zip_forwards_force( self, project_dir, pack_dir, temp_dir @@ -919,10 +925,16 @@ def test_install_from_tar_archive( archive.add(file_path, arcname=arcname) manager = PresetManager(project_dir) - manifest = manager.install_from_archive(archive_path, "0.1.5") + manifest = manager.install_from_archive( + archive_path, "0.1.5", catalog_name="preset-catalog" + ) assert manifest.id == "test-pack" assert manager.registry.is_installed("test-pack") + assert manager.registry.get("test-pack")["source"] == { + "kind": "catalog", + "catalog": "preset-catalog", + } def test_install_from_tar_rejects_symlink_entry( self, project_dir, pack_dir, temp_dir @@ -3324,6 +3336,40 @@ def test_load_catalog_config_empty(self, project_dir): result = catalog._load_catalog_config(config_path) assert result is None + def test_load_catalog_config_defaults_blank_names(self, project_dir): + """Blank and null names normalize by valid catalog order.""" + config_path = project_dir / ".specify" / "preset-catalogs.yml" + config_path.write_text( + yaml.dump( + { + "catalogs": [ + {"name": "skipped", "url": " "}, + { + "name": None, + "url": "https://one.example.com/catalog.json", + }, + { + "name": " ", + "url": "https://two.example.com/catalog.json", + }, + { + "name": " padded-name ", + "url": "https://three.example.com/catalog.json", + }, + ] + } + ), + encoding="utf-8", + ) + + entries = PresetCatalog(project_dir)._load_catalog_config(config_path) + + assert [entry.name for entry in entries] == [ + "catalog-1", + "catalog-2", + "padded-name", + ] + def test_load_catalog_config_invalid_yaml(self, project_dir): """Test loading invalid YAML raises error.""" config_path = project_dir / ".specify" / "preset-catalogs.yml" @@ -10752,6 +10798,36 @@ def test_bundled_preset_add_via_cli(self, project_dir): assert "Lean Workflow" in result.output assert "installed" in result.output.lower() + def test_preset_add_catalog_forwards_catalog_name(self, project_dir, monkeypatch): + """Catalog installs pass resolved provenance into the manager boundary.""" + from specify_cli.presets._commands import preset_add + + captured = {} + + def fake_install_from_zip(self, _archive, _version, priority=10, *, catalog_name=None): + captured.update(priority=priority, catalog_name=catalog_name) + return SimpleNamespace(name="Catalog Preset", version="1.0.0") + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "1.0.0") + monkeypatch.setattr( + PresetCatalog, + "get_pack_info", + lambda _self, _id: { + "name": "Catalog Preset", + "_install_allowed": True, + "_catalog_name": "preset-catalog", + }, + ) + archive = project_dir / "preset.zip" + archive.write_bytes(b"archive") + monkeypatch.setattr(PresetCatalog, "download_pack", lambda _self, _id: archive) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + preset_add(preset_id="catalog-preset", from_url=None, dev=None, priority=7) + + assert captured == {"priority": 7, "catalog_name": "preset-catalog"} + def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch): """URL installs reject redirects from HTTPS to non-loopback HTTP.""" import typer diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index dc39106b50..fcdb9c95bc 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -134,6 +134,90 @@ def install_from_directory(self, directory, speckit_version, priority): assert calls["priority"] == 0 +def test_catalog_preset_install_and_refresh_forward_catalog_name( + tmp_path: Path, monkeypatch +): + import specify_cli._assets as assets + from specify_cli.presets import PresetCatalog + + archive = tmp_path / "preset.zip" + archive.write_bytes(b"placeholder") + calls = [] + + class _FakeManager: + def install_from_zip(self, *args, **kwargs): + calls.append(kwargs) + + monkeypatch.setattr(assets, "_locate_bundled_preset", lambda _id: None) + monkeypatch.setattr( + PresetCatalog, + "get_pack_info", + lambda _self, _id: { + "version": "1.0.0", + "_install_allowed": True, + "_catalog_name": "bundle-preset-catalog", + }, + ) + monkeypatch.setattr(PresetCatalog, "download_pack", lambda _self, _id: archive) + + manager = primitive_manager("presets", tmp_path, allow_network=True) + manager._manager = _FakeManager() + component = ComponentRef(kind="presets", id="catalog-preset", version="1.0.0") + manager.install(component) + archive.write_bytes(b"placeholder") + manager.refresh(component) + + assert [call["catalog_name"] for call in calls] == [ + "bundle-preset-catalog", + "bundle-preset-catalog", + ] + assert calls[1]["force"] is True + + +def test_catalog_extension_install_and_refresh_forward_catalog_name( + tmp_path: Path, monkeypatch +): + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionCatalog + + archive = tmp_path / "extension.zip" + archive.write_bytes(b"placeholder") + calls = [] + + class _FakeManager: + def install_from_zip(self, *args, **kwargs): + calls.append(kwargs) + + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda _id: None) + monkeypatch.setattr( + ExtensionCatalog, + "get_extension_info", + lambda _self, _id: { + "version": "1.0.0", + "_install_allowed": True, + "_catalog_name": "bundle-extension-catalog", + }, + ) + monkeypatch.setattr( + ExtensionCatalog, "download_extension", lambda _self, _id: archive + ) + + manager = primitive_manager("extensions", tmp_path, allow_network=True) + manager._manager = _FakeManager() + component = ComponentRef( + kind="extensions", id="catalog-extension", version="1.0.0" + ) + manager.install(component) + archive.write_bytes(b"placeholder") + manager.refresh(component) + + assert [call["catalog_name"] for call in calls] == [ + "bundle-extension-catalog", + "bundle-extension-catalog", + ] + assert calls[1]["force"] is True + + def _write_manifest(path: Path, root_key: str, version: str) -> Path: path.mkdir(parents=True, exist_ok=True) (path / f"{root_key}.yml").write_text(