diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py index 2f92f22c01..fe9a0390bc 100644 --- a/src/specify_cli/integrations/amp/__init__.py +++ b/src/specify_cli/integrations/amp/__init__.py @@ -1,6 +1,7 @@ """Amp CLI integration.""" from collections.abc import Mapping, Sequence +from pathlib import Path from typing import Any from ..base import MarkdownIntegration @@ -30,6 +31,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable()] diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index fd895e1486..b419293dbd 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -1,38 +1,13 @@ -"""specify preset * command handlers — app objects and register() entry point. +"""Typer command group and shared CLI infrastructure for ``specify preset``.""" -Moved out of __init__.py (PR-6/8). Handlers reference helpers that remain in -the package root (`_require_specify_project`, `get_speckit_version`, -`_locate_bundled_preset`, `_display_project_path`) via lazy `from .. import` -calls inside each function so test monkeypatching of `specify_cli.` -keeps working. -""" from __future__ import annotations -import os -import re -import shlex -from pathlib import Path - import typer -import yaml -from rich.markup import escape as _escape_markup from .._console import console -from .._installed_list_json import ( - InstalledListJSONCommand, - 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, - detect_archive_format, - is_https_or_localhost_http, - is_safe_download_redirect, - read_response_limited, -) +from .._download_security import read_response_limited as _read_response_limited + +read_response_limited = _read_response_limited preset_app = typer.Typer( name="preset", @@ -40,27 +15,15 @@ add_completion=False, ) -preset_catalog_app = typer.Typer( - name="catalog", - help="Manage preset catalogs", - add_completion=False, -) -preset_app.add_typer(preset_catalog_app, name="catalog") - - -#: Lowest priority a user may request. Lower numbers win resolution, so the -#: stack is anchored at 1 rather than 0 to leave no unreachable slot above the -#: highest-precedence preset. +# Lowest priority a user may request. Lower numbers win resolution, so the +# stack is anchored at 1 rather than 0 to leave no unreachable slot above the +# highest-precedence preset. MINIMUM_PRESET_PRIORITY = 1 def _render_powershell_argv(argv: list[str]) -> str: - """Render argv as a copy-pastable PowerShell command. + """Render argv as a copy-pastable PowerShell command.""" - PowerShell single-quoted strings are literal except that an embedded single - quote is escaped by doubling it. The call operator is required because the - executable name is quoted too. - """ def quote_arg(arg: str) -> str: return "'" + arg.replace("'", "''") + "'" @@ -68,14 +31,7 @@ def quote_arg(arg: str) -> str: def _validate_priority(priority: int) -> None: - """Reject a non-positive priority before any destructive work begins. - - Shared by add, set-priority, and update so the three commands cannot drift - apart on the accepted range or the message they print. update in particular - must call this *before* removing the installed preset: validating only - inside add would leave the preset removed and print a retry command - carrying the same rejected priority. - """ + """Reject a non-positive priority before destructive work begins.""" if priority < MINIMUM_PRESET_PRIORITY: console.print( "[red]Error:[/red] Priority must be a positive integer " @@ -85,1049 +41,59 @@ def _validate_priority(priority: int) -> None: def _warn_unmet_extension_dependencies(manager, manifest) -> None: - """Warn when a preset's declared extension dependencies are unsatisfied. + """Preserve the legacy helper import path for external consumers.""" + from .command_add import _warn_unmet_extension_dependencies as implementation - A preset whose command overrides call into an extension is inert without - it, but the overrides still fall through to the core workflow, so nothing - breaks -- it just silently does less than the user expects. Naming the - missing extension and the command that installs it turns that silence into - something actionable. See issue #4231. - """ - from ..extensions._commands import _command_safe_id + implementation(manager, manifest) - unmet = manager.find_unmet_extension_dependencies(manifest) - if not unmet: - return - console.print() - console.print("[yellow]![/yellow] This preset depends on extensions that are not satisfied:") - needs_catalog = False - for dep in unmet: - uses_catalog = False - extension_id = _escape_markup(dep["id"]) - # The displayed id only needs Rich escaping, but a suggested command - # has to survive Typer's parser: `^[a-z0-9-]+$` admits a leading - # hyphen, so an id like `--force` would render as an option rather - # than the positional argument. _command_safe_id substitutes a - # placeholder in that case, the same way extension commands do. - command_id = _command_safe_id(dep["id"]) - reason = dep["reason"] - # The remediation has to match the reason. `extension add` refuses an - # already-installed extension without --force, and `extension update` - # only moves forward to the catalog release. A general PEP 440 - # constraint may require an exact version, an upper bound, or a - # downgrade, so do not promise that update will satisfy it. - if reason == "missing": - console.print(f" [yellow]{extension_id}[/yellow] is not installed") - label, remedy = "Install with", f"specify extension add {command_id}" - uses_catalog = True - elif reason == "corrupt": - console.print( - f" [yellow]{extension_id}[/yellow] has an unreadable " - "registry entry" - ) - # is_installed() still counts the key, so a plain add is refused. - label = "Reinstall with" - remedy = f"specify extension add {command_id} --force" - uses_catalog = True - elif reason == "stale": - console.print( - f" [yellow]{extension_id}[/yellow] is registered but its " - "files are missing" - ) - label = "Reinstall with" - remedy = f"specify extension add {command_id} --force" - uses_catalog = True - elif reason == "disabled": - console.print(f" [yellow]{extension_id}[/yellow] is installed but disabled") - label, remedy = "Enable with", f"specify extension enable {command_id}" - else: - console.print( - f" [yellow]{extension_id}[/yellow] " - f"{_escape_markup(dep['installed'])} does not satisfy " - f"{_escape_markup(dep['version'])}" - ) - label = "Needs" - remedy = ( - f"a release of {command_id} satisfying " - f"{_escape_markup(dep['version'])}" - ) - console.print(f" {label}: {remedy}") - needs_catalog = needs_catalog or uses_catalog - console.print() - # The consequence differs by reason and must not be overstated. An - # unavailable extension contributes nothing, so those features are simply - # inert. A version mismatch is the opposite: the extension is installed and - # enabled, so the preset does invoke it -- the combination is just untested - # against the declared constraint, which is not the same as "safe". - console.print("[dim]The preset is installed.[/dim]") - if any( - dep["reason"] in ("missing", "corrupt", "stale", "disabled") - for dep in unmet - ): - console.print( - "[dim]Anything relying on an unavailable extension does nothing " - "until that is resolved.[/dim]" - ) - if any(dep["reason"] == "version" for dep in unmet): - console.print( - "[dim]Where only a version constraint is unmet the extension is " - "still used, so it may not behave as the preset expects.[/dim]" - ) - if needs_catalog: - # `extension add ` resolves through the catalogs, and the default - # community catalog is discovery-only, so installing by id is refused - # for anything listed only there -- true of every extension motivating - # this feature. Knowing which applies would mean a catalog fetch, and - # this runs on an install path that touches no network, so describe - # the outcome instead of asserting the command succeeds. The rejection - # itself prints the exact --from form, so this is a signpost rather - # than a dead end. - console.print( - "[dim]If an extension is listed only in a discovery-only catalog, " - "that command is refused and prints the " - "--from form to use instead.[/dim]" - ) - - -# ===== Preset Commands ===== - - -@preset_app.command("list", cls=InstalledListJSONCommand) -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 +def preset_add(*args, **kwargs): + """Preserve the legacy add-handler import path for external consumers.""" + from .command_add import preset_add as implementation - 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) + return implementation(*args, **kwargs) - project_root = _require_specify_project() - manager = PresetManager(project_root) - installed = manager.list_installed() - if not installed: - console.print("[yellow]No presets installed.[/yellow]") - console.print("\nInstall a preset with:") - console.print(" [cyan]specify preset add [/cyan]") - return +def preset_remove(*args, **kwargs): + """Preserve the legacy remove-handler import path for external consumers.""" + from .command_remove import preset_remove as implementation - # Sort by actual resolution precedence: lower priority number wins, ties - # broken by preset id (matching PresetRegistry.list_by_priority()). This - # keeps the printed order aligned with how presets are composed/resolved. - installed = sorted( - installed, - key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), - ) - - console.print("\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n") - for pack in installed: - status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" - pri = pack.get('priority', 10) - name = _escape_markup(str(pack['name'])) - pack_id = _escape_markup(str(pack['id'])) - version = _escape_markup(str(pack['version'])) - console.print(f" [bold]{name}[/bold] ({pack_id}) v{version} — {status} — priority {pri}") - console.print(f" {_escape_markup(str(pack['description']))}") - tags = pack.get("tags", []) - if isinstance(tags, list) and tags: - tags_str = _escape_markup(", ".join(str(t) for t in tags)) - console.print(f" [dim]Tags: {tags_str}[/dim]") - console.print(f" [dim]Templates: {pack['template_count']}[/dim]") - console.print() - - console.print("[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]") - - -@preset_app.command("add") -def preset_add( - preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), - from_url: str = typer.Option( - None, - "--from", - help="Install from a .zip, .tar.gz, or .tgz URL", - ), - dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), - priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), -): - """Install a preset.""" - from .. import _locate_bundled_preset, _require_specify_project, get_speckit_version - from . import ( - PresetManager, - PresetCatalog, - PresetError, - PresetValidationError, - PresetCompatibilityError, - ) + return implementation(*args, **kwargs) - project_root = _require_specify_project() - # Validate priority - _validate_priority(priority) - manager = PresetManager(project_root) - speckit_version = get_speckit_version() - - try: - if dev: - dev_path = Path(dev).resolve() - if not dev_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {dev}") - raise typer.Exit(1) - - console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") - manifest = manager.install_from_directory(dev_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - - elif from_url: - # Validate URL scheme before downloading - from urllib.parse import urlparse as _urlparse - - try: - _parsed = _urlparse(from_url) - _parsed.port - except ValueError: - console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}") - raise typer.Exit(1) - - def _validate_download_redirect(old_url, new_url): - if not is_safe_download_redirect(old_url, new_url): - import urllib.error - - raise urllib.error.URLError( - "redirect target must use HTTPS without entering a local " - "target, or stay within loopback over HTTP" - ) - - if not is_https_or_localhost_http(from_url): - console.print( - "[red]Error:[/red] URL must use HTTPS with a hostname and be " - "a valid URL with a host. HTTP is only allowed for localhost, " - "127.0.0.1, and ::1." - ) - raise typer.Exit(1) - - console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...") - import urllib.error - import tempfile - - with tempfile.TemporaryDirectory() as tmpdir: - archive_path = Path(tmpdir) / "preset.archive" - try: - from specify_cli.authentication.http import open_url as _open_url - from specify_cli.authentication.http import github_provider_hosts - from specify_cli._github_http import resolve_github_release_asset_api_url - - _preset_extra_headers = None - _resolved_from_url = resolve_github_release_asset_api_url( - from_url, _open_url, github_hosts=github_provider_hosts() - ) - if _resolved_from_url: - from_url = _resolved_from_url - _preset_extra_headers = {"Accept": "application/octet-stream"} - - with _open_url( - from_url, - timeout=60, - extra_headers=_preset_extra_headers, - redirect_validator=_validate_download_redirect, - ) as response: - final_url = response.geturl() if hasattr(response, "geturl") else from_url - if not is_https_or_localhost_http(final_url): - console.print( - "[red]Error:[/red] Preset URL redirected to a disallowed URL: " - f"{final_url}. Redirect targets must use HTTPS with a hostname, " - "or HTTP for localhost (127.0.0.1, ::1)." - ) - raise typer.Exit(1) - archive_data = read_response_limited( - response, - error_type=PresetError, - label=f"preset {from_url}", - ) - content_type = ( - response.getheader("Content-Type") - if hasattr(response, "getheader") - else None - ) - archive_path.write_bytes(archive_data) - format_source = ( - final_url - if archive_format_from_name(final_url) is not None - else from_url - ) - archive_format = detect_archive_format( - archive_path, - source_name=format_source, - content_type=content_type, - error_type=PresetError, - ) - detected_path = archive_path.with_suffix( - archive_suffix(archive_format) - ) - os.replace(archive_path, detected_path) - archive_path = detected_path - except (urllib.error.URLError, PresetError) as e: - console.print( - f"[red]Error:[/red] Failed to download: " - f"{_escape_markup(str(e))}" - ) - raise typer.Exit(1) - - manifest = manager.install_from_zip( - archive_path, - speckit_version, - priority, - ) - - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - - elif preset_id: - # Try bundled preset first, then catalog - bundled_path = _locate_bundled_preset(preset_id) - if bundled_path: - console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") - manifest = manager.install_from_directory(bundled_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - else: - catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(preset_id) - - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") - raise typer.Exit(1) - - # Bundled presets should have been caught above; if we reach - # here the bundled files are missing from the installation. - if pack_info.get("bundled") and not pack_info.get("download_url"): - from ..extensions import REINSTALL_COMMAND - console.print( - f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " - f"but could not be found in the installed package." - ) - console.print( - "\nThis usually means the spec-kit installation is incomplete or corrupted." - ) - console.print("Try reinstalling spec-kit:") - console.print(f" {REINSTALL_COMMAND}") - raise typer.Exit(1) - - if not pack_info.get("_install_allowed", True): - catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") - console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") - raise typer.Exit(1) - - console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") - - try: - archive_path = catalog.download_pack(preset_id) - manifest = manager.install_from_zip( - 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: - if 'archive_path' in locals() and archive_path.exists(): - archive_path.unlink(missing_ok=True) - else: - console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") - raise typer.Exit(1) - - # Every install path above binds `manifest` and the no-source branch - # exits, so one call here covers --dev, --from, and catalog installs - # alike. Warns rather than fails: the preset is installed and its - # overrides fall through to the core workflow without the extension. - _warn_unmet_extension_dependencies(manager, manifest) - - except PresetCompatibilityError as e: - console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - except PresetValidationError as e: - console.print(f"[red]Validation Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - except PresetError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) +def preset_update(*args, **kwargs): + """Preserve the legacy update-handler import path for external consumers.""" + from .command_update import preset_update as implementation - -@preset_app.command("remove") -def preset_remove( - preset_id: str = typer.Argument(..., help="Preset ID to remove"), -): - """Remove an installed preset.""" - from .. import _require_specify_project - from . import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - if manager.remove(preset_id): - console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") - else: - console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") - raise typer.Exit(1) - - -@preset_app.command("update") -def preset_update( - preset_id: str = typer.Argument(..., help="Installed preset ID to replace"), - from_url: str = typer.Option( - None, - "--from", - help="Install the replacement from a .zip, .tar.gz, or .tgz URL", - ), - dev: str = typer.Option( - None, - "--dev", - help="Install the replacement from a local directory (development mode)", - ), - priority: int = typer.Option( - 10, - "--priority", - help="Resolution priority for the replacement (default 10)", - ), -): - """Replace an installed preset using the normal remove and add flows.""" - from .. import _require_specify_project - from . import PresetManager - - if from_url is not None and dev is not None: - console.print("[red]Error:[/red] --from and --dev are mutually exclusive") - raise typer.Exit(1) - if from_url == "": - console.print("[red]Error:[/red] --from must not be empty") - raise typer.Exit(1) - if dev == "": - console.print("[red]Error:[/red] --dev must not be empty") - raise typer.Exit(1) - - # Validate priority before removal. add rejects the same range, but only - # after remove has already run, which would leave the preset removed and - # the printed retry command carrying the rejected priority. - _validate_priority(priority) - - project_root = _require_specify_project() - manager = PresetManager(project_root) - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Keep update deliberately destructive: remove performs its complete normal - # reconciliation before add resolves and installs the replacement. - preset_remove(preset_id) - - retry_args = ["specify", "preset", "add"] - retry_options = [] - if from_url is not None: - retry_options.extend(["--from", from_url]) - if dev is not None: - retry_options.extend(["--dev", dev]) - retry_options.extend(["--priority", str(priority)]) - if preset_id.startswith("-"): - retry_args.extend([*retry_options, "--", preset_id]) - else: - retry_args.extend([preset_id, *retry_options]) - - def report_add_failure() -> None: - if os.name == "nt": - retry_label = "Retry in PowerShell: " - rendered_args = _render_powershell_argv(retry_args) - else: - retry_label = "Retry with: " - rendered_args = shlex.join(retry_args) - console.print( - "[red]Error:[/red] Preset update failed; the previous preset was removed." - ) - console.print( - f"{retry_label}[cyan]" - f"{_escape_markup(rendered_args)}" - "[/cyan]", - soft_wrap=True, - ) - - try: - preset_add( - preset_id=preset_id, - from_url=from_url, - dev=dev, - priority=priority, - ) - except typer.Exit as error: - report_add_failure() - raise typer.Exit(error.exit_code or 1) - except Exception as error: - console.print(f"[red]Error:[/red] {_escape_markup(str(error))}") - report_add_failure() - raise typer.Exit(1) - - -@preset_app.command("search") -def preset_search( - query: str = typer.Argument(None, help="Search query"), - tag: str = typer.Option(None, "--tag", help="Filter by tag"), - author: str = typer.Option(None, "--author", help="Filter by author"), -): - """Search for presets in the catalog.""" - from .. import _require_specify_project - from . import PresetCatalog, PresetError - - project_root = _require_specify_project() - catalog = PresetCatalog(project_root) - - try: - results = catalog.search(query=query, tag=tag, author=author) - except PresetError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - if not results: - console.print("[yellow]No presets found matching your criteria.[/yellow]") - return - - console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") - for pack in results: - name = _escape_markup(str(pack.get("name", pack["id"]))) - pack_id = _escape_markup(str(pack["id"])) - version = _escape_markup(str(pack.get("version", "?"))) - console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}") - console.print( - f" {_escape_markup(str(pack.get('description', '')))}" - ) - tags = pack.get("tags", []) - if isinstance(tags, list) and tags: - tags_str = _escape_markup(", ".join(str(t) for t in tags)) - console.print(f" [dim]Tags: {tags_str}[/dim]") - console.print() - - -@preset_app.command("resolve") -def preset_resolve( - template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), -): - """Show which template will be resolved for a given name.""" - from .. import _require_specify_project - from . import PresetResolver - - is_command = "." in template_name - valid_name = ( - re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name) - if is_command - else re.fullmatch(r"[a-z0-9-]+", template_name) - ) - if valid_name is None: - typer.echo( - f"Error: invalid template name '{template_name}'; " - "use lowercase letters, digits, and hyphens, with non-empty " - "dot-separated segments for commands", - err=True, - ) - raise typer.Exit(1) - - project_root = _require_specify_project() - resolver = PresetResolver(project_root) - template_type = "command" if is_command else "template" - - layers = resolver.collect_all_layers(template_name, template_type) - safe_template_name = _escape_markup(str(template_name)) - - if layers: - # Use the highest-priority layer for display because the final output - # may be composed and may not map to resolve_with_source()'s single path. - display_layer = layers[0] - console.print( - f" [bold]{safe_template_name}[/bold]: " - f"{_escape_markup(str(display_layer['path']))}" - ) - console.print( - f" [dim](top layer from: " - f"{_escape_markup(str(display_layer['source']))})[/dim]" - ) - - has_composition = ( - layers[0]["strategy"] != "replace" - and any(layer["strategy"] != "replace" for layer in layers) - ) - if has_composition: - # Verify composition is actually possible - try: - composed = resolver.resolve_content(template_name, template_type) - except Exception as exc: - composed = None - console.print( - f" [yellow]Warning: composition error: " - f"{_escape_markup(str(exc))}[/yellow]" - ) - if composed is None: - console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") - else: - console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") - console.print("\n [bold]Composition chain:[/bold]") - # Compute the effective base: first replace layer scanning from - # highest priority (matching resolve_content top-down logic). - # Only show layers from the base upward (lower layers are ignored). - effective_base_idx = None - for idx, lyr in enumerate(layers): - if lyr["strategy"] == "replace": - effective_base_idx = idx - break - # Show only contributing layers (base and above) - if effective_base_idx is not None: - contributing = layers[:effective_base_idx + 1] - else: - contributing = layers - for i, layer in enumerate(reversed(contributing)): - strategy_label = layer["strategy"] - if strategy_label == "replace" and i == 0: - strategy_label = "base" - # Escape the literal bracket (\[) so Rich renders `[]` - # instead of parsing it as a style tag and swallowing the label, - # mirroring `workflow info`'s step-graph line. - console.print( - f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] " - f"{_escape_markup(str(layer['source']))} → " - f"{_escape_markup(str(layer['path']))}" - ) - else: - # No layers found — fall back to resolve_with_source for non-composition cases - result = resolver.resolve_with_source(template_name, template_type) - if result: - console.print( - f" [bold]{safe_template_name}[/bold]: " - f"{_escape_markup(str(result['path']))}" - ) - console.print( - f" [dim](from: {_escape_markup(str(result['source']))})[/dim]" - ) - else: - console.print(f" [yellow]{safe_template_name}[/yellow]: not found") - console.print(" [dim]No template with this name exists in the resolution stack[/dim]") - - -@preset_app.command("info") -def preset_info( - preset_id: str = typer.Argument(..., help="Preset ID to get info about"), -): - """Show detailed information about a preset.""" - from .. import _require_specify_project - from ..extensions import normalize_priority - from . import PresetCatalog, PresetManager, PresetError - - project_root = _require_specify_project() - safe_preset_id = _escape_markup(str(preset_id)) - # Check if installed locally first - manager = PresetManager(project_root) - local_pack = manager.get_pack(preset_id) - - if local_pack: - console.print( - f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n" - ) - console.print(f" ID: {_escape_markup(str(local_pack.id))}") - console.print(f" Version: {_escape_markup(str(local_pack.version))}") - console.print( - f" Description: {_escape_markup(str(local_pack.description))}" - ) - if local_pack.author: - console.print(f" Author: {_escape_markup(str(local_pack.author))}") - local_tags = local_pack.tags - if isinstance(local_tags, list) and local_tags: - tags_str = _escape_markup(", ".join(str(t) for t in local_tags)) - console.print(f" Tags: {tags_str}") - console.print(f" Templates: {len(local_pack.templates)}") - for tmpl in local_pack.templates: - tmpl_name = _escape_markup(str(tmpl['name'])) - tmpl_type = _escape_markup(str(tmpl['type'])) - tmpl_desc = _escape_markup(str(tmpl.get('description', ''))) - console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}") - repo = local_pack.data.get("preset", {}).get("repository") - if repo: - console.print(f" Repository: {_escape_markup(str(repo))}") - license_val = local_pack.data.get("preset", {}).get("license") - if license_val: - console.print(f" License: {_escape_markup(str(license_val))}") - console.print("\n [green]Status: installed[/green]") - # Get priority from registry - pack_metadata = manager.registry.get(preset_id) - priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) - console.print(f" [dim]Priority:[/dim] {priority}") - console.print() - return - - # Fall back to catalog - catalog = PresetCatalog(project_root) - try: - pack_info = catalog.get_pack_info(preset_id) - except PresetError: - pack_info = None - - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") - raise typer.Exit(1) - - name = _escape_markup(str(pack_info.get("name", preset_id))) - console.print(f"\n[bold cyan]Preset: {name}[/bold cyan]\n") - console.print(f" ID: {_escape_markup(str(pack_info['id']))}") - console.print( - f" Version: {_escape_markup(str(pack_info.get('version', '?')))}" - ) - console.print( - f" Description: {_escape_markup(str(pack_info.get('description', '')))}" - ) - if pack_info.get("author"): - console.print( - f" Author: {_escape_markup(str(pack_info['author']))}" - ) - catalog_tags = pack_info.get("tags", []) - if isinstance(catalog_tags, list) and catalog_tags: - catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags)) - console.print(f" Tags: {catalog_tags_str}") - if pack_info.get("repository"): - console.print( - f" Repository: {_escape_markup(str(pack_info['repository']))}" - ) - if pack_info.get("license"): - console.print( - f" License: {_escape_markup(str(pack_info['license']))}" - ) - console.print("\n [yellow]Status: not installed[/yellow]") - console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]") - console.print() - - -@preset_app.command("set-priority") -def preset_set_priority( - preset_id: str = typer.Argument(help="Preset ID"), - priority: int = typer.Argument(help="New priority (lower = higher precedence)"), -): - """Set the resolution priority of an installed preset.""" - from .. import _require_specify_project - from . import PresetManager - - project_root = _require_specify_project() - # Validate priority - _validate_priority(priority) - - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - from ..extensions import normalize_priority - raw_priority = metadata.get("priority") - # Only skip if the stored value is already a valid int equal to requested priority - # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) - # A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly — - # mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority - # equals 1/0 here and is never repaired. - if ( - isinstance(raw_priority, int) - and not isinstance(raw_priority, bool) - and raw_priority == priority - ): - console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") - raise typer.Exit(0) - - old_priority = normalize_priority(raw_priority) - - # Update priority - manager.registry.update(preset_id, {"priority": priority}) - manager.reconcile_constitution( - f"Failed to reconcile constitution after changing priority for preset {preset_id}" - ) - - console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") - console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") - - -@preset_app.command("enable") -def preset_enable( - preset_id: str = typer.Argument(help="Preset ID to enable"), -): - """Enable a disabled preset.""" - from .. import _require_specify_project - from . import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if metadata.get("enabled", True): - console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") - raise typer.Exit(0) - - # Enable the preset - manager.registry.update(preset_id, {"enabled": True}) - manager.reconcile_constitution( - f"Failed to reconcile constitution after enabling preset {preset_id}" - ) - - console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") - console.print("\nTemplates from this preset will now be included in resolution.") - console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") - - -@preset_app.command("disable") -def preset_disable( - preset_id: str = typer.Argument(help="Preset ID to disable"), -): - """Disable a preset without removing it.""" - from .. import _require_specify_project - from . import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if not metadata.get("enabled", True): - console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") - raise typer.Exit(0) - - # Disable the preset - manager.registry.update(preset_id, {"enabled": False}) - manager.reconcile_constitution( - f"Failed to reconcile constitution after disabling preset {preset_id}" - ) - - console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") - console.print("\nTemplates from this preset will be skipped during resolution.") - console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") - console.print(f"To re-enable: specify preset enable {preset_id}") - - -# ===== Preset Catalog Commands ===== - - -@preset_catalog_app.command("list") -def preset_catalog_list(): - """List all active preset catalogs.""" - from .. import _display_project_path, _require_specify_project - from . import PresetCatalog, PresetValidationError - - project_root = _require_specify_project() - catalog = PresetCatalog(project_root) - - try: - active_catalogs = catalog.get_active_catalogs() - except PresetValidationError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") - for entry in active_catalogs: - install_str = ( - "[green]install allowed[/green]" - if entry.install_allowed - else "[yellow]discovery only[/yellow]" - ) - console.print(f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})") - if entry.description: - console.print(f" {_escape_markup(str(entry.description))}") - console.print(f" URL: {_escape_markup(str(entry.url))}") - console.print(f" Install: {install_str}") - console.print() - - config_path = project_root / ".specify" / "preset-catalogs.yml" - user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" - if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): - console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") - else: - try: - proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None - except PresetValidationError: - proj_loaded = False - if proj_loaded: - console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") - else: - try: - user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None - except PresetValidationError: - user_loaded = False - if user_loaded: - console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") - else: - console.print("[dim]Using built-in default catalog stack.[/dim]") - console.print( - "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" - ) - - -@preset_catalog_app.command("add") -def preset_catalog_add( - url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), - name: str = typer.Option(..., "--name", help="Catalog name"), - priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), - install_allowed: bool = typer.Option( - False, "--install-allowed/--no-install-allowed", - help="Allow presets from this catalog to be installed", - ), - description: str = typer.Option("", "--description", help="Description of the catalog"), -): - """Add a catalog to .specify/preset-catalogs.yml.""" - from .. import _display_project_path, _require_specify_project - from . import PresetCatalog, PresetValidationError - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - # Validate URL - tmp_catalog = PresetCatalog(project_root) - try: - tmp_catalog._validate_catalog_url(url) - except PresetValidationError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - config_path = specify_dir / "preset-catalogs.yml" - - # Load existing config - if config_path.exists(): - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except Exception as e: - config_label = _display_project_path(project_root, config_path) - console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}") - raise typer.Exit(1) - if config is None: - config = {} - elif not isinstance(config, dict): - console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") - raise typer.Exit(1) - else: - config = {} - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - - # Only rendering is escaped — the raw values are what get persisted and - # compared below, so a name containing markup still round-trips exactly. - safe_name = _escape_markup(str(name)) - safe_url = _escape_markup(str(url)) - - # Check for duplicate name - 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.") - console.print("Use 'specify preset catalog remove' first, or choose a different name.") - raise typer.Exit(1) - - catalogs.append({ - "name": name, - "url": url, - "priority": priority, - "install_allowed": install_allowed, - "description": description, - }) - - config["catalogs"] = catalogs - config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - install_label = "install allowed" if install_allowed else "discovery only" - console.print(f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})") - console.print(f" URL: {safe_url}") - console.print(f" Priority: {priority}") - config_label = _escape_markup(str(_display_project_path(project_root, config_path))) - console.print(f"\nConfig saved to {config_label}") - - -@preset_catalog_app.command("remove") -def preset_catalog_remove( - name: str = typer.Argument(help="Catalog name to remove"), -): - """Remove a catalog from .specify/preset-catalogs.yml.""" - from .. import _require_specify_project - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - config_path = specify_dir / "preset-catalogs.yml" - if not config_path.exists(): - console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") - raise typer.Exit(1) - - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except Exception as e: - console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}") - raise typer.Exit(1) - if config is None: - config = {} - elif not isinstance(config, dict): - console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") - raise typer.Exit(1) - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - # Rendering only — the raw name drives the comparison below. - safe_name = _escape_markup(str(name)) - - original_count = len(catalogs) - catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] - - if len(catalogs) == original_count: - console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.") - raise typer.Exit(1) - - config["catalogs"] = catalogs - config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - console.print(f"[green]✓[/green] Removed catalog '{safe_name}'") - if not catalogs: - console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + return implementation(*args, **kwargs) def register(app: typer.Typer) -> None: - """Attach the preset command group to the root Typer app.""" + """Register preset commands on the parent application.""" + # Imports are intentionally ordered to preserve command help output. + from . import command_list as _command_list + from . import command_add as _command_add + from . import command_remove as _command_remove + from . import command_update as _command_update + from . import command_search as _command_search + from . import command_resolve as _command_resolve + from . import command_info as _command_info + from . import command_set_priority as _command_set_priority + from . import command_enable as _command_enable + from . import command_disable as _command_disable + from .catalog import register as register_catalog + + _ = ( + _command_list, + _command_add, + _command_remove, + _command_update, + _command_search, + _command_resolve, + _command_info, + _command_set_priority, + _command_enable, + _command_disable, + ) + register_catalog(preset_app) app.add_typer(preset_app, name="preset") diff --git a/src/specify_cli/presets/catalog/__init__.py b/src/specify_cli/presets/catalog/__init__.py new file mode 100644 index 0000000000..e893a79c55 --- /dev/null +++ b/src/specify_cli/presets/catalog/__init__.py @@ -0,0 +1,25 @@ +"""Registration for the nested ``specify preset catalog`` command group. + +Command handlers live in ``command_*.py`` modules. +""" + +from __future__ import annotations + +import typer + +catalog_app = typer.Typer( + name="catalog", + help="Manage preset catalogs", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the catalog command group to the preset Typer app.""" + # isort: off + from . import command_list # noqa: F401 — registers handler via decorator + from . import command_add # noqa: F401 — registers handler via decorator + from . import command_remove # noqa: F401 — registers handler via decorator + # isort: on + + app.add_typer(catalog_app, name="catalog") diff --git a/src/specify_cli/presets/catalog/command_add.py b/src/specify_cli/presets/catalog/command_add.py new file mode 100644 index 0000000000..eedf01275b --- /dev/null +++ b/src/specify_cli/presets/catalog/command_add.py @@ -0,0 +1,114 @@ +"""Implementation of the ``specify preset catalog add`` command.""" + +from __future__ import annotations + +import typer +import yaml +from rich.markup import escape as _escape_markup + +from ..._console import console +from . import catalog_app + + +@catalog_app.command("add") +def preset_catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option( + 10, "--priority", help="Priority (lower = higher priority)" + ), + install_allowed: bool = typer.Option( + False, + "--install-allowed/--no-install-allowed", + help="Allow presets from this catalog to be installed", + ), + description: str = typer.Option( + "", "--description", help="Description of the catalog" + ), +): + """Add a catalog to .specify/preset-catalogs.yml.""" + from ... import _display_project_path, _require_specify_project + from .. import PresetCatalog, PresetValidationError + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + # Validate URL + tmp_catalog = PresetCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 - preserve CLI error boundary + config_label = _display_project_path(project_root, config_path) + console.print( + f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}" + ) + raise typer.Exit(1) + if config is None: + config = {} + elif not isinstance(config, dict): + console.print( + "[red]Error:[/red] Invalid catalog config: expected a mapping." + ) + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print( + "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list." + ) + raise typer.Exit(1) + + # Only rendering is escaped — the raw values are what get persisted and + # compared below, so a name containing markup still round-trips exactly. + safe_name = _escape_markup(str(name)) + safe_url = _escape_markup(str(url)) + + # Check for duplicate name + 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." + ) + console.print( + "Use 'specify preset catalog remove' first, or choose a different name." + ) + raise typer.Exit(1) + + catalogs.append( + { + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + } + ) + + config["catalogs"] = catalogs + config_path.write_text( + yaml.safe_dump( + config, default_flow_style=False, sort_keys=False, allow_unicode=True + ), + encoding="utf-8", + ) + + install_label = "install allowed" if install_allowed else "discovery only" + console.print( + f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})" + ) + console.print(f" URL: {safe_url}") + console.print(f" Priority: {priority}") + config_label = _escape_markup(str(_display_project_path(project_root, config_path))) + console.print(f"\nConfig saved to {config_label}") diff --git a/src/specify_cli/presets/catalog/command_list.py b/src/specify_cli/presets/catalog/command_list.py new file mode 100644 index 0000000000..1a5c4764f5 --- /dev/null +++ b/src/specify_cli/presets/catalog/command_list.py @@ -0,0 +1,78 @@ +"""Implementation of the ``specify preset catalog list`` command.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from ..._console import console +from . import catalog_app + + +@catalog_app.command("list") +def preset_catalog_list(): + """List all active preset catalogs.""" + from ... import _display_project_path, _require_specify_project + from .. import PresetCatalog, PresetValidationError + + project_root = _require_specify_project() + catalog = PresetCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print( + f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})" + ) + if entry.description: + console.print(f" {_escape_markup(str(entry.description))}") + console.print(f" URL: {_escape_markup(str(entry.url))}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "preset-catalogs.yml" + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + console.print( + "[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]" + ) + else: + try: + proj_loaded = ( + config_path.exists() + and catalog._load_catalog_config(config_path) is not None + ) + except PresetValidationError: + proj_loaded = False + if proj_loaded: + console.print( + f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]" + ) + else: + try: + user_loaded = ( + user_config_path.exists() + and catalog._load_catalog_config(user_config_path) is not None + ) + except PresetValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" + ) diff --git a/src/specify_cli/presets/catalog/command_remove.py b/src/specify_cli/presets/catalog/command_remove.py new file mode 100644 index 0000000000..2ee2e85860 --- /dev/null +++ b/src/specify_cli/presets/catalog/command_remove.py @@ -0,0 +1,69 @@ +"""Implementation of the ``specify preset catalog remove`` command.""" + +from __future__ import annotations + +import typer +import yaml +from rich.markup import escape as _escape_markup + +from ..._console import console +from . import catalog_app + + +@catalog_app.command("remove") +def preset_catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/preset-catalogs.yml.""" + from ... import _require_specify_project + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + config_path = specify_dir / "preset-catalogs.yml" + if not config_path.exists(): + console.print( + "[red]Error:[/red] No preset catalog config found. Nothing to remove." + ) + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 - preserve CLI error boundary + console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}") + raise typer.Exit(1) + if config is None: + config = {} + elif not isinstance(config, dict): + console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print( + "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list." + ) + raise typer.Exit(1) + # Rendering only — the raw name drives the comparison below. + safe_name = _escape_markup(str(name)) + + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text( + yaml.safe_dump( + config, default_flow_style=False, sort_keys=False, allow_unicode=True + ), + encoding="utf-8", + ) + + console.print(f"[green]✓[/green] Removed catalog '{safe_name}'") + if not catalogs: + console.print( + "\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]" + ) diff --git a/src/specify_cli/presets/command_add.py b/src/specify_cli/presets/command_add.py new file mode 100644 index 0000000000..fae04d0558 --- /dev/null +++ b/src/specify_cli/presets/command_add.py @@ -0,0 +1,381 @@ +"""Implementation of the ``specify preset add`` command.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from .._download_security import ( + archive_format_from_name, + archive_suffix, + detect_archive_format, + is_https_or_localhost_http, + is_safe_download_redirect, +) +from . import _commands +from ._commands import preset_app + + +def _warn_unmet_extension_dependencies(manager, manifest) -> None: + """Warn when a preset's declared extension dependencies are unsatisfied. + + A preset whose command overrides call into an extension is inert without + it, but the overrides still fall through to the core workflow, so nothing + breaks -- it just silently does less than the user expects. Naming the + missing extension and the command that installs it turns that silence into + something actionable. See issue #4231. + """ + from ..extensions._commands import _command_safe_id + + unmet = manager.find_unmet_extension_dependencies(manifest) + if not unmet: + return + + console.print() + console.print( + "[yellow]![/yellow] This preset depends on extensions that are not satisfied:" + ) + needs_catalog = False + for dep in unmet: + uses_catalog = False + extension_id = _escape_markup(dep["id"]) + # The displayed id only needs Rich escaping, but a suggested command + # has to survive Typer's parser: `^[a-z0-9-]+$` admits a leading + # hyphen, so an id like `--force` would render as an option rather + # than the positional argument. _command_safe_id substitutes a + # placeholder in that case, the same way extension commands do. + command_id = _command_safe_id(dep["id"]) + reason = dep["reason"] + # The remediation has to match the reason. `extension add` refuses an + # already-installed extension without --force, and `extension update` + # only moves forward to the catalog release. A general PEP 440 + # constraint may require an exact version, an upper bound, or a + # downgrade, so do not promise that update will satisfy it. + if reason == "missing": + console.print(f" [yellow]{extension_id}[/yellow] is not installed") + label, remedy = "Install with", f"specify extension add {command_id}" + uses_catalog = True + elif reason == "corrupt": + console.print( + f" [yellow]{extension_id}[/yellow] has an unreadable registry entry" + ) + # is_installed() still counts the key, so a plain add is refused. + label = "Reinstall with" + remedy = f"specify extension add {command_id} --force" + uses_catalog = True + elif reason == "stale": + console.print( + f" [yellow]{extension_id}[/yellow] is registered but its " + "files are missing" + ) + label = "Reinstall with" + remedy = f"specify extension add {command_id} --force" + uses_catalog = True + elif reason == "disabled": + console.print( + f" [yellow]{extension_id}[/yellow] is installed but disabled" + ) + label, remedy = "Enable with", f"specify extension enable {command_id}" + else: + console.print( + f" [yellow]{extension_id}[/yellow] " + f"{_escape_markup(dep['installed'])} does not satisfy " + f"{_escape_markup(dep['version'])}" + ) + label = "Needs" + remedy = ( + f"a release of {command_id} satisfying {_escape_markup(dep['version'])}" + ) + console.print(f" {label}: {remedy}") + needs_catalog = needs_catalog or uses_catalog + console.print() + # The consequence differs by reason and must not be overstated. An + # unavailable extension contributes nothing, so those features are simply + # inert. A version mismatch is the opposite: the extension is installed and + # enabled, so the preset does invoke it -- the combination is just untested + # against the declared constraint, which is not the same as "safe". + console.print("[dim]The preset is installed.[/dim]") + if any( + dep["reason"] in ("missing", "corrupt", "stale", "disabled") for dep in unmet + ): + console.print( + "[dim]Anything relying on an unavailable extension does nothing " + "until that is resolved.[/dim]" + ) + if any(dep["reason"] == "version" for dep in unmet): + console.print( + "[dim]Where only a version constraint is unmet the extension is " + "still used, so it may not behave as the preset expects.[/dim]" + ) + if needs_catalog: + # `extension add ` resolves through the catalogs, and the default + # community catalog is discovery-only, so installing by id is refused + # for anything listed only there -- true of every extension motivating + # this feature. Knowing which applies would mean a catalog fetch, and + # this runs on an install path that touches no network, so describe + # the outcome instead of asserting the command succeeds. The rejection + # itself prints the exact --from form, so this is a signpost rather + # than a dead end. + console.print( + "[dim]If an extension is listed only in a discovery-only catalog, " + "that command is refused and prints the " + "--from form to use instead.[/dim]" + ) + + +# ===== Preset Commands ===== + + +@preset_app.command("add") +def preset_add( + preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + from_url: str = typer.Option( + None, + "--from", + help="Install from a .zip, .tar.gz, or .tgz URL", + ), + dev: str = typer.Option( + None, "--dev", help="Install from local directory (development mode)" + ), + priority: int = typer.Option( + 10, + "--priority", + help="Resolution priority (lower = higher precedence, default 10)", + ), +): + """Install a preset.""" + from .. import _locate_bundled_preset, _require_specify_project, get_speckit_version + from . import ( + PresetCatalog, + PresetCompatibilityError, + PresetError, + PresetManager, + PresetValidationError, + ) + + project_root = _require_specify_project() + _commands._validate_priority(priority) + + manager = PresetManager(project_root) + speckit_version = get_speckit_version() + + try: + if dev: + dev_path = Path(dev).resolve() + if not dev_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {dev}") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") + manifest = manager.install_from_directory( + dev_path, speckit_version, priority + ) + console.print( + f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})" + ) + + elif from_url: + # Validate URL scheme before downloading + from urllib.parse import urlparse as _urlparse + + try: + _parsed = _urlparse(from_url) + _ = _parsed.port + except ValueError: + console.print( + f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}" + ) + raise typer.Exit(1) + + def _validate_download_redirect(old_url, new_url): + if not is_safe_download_redirect(old_url, new_url): + import urllib.error + + raise urllib.error.URLError( + "redirect target must use HTTPS without entering a local " + "target, or stay within loopback over HTTP" + ) + + if not is_https_or_localhost_http(from_url): + console.print( + "[red]Error:[/red] URL must use HTTPS with a hostname and be " + "a valid URL with a host. HTTP is only allowed for localhost, " + "127.0.0.1, and ::1." + ) + raise typer.Exit(1) + + console.print( + f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]..." + ) + import tempfile + import urllib.error + + with tempfile.TemporaryDirectory() as tmpdir: + archive_path = Path(tmpdir) / "preset.archive" + try: + from specify_cli._github_http import ( + resolve_github_release_asset_api_url, + ) + from specify_cli.authentication.http import github_provider_hosts + from specify_cli.authentication.http import open_url as _open_url + + _preset_extra_headers = None + _resolved_from_url = resolve_github_release_asset_api_url( + from_url, _open_url, github_hosts=github_provider_hosts() + ) + if _resolved_from_url: + from_url = _resolved_from_url + _preset_extra_headers = {"Accept": "application/octet-stream"} + + with _open_url( + from_url, + timeout=60, + extra_headers=_preset_extra_headers, + redirect_validator=_validate_download_redirect, + ) as response: + final_url = ( + response.geturl() + if hasattr(response, "geturl") + else from_url + ) + if not is_https_or_localhost_http(final_url): + console.print( + "[red]Error:[/red] Preset URL redirected to a disallowed URL: " + f"{final_url}. Redirect targets must use HTTPS with a hostname, " + "or HTTP for localhost (127.0.0.1, ::1)." + ) + raise typer.Exit(1) + archive_data = _commands.read_response_limited( + response, + error_type=PresetError, + label=f"preset {from_url}", + ) + content_type = ( + response.getheader("Content-Type") + if hasattr(response, "getheader") + else None + ) + archive_path.write_bytes(archive_data) + format_source = ( + final_url + if archive_format_from_name(final_url) is not None + else from_url + ) + archive_format = detect_archive_format( + archive_path, + source_name=format_source, + content_type=content_type, + error_type=PresetError, + ) + detected_path = archive_path.with_suffix( + archive_suffix(archive_format) + ) + os.replace(archive_path, detected_path) + archive_path = detected_path + except (urllib.error.URLError, PresetError) as e: + console.print( + f"[red]Error:[/red] Failed to download: " + f"{_escape_markup(str(e))}" + ) + raise typer.Exit(1) + + manifest = manager.install_from_zip( + archive_path, + speckit_version, + priority, + ) + + console.print( + f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})" + ) + + elif preset_id: + # Try bundled preset first, then catalog + bundled_path = _locate_bundled_preset(preset_id) + if bundled_path: + console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority + ) + console.print( + f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})" + ) + else: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(preset_id) + + if not pack_info: + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in catalog" + ) + raise typer.Exit(1) + + # Bundled presets should have been caught above; if we reach + # here the bundled files are missing from the installation. + if pack_info.get("bundled") and not pack_info.get("download_url"): + from ..extensions import REINSTALL_COMMAND + + console.print( + f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print( + f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed)." + ) + console.print( + "Add the catalog with --install-allowed or install from the preset's repository directly with --from." + ) + raise typer.Exit(1) + + console.print( + f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]..." + ) + + try: + archive_path = catalog.download_pack(preset_id) + manifest = manager.install_from_zip( + 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: + if "archive_path" in locals() and archive_path.exists(): + archive_path.unlink(missing_ok=True) + else: + console.print( + "[red]Error:[/red] Specify a preset ID, --from URL, or --dev path" + ) + raise typer.Exit(1) + + # Every install path above binds `manifest` and the no-source branch + # exits, so one call here covers --dev, --from, and catalog installs + # alike. Warns rather than fails: the preset is installed and its + # overrides fall through to the core workflow without the extension. + _commands._warn_unmet_extension_dependencies(manager, manifest) + + except PresetCompatibilityError as e: + console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except PresetValidationError as e: + console.print(f"[red]Validation Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except PresetError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) diff --git a/src/specify_cli/presets/command_disable.py b/src/specify_cli/presets/command_disable.py new file mode 100644 index 0000000000..85c0231abd --- /dev/null +++ b/src/specify_cli/presets/command_disable.py @@ -0,0 +1,50 @@ +"""Implementation of the ``specify preset disable`` command.""" + +from __future__ import annotations + +import typer + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("disable") +def preset_disable( + preset_id: str = typer.Argument(help="Preset ID to disable"), +): + """Disable a preset without removing it.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") + raise typer.Exit(0) + + # Disable the preset + manager.registry.update(preset_id, {"enabled": False}) + manager.reconcile_constitution( + f"Failed to reconcile constitution after disabling preset {preset_id}" + ) + + console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") + console.print("\nTemplates from this preset will be skipped during resolution.") + console.print( + "[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]" + ) + console.print(f"To re-enable: specify preset enable {preset_id}") diff --git a/src/specify_cli/presets/command_enable.py b/src/specify_cli/presets/command_enable.py new file mode 100644 index 0000000000..c3cd3ba5ff --- /dev/null +++ b/src/specify_cli/presets/command_enable.py @@ -0,0 +1,49 @@ +"""Implementation of the ``specify preset enable`` command.""" + +from __future__ import annotations + +import typer + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("enable") +def preset_enable( + preset_id: str = typer.Argument(help="Preset ID to enable"), +): + """Enable a disabled preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") + raise typer.Exit(0) + + # Enable the preset + manager.registry.update(preset_id, {"enabled": True}) + manager.reconcile_constitution( + f"Failed to reconcile constitution after enabling preset {preset_id}" + ) + + console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") + console.print("\nTemplates from this preset will now be included in resolution.") + console.print( + "[dim]Note: Previously registered commands/skills remain active.[/dim]" + ) diff --git a/src/specify_cli/presets/command_info.py b/src/specify_cli/presets/command_info.py new file mode 100644 index 0000000000..a3920a6605 --- /dev/null +++ b/src/specify_cli/presets/command_info.py @@ -0,0 +1,96 @@ +"""Implementation of the ``specify preset info`` command.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("info") +def preset_info( + preset_id: str = typer.Argument(..., help="Preset ID to get info about"), +): + """Show detailed information about a preset.""" + from .. import _require_specify_project + from ..extensions import normalize_priority + from . import PresetCatalog, PresetError, PresetManager + + project_root = _require_specify_project() + safe_preset_id = _escape_markup(str(preset_id)) + # Check if installed locally first + manager = PresetManager(project_root) + local_pack = manager.get_pack(preset_id) + + if local_pack: + console.print( + f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n" + ) + console.print(f" ID: {_escape_markup(str(local_pack.id))}") + console.print(f" Version: {_escape_markup(str(local_pack.version))}") + console.print(f" Description: {_escape_markup(str(local_pack.description))}") + if local_pack.author: + console.print(f" Author: {_escape_markup(str(local_pack.author))}") + local_tags = local_pack.tags + if isinstance(local_tags, list) and local_tags: + tags_str = _escape_markup(", ".join(str(t) for t in local_tags)) + console.print(f" Tags: {tags_str}") + console.print(f" Templates: {len(local_pack.templates)}") + for tmpl in local_pack.templates: + tmpl_name = _escape_markup(str(tmpl["name"])) + tmpl_type = _escape_markup(str(tmpl["type"])) + tmpl_desc = _escape_markup(str(tmpl.get("description", ""))) + console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}") + repo = local_pack.data.get("preset", {}).get("repository") + if repo: + console.print(f" Repository: {_escape_markup(str(repo))}") + license_val = local_pack.data.get("preset", {}).get("license") + if license_val: + console.print(f" License: {_escape_markup(str(license_val))}") + console.print("\n [green]Status: installed[/green]") + # Get priority from registry + pack_metadata = manager.registry.get(preset_id) + priority = normalize_priority( + pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None + ) + console.print(f" [dim]Priority:[/dim] {priority}") + console.print() + return + + # Fall back to catalog + catalog = PresetCatalog(project_root) + try: + pack_info = catalog.get_pack_info(preset_id) + except PresetError: + pack_info = None + + if not pack_info: + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)" + ) + raise typer.Exit(1) + + name = _escape_markup(str(pack_info.get("name", preset_id))) + console.print(f"\n[bold cyan]Preset: {name}[/bold cyan]\n") + console.print(f" ID: {_escape_markup(str(pack_info['id']))}") + console.print( + f" Version: {_escape_markup(str(pack_info.get('version', '?')))}" + ) + console.print( + f" Description: {_escape_markup(str(pack_info.get('description', '')))}" + ) + if pack_info.get("author"): + console.print(f" Author: {_escape_markup(str(pack_info['author']))}") + catalog_tags = pack_info.get("tags", []) + if isinstance(catalog_tags, list) and catalog_tags: + catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags)) + console.print(f" Tags: {catalog_tags_str}") + if pack_info.get("repository"): + console.print(f" Repository: {_escape_markup(str(pack_info['repository']))}") + if pack_info.get("license"): + console.print(f" License: {_escape_markup(str(pack_info['license']))}") + console.print("\n [yellow]Status: not installed[/yellow]") + console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]") + console.print() diff --git a/src/specify_cli/presets/command_list.py b/src/specify_cli/presets/command_list.py new file mode 100644 index 0000000000..f7fd72efe3 --- /dev/null +++ b/src/specify_cli/presets/command_list.py @@ -0,0 +1,89 @@ +"""Implementation of the ``specify preset list`` command.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from .._installed_list_json import ( + InstalledListJSONCommand, + emit_json, + emit_json_error, + installed_list_item, +) +from .._project import resolve_specify_project_root +from ._commands import preset_app + + +@preset_app.command("list", cls=InstalledListJSONCommand) +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: # noqa: BLE001 - emit the JSON error contract + emit_json_error(error) + + project_root = _require_specify_project() + manager = PresetManager(project_root) + installed = manager.list_installed() + + if not installed: + console.print("[yellow]No presets installed.[/yellow]") + console.print("\nInstall a preset with:") + console.print(" [cyan]specify preset add [/cyan]") + return + + # Sort by actual resolution precedence: lower priority number wins, ties + # broken by preset id (matching PresetRegistry.list_by_priority()). This + # keeps the printed order aligned with how presets are composed/resolved. + installed = sorted( + installed, + key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), + ) + + console.print( + "\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n" + ) + for pack in installed: + status = ( + "[green]enabled[/green]" + if pack.get("enabled", True) + else "[red]disabled[/red]" + ) + pri = pack.get("priority", 10) + name = _escape_markup(str(pack["name"])) + pack_id = _escape_markup(str(pack["id"])) + version = _escape_markup(str(pack["version"])) + console.print( + f" [bold]{name}[/bold] ({pack_id}) v{version} — {status} — priority {pri}" + ) + console.print(f" {_escape_markup(str(pack['description']))}") + tags = pack.get("tags", []) + if isinstance(tags, list) and tags: + tags_str = _escape_markup(", ".join(str(t) for t in tags)) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print(f" [dim]Templates: {pack['template_count']}[/dim]") + console.print() + + console.print( + "[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]" + ) diff --git a/src/specify_cli/presets/command_remove.py b/src/specify_cli/presets/command_remove.py new file mode 100644 index 0000000000..3654f1e04f --- /dev/null +++ b/src/specify_cli/presets/command_remove.py @@ -0,0 +1,30 @@ +"""Implementation of the ``specify preset remove`` command.""" + +from __future__ import annotations + +import typer + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("remove") +def preset_remove( + preset_id: str = typer.Argument(..., help="Preset ID to remove"), +): + """Remove an installed preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + if manager.remove(preset_id): + console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") + else: + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") + raise typer.Exit(1) diff --git a/src/specify_cli/presets/command_resolve.py b/src/specify_cli/presets/command_resolve.py new file mode 100644 index 0000000000..7256307160 --- /dev/null +++ b/src/specify_cli/presets/command_resolve.py @@ -0,0 +1,121 @@ +"""Implementation of the ``specify preset resolve`` command.""" + +from __future__ import annotations + +import re + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("resolve") +def preset_resolve( + template_name: str = typer.Argument( + ..., help="Template name to resolve (e.g., spec-template)" + ), +): + """Show which template will be resolved for a given name.""" + from .. import _require_specify_project + from . import PresetResolver + + is_command = "." in template_name + valid_name = ( + re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name) + if is_command + else re.fullmatch(r"[a-z0-9-]+", template_name) + ) + if valid_name is None: + typer.echo( + f"Error: invalid template name '{template_name}'; " + "use lowercase letters, digits, and hyphens, with non-empty " + "dot-separated segments for commands", + err=True, + ) + raise typer.Exit(1) + + project_root = _require_specify_project() + resolver = PresetResolver(project_root) + template_type = "command" if is_command else "template" + + layers = resolver.collect_all_layers(template_name, template_type) + safe_template_name = _escape_markup(str(template_name)) + + if layers: + # Use the highest-priority layer for display because the final output + # may be composed and may not map to resolve_with_source()'s single path. + display_layer = layers[0] + console.print( + f" [bold]{safe_template_name}[/bold]: " + f"{_escape_markup(str(display_layer['path']))}" + ) + console.print( + f" [dim](top layer from: " + f"{_escape_markup(str(display_layer['source']))})[/dim]" + ) + + has_composition = layers[0]["strategy"] != "replace" and any( + layer["strategy"] != "replace" for layer in layers + ) + if has_composition: + # Verify composition is actually possible + try: + composed = resolver.resolve_content(template_name, template_type) + except Exception as exc: # noqa: BLE001 - render composition failures + composed = None + console.print( + f" [yellow]Warning: composition error: " + f"{_escape_markup(str(exc))}[/yellow]" + ) + if composed is None: + console.print( + " [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]" + ) + else: + console.print( + " [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]" + ) + console.print("\n [bold]Composition chain:[/bold]") + # Compute the effective base: first replace layer scanning from + # highest priority (matching resolve_content top-down logic). + # Only show layers from the base upward (lower layers are ignored). + effective_base_idx = None + for idx, lyr in enumerate(layers): + if lyr["strategy"] == "replace": + effective_base_idx = idx + break + # Show only contributing layers (base and above) + if effective_base_idx is not None: + contributing = layers[: effective_base_idx + 1] + else: + contributing = layers + for i, layer in enumerate(reversed(contributing)): + strategy_label = layer["strategy"] + if strategy_label == "replace" and i == 0: + strategy_label = "base" + # Escape the literal bracket (\[) so Rich renders `[]` + # instead of parsing it as a style tag and swallowing the label, + # mirroring `workflow info`'s step-graph line. + console.print( + f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] " + f"{_escape_markup(str(layer['source']))} → " + f"{_escape_markup(str(layer['path']))}" + ) + else: + # No layers found — fall back to resolve_with_source for non-composition cases + result = resolver.resolve_with_source(template_name, template_type) + if result: + console.print( + f" [bold]{safe_template_name}[/bold]: " + f"{_escape_markup(str(result['path']))}" + ) + console.print( + f" [dim](from: {_escape_markup(str(result['source']))})[/dim]" + ) + else: + console.print(f" [yellow]{safe_template_name}[/yellow]: not found") + console.print( + " [dim]No template with this name exists in the resolution stack[/dim]" + ) diff --git a/src/specify_cli/presets/command_search.py b/src/specify_cli/presets/command_search.py new file mode 100644 index 0000000000..85216b1cb6 --- /dev/null +++ b/src/specify_cli/presets/command_search.py @@ -0,0 +1,46 @@ +"""Implementation of the ``specify preset search`` command.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("search") +def preset_search( + query: str = typer.Argument(None, help="Search query"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), + author: str = typer.Option(None, "--author", help="Filter by author"), +): + """Search for presets in the catalog.""" + from .. import _require_specify_project + from . import PresetCatalog, PresetError + + project_root = _require_specify_project() + catalog = PresetCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except PresetError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No presets found matching your criteria.[/yellow]") + return + + console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") + for pack in results: + name = _escape_markup(str(pack.get("name", pack["id"]))) + pack_id = _escape_markup(str(pack["id"])) + version = _escape_markup(str(pack.get("version", "?"))) + console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}") + console.print(f" {_escape_markup(str(pack.get('description', '')))}") + tags = pack.get("tags", []) + if isinstance(tags, list) and tags: + tags_str = _escape_markup(", ".join(str(t) for t in tags)) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print() diff --git a/src/specify_cli/presets/command_set_priority.py b/src/specify_cli/presets/command_set_priority.py new file mode 100644 index 0000000000..8337c251fa --- /dev/null +++ b/src/specify_cli/presets/command_set_priority.py @@ -0,0 +1,70 @@ +"""Implementation of the ``specify preset set-priority`` command.""" + +from __future__ import annotations + +import typer + +from .._console import console +from . import _commands +from ._commands import preset_app + + +@preset_app.command("set-priority") +def preset_set_priority( + preset_id: str = typer.Argument(help="Preset ID"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + _commands._validate_priority(priority) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + from ..extensions import normalize_priority + + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + # A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly — + # mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority + # equals 1/0 here and is never repaired. + if ( + isinstance(raw_priority, int) + and not isinstance(raw_priority, bool) + and raw_priority == priority + ): + console.print( + f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]" + ) + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(preset_id, {"priority": priority}) + manager.reconcile_constitution( + f"Failed to reconcile constitution after changing priority for preset {preset_id}" + ) + + console.print( + f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}" + ) + console.print( + "\n[dim]Lower priority = higher precedence in template resolution[/dim]" + ) diff --git a/src/specify_cli/presets/command_update.py b/src/specify_cli/presets/command_update.py new file mode 100644 index 0000000000..cde9b256bb --- /dev/null +++ b/src/specify_cli/presets/command_update.py @@ -0,0 +1,99 @@ +"""Implementation of the ``specify preset update`` command.""" + +from __future__ import annotations + +import os +import shlex + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import _commands +from ._commands import preset_app + + +@preset_app.command("update") +def preset_update( + preset_id: str = typer.Argument(..., help="Installed preset ID to replace"), + from_url: str = typer.Option( + None, + "--from", + help="Install the replacement from a .zip, .tar.gz, or .tgz URL", + ), + dev: str = typer.Option( + None, + "--dev", + help="Install the replacement from a local directory (development mode)", + ), + priority: int = typer.Option( + 10, + "--priority", + help="Resolution priority for the replacement (default 10)", + ), +): + """Replace an installed preset using the normal remove and add flows.""" + from .. import _require_specify_project + from . import PresetManager + + if from_url is not None and dev is not None: + console.print("[red]Error:[/red] --from and --dev are mutually exclusive") + raise typer.Exit(1) + if from_url == "": + console.print("[red]Error:[/red] --from must not be empty") + raise typer.Exit(1) + if dev == "": + console.print("[red]Error:[/red] --dev must not be empty") + raise typer.Exit(1) + + _commands._validate_priority(priority) + + project_root = _require_specify_project() + manager = PresetManager(project_root) + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + _commands.preset_remove(preset_id) + + retry_args = ["specify", "preset", "add"] + retry_options = [] + if from_url is not None: + retry_options.extend(["--from", from_url]) + if dev is not None: + retry_options.extend(["--dev", dev]) + retry_options.extend(["--priority", str(priority)]) + if preset_id.startswith("-"): + retry_args.extend([*retry_options, "--", preset_id]) + else: + retry_args.extend([preset_id, *retry_options]) + + def report_add_failure() -> None: + if os.name == "nt": + retry_label = "Retry in PowerShell: " + rendered_args = _commands._render_powershell_argv(retry_args) + else: + retry_label = "Retry with: " + rendered_args = shlex.join(retry_args) + console.print( + "[red]Error:[/red] Preset update failed; the previous preset was removed." + ) + console.print( + f"{retry_label}[cyan]{_escape_markup(rendered_args)}[/cyan]", + soft_wrap=True, + ) + + try: + _commands.preset_add( + preset_id=preset_id, + from_url=from_url, + dev=dev, + priority=priority, + ) + except typer.Exit as error: + report_add_failure() + raise typer.Exit(error.exit_code or 1) + except Exception as error: + console.print(f"[red]Error:[/red] {_escape_markup(str(error))}") + report_add_failure() + raise typer.Exit(1) diff --git a/tests/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py index 587bf61add..f69b9cde50 100644 --- a/tests/integrations/test_integration_amp.py +++ b/tests/integrations/test_integration_amp.py @@ -64,6 +64,18 @@ def test_build_exec_args_omits_model_flag(self): assert "-m" not in args assert "gpt-5" not in args + def test_build_exec_args_accepts_project_root(self, tmp_path): + """Workflow dispatch may provide a project root to every integration.""" + integration = get_integration(self.KEY) + + args = integration.build_exec_args( + "check the project", + output_json=False, + project_root=tmp_path, + ) + + assert args == ["amp", "--execute", "check the project"] + def test_build_exec_args_applies_extra_args_before_execute(self, monkeypatch): """Operator-injected flags precede `--execute` so they stay global. diff --git a/tests/specify_cli/presets/__init__.py b/tests/specify_cli/presets/__init__.py new file mode 100644 index 0000000000..13f104b1c1 --- /dev/null +++ b/tests/specify_cli/presets/__init__.py @@ -0,0 +1 @@ +"""Tests for the preset CLI command hierarchy.""" diff --git a/tests/specify_cli/presets/_fixtures.py b/tests/specify_cli/presets/_fixtures.py new file mode 100644 index 0000000000..b75c518baf --- /dev/null +++ b/tests/specify_cli/presets/_fixtures.py @@ -0,0 +1,82 @@ +"""Shared pytest fixtures for preset domain and command suites.""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path + +import pytest +import yaml + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def valid_pack_data(): + """Return valid preset manifest data.""" + return { + "schema_version": "1.0", + "preset": { + "id": "test-pack", + "name": "Test Preset", + "version": "1.0.0", + "description": "A test preset", + "author": "Test Author", + "repository": "https://github.com/test/test-pack", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "description": "Custom spec template", + "replaces": "spec-template", + } + ] + }, + "tags": ["testing", "example"], + } + + +@pytest.fixture +def pack_dir(temp_dir, valid_pack_data): + """Create a complete preset directory structure.""" + preset_dir = temp_dir / "test-pack" + preset_dir.mkdir() + (preset_dir / "preset.yml").write_text( + yaml.safe_dump(valid_pack_data), encoding="utf-8" + ) + templates_dir = preset_dir / "templates" + templates_dir.mkdir() + (templates_dir / "spec-template.md").write_text( + "# Custom Spec Template\n\nThis is a custom template.\n", + encoding="utf-8", + ) + return preset_dir + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory.""" + project = temp_dir / "project" + project.mkdir() + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + (templates_dir / "spec-template.md").write_text( + "# Core Spec Template\n", encoding="utf-8" + ) + (templates_dir / "plan-template.md").write_text( + "# Core Plan Template\n", encoding="utf-8" + ) + (templates_dir / "commands").mkdir() + return project diff --git a/tests/specify_cli/presets/_helpers.py b/tests/specify_cli/presets/_helpers.py new file mode 100644 index 0000000000..dc3661c055 --- /dev/null +++ b/tests/specify_cli/presets/_helpers.py @@ -0,0 +1,128 @@ +"""Shared helpers for preset domain and command tests.""" + +from __future__ import annotations + +import json +import warnings +from datetime import UTC, datetime +from pathlib import Path + +import yaml + +from specify_cli.presets import ( + PresetCatalog, + PresetCatalogEntry, + PresetManager, + PresetManifest, +) + +REPO_ROOT = Path(__file__).parents[3] +SELF_TEST_PRESET_DIR = REPO_ROOT / "presets" / "self-test" +CONSTITUTION_SYNC_PRESET_DIR = REPO_ROOT / "presets" / "constitution-sync" +SELF_TEST_WRAP_WARNING = ( + r"Cannot compose command 'speckit\.wrap-test': no base layer\. " + r"Stale command files may remain\." +) + +CORE_TEMPLATE_NAMES = [ + "spec-template", + "plan-template", + "tasks-template", + "checklist-template", + "constitution-template", +] + + +def seed_catalog( + project_dir: Path, + tags: object, + extra: dict[str, object] | None = None, +) -> PresetCatalog: + """Seed cached catalog metadata used by search and info command tests.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + pack = { + "name": "Numeric Tags", + "description": "Preset with non-string tags", + "version": "1.0.0", + "tags": tags, + } + if extra: + pack.update(extra) + catalog.cache_file.write_text( + json.dumps( + { + "schema_version": "1.0", + "presets": {"numeric-tags": pack}, + } + ) + ) + catalog.cache_metadata_file.write_text( + json.dumps({"cached_at": datetime.now(UTC).isoformat()}) + ) + return catalog + + +def default_catalog_entries(catalog: PresetCatalog) -> list[PresetCatalogEntry]: + """Return the default catalog as the only active catalog.""" + return [ + PresetCatalogEntry( + url=catalog.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + ) + ] + + +def install_self_test_preset( + manager: PresetManager, speckit_version: str = "0.1.5" +) -> PresetManifest: + """Install self-test while filtering its intentionally missing wrap base.""" + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=SELF_TEST_WRAP_WARNING, + category=UserWarning, + module=r"specify_cli\.presets", + ) + return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version) + + +def install_constitution_sync_preset(manager: PresetManager) -> PresetManifest: + """Enable guarded install-time constitution materialization.""" + return manager.install_from_directory(CONSTITUTION_SYNC_PRESET_DIR, "0.15.0") + + +def make_convention_constitution_preset(temp_dir: Path) -> Path: + """Create a preset whose constitution is found by convention.""" + preset_dir = temp_dir / "convention-constitution" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text( + "# Convention Constitution\n" + ) + (preset_dir / "templates" / "spec-template.md").write_text("# Spec\n") + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "convention-constitution", + "name": "Convention Constitution", + "version": "1.0.0", + "description": "Convention-based constitution for testing", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + ] + }, + } + ) + ) + return preset_dir diff --git a/tests/specify_cli/presets/catalog/__init__.py b/tests/specify_cli/presets/catalog/__init__.py new file mode 100644 index 0000000000..4774695ccf --- /dev/null +++ b/tests/specify_cli/presets/catalog/__init__.py @@ -0,0 +1 @@ +"""Tests for preset catalog commands.""" diff --git a/tests/specify_cli/presets/catalog/test_command_add.py b/tests/specify_cli/presets/catalog/test_command_add.py new file mode 100644 index 0000000000..1b6fae541a --- /dev/null +++ b/tests/specify_cli/presets/catalog/test_command_add.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + + +class TestPresetCatalogAdd: + """Test multi-catalog support in PresetCatalog.""" + + def test_catalog_add_escapes_rich_markup(self, project_dir): + """`preset catalog add` must not parse the name/url as Rich markup. + + An unbalanced closing tag raised MarkupError *after* the entry was + already written to preset-catalogs.yml, so the user saw a traceback + and no confirmation for a catalog that had in fact been added. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + name = "[/red]my-catalog" + url = "https://example.com/[bold]c.json" + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["preset", "catalog", "add", url, "--name", name] + ) + assert result.exit_code == 0, result.output + # Rendered verbatim, not swallowed as markup. + assert name in result.output + assert url in result.output + # Only rendering is escaped: the raw values still round-trip to disk. + config = yaml.safe_load( + (project_dir / ".specify" / "preset-catalogs.yml").read_text( + encoding="utf-8" + ) + ) + assert config["catalogs"][0]["name"] == name + assert config["catalogs"][0]["url"] == url + + @pytest.mark.parametrize( + "args", + [ + [ + "preset", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "example", + ], + ["preset", "catalog", "remove", "example"], + ], + ) + def test_catalog_mutation_rejects_non_mapping_config_root(self, project_dir, args): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + config_path = project_dir / ".specify" / "preset-catalogs.yml" + original = "[]\n" + config_path.write_text(original, encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke(app, args) + + assert result.exit_code == 1 + assert "expected a mapping" in result.output + assert config_path.read_text(encoding="utf-8") == original diff --git a/tests/specify_cli/presets/catalog/test_command_list.py b/tests/specify_cli/presets/catalog/test_command_list.py new file mode 100644 index 0000000000..21c2aec71f --- /dev/null +++ b/tests/specify_cli/presets/catalog/test_command_list.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +from specify_cli.presets import ( + PresetCatalog, + PresetCatalogEntry, +) + + +class TestPresetCatalogList: + """Test multi-catalog support in PresetCatalog.""" + + def test_catalog_list_escapes_rich_markup(self, project_dir): + """User-editable catalog name/url/description must not be parsed as Rich markup.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + entry = PresetCatalogEntry( + url="https://example.com/[cat].json", + name="Bracket [Catalog]", + priority=1, + install_allowed=True, + description="desc [with] brackets", + ) + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object(PresetCatalog, "get_active_catalogs", return_value=[entry]), + ): + result = runner.invoke(app, ["preset", "catalog", "list"]) + assert result.exit_code == 0, result.output + assert "Bracket [Catalog]" in result.output + assert "https://example.com/[cat].json" in result.output + assert "desc [with] brackets" in result.output diff --git a/tests/specify_cli/presets/catalog/test_command_remove.py b/tests/specify_cli/presets/catalog/test_command_remove.py new file mode 100644 index 0000000000..4466232d2a --- /dev/null +++ b/tests/specify_cli/presets/catalog/test_command_remove.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + + +class TestPresetCatalogRemove: + """Test multi-catalog support in PresetCatalog.""" + + def test_catalog_remove_escapes_rich_markup(self, project_dir): + """`preset catalog remove` must not parse the name as Rich markup.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + name = "[/red]my-catalog" + (project_dir / ".specify" / "preset-catalogs.yml").write_text( + yaml.dump( + { + "catalogs": [ + { + "name": name, + "url": "https://example.com/c.json", + "priority": 1, + "install_allowed": False, + } + ] + } + ), + encoding="utf-8", + ) + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "catalog", "remove", name]) + assert result.exit_code == 0, result.output + assert name in result.output + + def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir): + """The not-found error path renders the name too.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + (project_dir / ".specify" / "preset-catalogs.yml").write_text( + yaml.dump({"catalogs": []}), encoding="utf-8" + ) + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "catalog", "remove", "[/red]absent"]) + assert result.exit_code == 1 + assert "[/red]absent" in result.output diff --git a/tests/specify_cli/presets/conftest.py b/tests/specify_cli/presets/conftest.py new file mode 100644 index 0000000000..e8cdb4f34b --- /dev/null +++ b/tests/specify_cli/presets/conftest.py @@ -0,0 +1,10 @@ +"""Load shared fixtures for mirrored preset command tests.""" + +from __future__ import annotations + +from . import _fixtures + +temp_dir = _fixtures.temp_dir +valid_pack_data = _fixtures.valid_pack_data +pack_dir = _fixtures.pack_dir +project_dir = _fixtures.project_dir diff --git a/tests/specify_cli/presets/test_command_add.py b/tests/specify_cli/presets/test_command_add.py new file mode 100644 index 0000000000..ae46f2bb9d --- /dev/null +++ b/tests/specify_cli/presets/test_command_add.py @@ -0,0 +1,991 @@ +from __future__ import annotations + +import io +import json +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import ANY, MagicMock + +import pytest +import yaml + +from specify_cli._console import console +from specify_cli.presets import ( + PresetCatalog, + PresetCompatibilityError, + PresetError, + PresetManager, + PresetValidationError, +) +from specify_cli.presets._commands import _warn_unmet_extension_dependencies +from tests.conftest import strip_ansi + + +class TestPresetAddDependencyWarnings: + """Test find_unmet_extension_dependencies (issue #4231).""" + + def test_version_warning_does_not_promise_update_satisfies_constraint(self): + """Version remediation must handle constraints update cannot guarantee.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "version", + "installed": "3.0.0", + "version": "<2", + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "Needs: a release of speckit-inventory satisfying <2" in output + assert "specify extension update" not in output + + def test_missing_and_stale_warnings_mention_discovery_only_catalogs(self): + """`extension add ` is rejected for discovery-only entries, so say so.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "missing", + "installed": None, + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "discovery-only catalog" in output + assert "--from " in output + assert "Install with: specify extension add speckit-inventory" in output + + @pytest.mark.parametrize( + "reason, extra", + [ + ("missing", {"installed": None, "version": None}), + ("stale", {"installed": "0.1.0", "version": None}), + ("disabled", {"installed": "0.1.0", "version": None}), + ("version", {"installed": "0.1.0", "version": ">=9.0.0"}), + ], + ) + def test_leading_hyphen_id_is_not_emitted_into_a_command(self, reason, extra): + """A leading-hyphen id satisfies `^[a-z0-9-]+$` but breaks the command. + + Typer would read it as an option rather than the positional extension + argument, so the advertised fix would fail. Every remedy substitutes + the placeholder `_command_safe_id` returns. The id here is deliberately + not a real flag, so a match cannot be confused with `--force` appearing + legitimately in the stale remedy. + """ + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + {"id": "--not-a-real-flag", "reason": reason, **extra} + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + # Isolate the remedy: the description line legitimately shows the raw + # id, escaped for display; only the copyable command must not carry it. + label = next( + lbl + for lbl in ("Install with:", "Reinstall with:", "Enable with:", "Needs:") + if lbl in output + ) + remedy = output.split(label, 1)[1].split("The preset is installed.")[0] + assert "--not-a-real-flag" not in remedy + assert "" in remedy + + def test_version_only_warning_omits_the_discovery_only_note(self): + """The note is about installing by id, which a version mismatch does not do.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "version", + "installed": "0.1.0", + "version": ">=9.0.0", + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + assert "discovery-only" not in strip_ansi(capture.get()) + + def test_corrupt_warning_suggests_forced_reinstall(self): + """The corrupt remedy must use --force, since the id is still registered.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "corrupt", + "installed": None, + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "unreadable registry entry" in output + assert ( + "Reinstall with: specify extension add speckit-inventory --force" in output + ) + + def test_version_only_footer_does_not_claim_the_feature_is_inert(self): + """A version mismatch still invokes the extension, so wording differs.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "version", + "installed": "0.1.0", + "version": ">=9.0.0", + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "may not behave as the preset expects" in output + assert "does nothing" not in output + assert "safe to use" not in output + + def test_unavailable_footer_states_the_feature_is_inert(self): + """An unavailable extension genuinely contributes nothing.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "missing", + "installed": None, + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "does nothing" in output + assert "may not behave as the preset expects" not in output + + def test_stale_warning_suggests_a_forced_reinstall(self): + """The stale remedy must restore the files, not re-add a registered id.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "stale", + "installed": "0.1.0", + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "its files are missing" in output + assert "specify extension add speckit-inventory --force" in output + + +class TestPresetAdd: + """Tests for _locate_bundled_preset discovery function.""" + + def test_bundled_preset_add_via_cli(self, project_dir): + """Test that 'specify preset add lean' installs the bundled preset.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.get_speckit_version", return_value="0.6.0"), + ): + result = runner.invoke(app, ["preset", "add", "lean"]) + + assert result.exit_code == 0, result.output + 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_uses_legacy_dependency_warning_seam( + self, project_dir, pack_dir, monkeypatch + ): + """The extracted handler must honor patches at the legacy helper path.""" + from specify_cli.presets import _commands as preset_commands + + manifest = SimpleNamespace(name="Test Preset", version="1.0.0") + warning = MagicMock() + monkeypatch.setattr( + "specify_cli._require_specify_project", lambda: project_dir + ) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "1.0.0") + monkeypatch.setattr( + PresetManager, + "install_from_directory", + lambda _self, _path, _version, _priority: manifest, + ) + monkeypatch.setattr( + preset_commands, + "_warn_unmet_extension_dependencies", + warning, + ) + + preset_commands.preset_add( + preset_id=None, + from_url=None, + dev=str(pack_dir), + priority=10, + ) + + warning.assert_called_once_with(ANY, manifest) + + 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 + + from specify_cli.presets._commands import preset_add + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "http://example.com/preset.zip" + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + + def fake_open_url( + url, timeout=None, extra_headers=None, redirect_validator=None + ): + assert redirect_validator is not None + redirect_validator(url, "http://example.com/preset.zip") + return FakeResponse(b"zip") + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + installed = False + + def fake_install_from_zip(self, zip_path, speckit_version, priority=10): + nonlocal installed + installed = True + + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + with pytest.raises(typer.Exit) as exc_info: + preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + assert installed is False + + def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir): + """URL installs reject HTTPS URLs without a hostname before downloading.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.authentication.http.open_url") as open_url, + ): + result = runner.invoke( + app, ["preset", "add", "--from", "https:///preset.zip"] + ) + + assert result.exit_code == 1 + output = strip_ansi(result.output) + assert "URL must use HTTPS with a hostname" in output + assert "got https://" not in output + open_url.assert_not_called() + + def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir): + """A malformed IPv6 URL must produce a clean error, not a ValueError traceback.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.authentication.http.open_url") as open_url, + ): + result = runner.invoke( + app, + ["preset", "add", "--from", "https://[::1/preset.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Invalid URL" in output + open_url.assert_not_called() + + def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir): + """A bracketed-but-invalid IPv6 host in --from must exit cleanly. + + "https://[not-an-ip]/preset.zip" is a malformed authority that raises + ValueError during URL validation; the try/except guard around parsing + and the .hostname read must turn that into a clean "Invalid URL" message. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.authentication.http.open_url") as open_url, + ): + result = runner.invoke( + app, + ["preset", "add", "--from", "https://[not-an-ip]/preset.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Invalid URL" in output + open_url.assert_not_called() + + def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir): + """An out-of-range port raises ValueError lazily on .port access. + + The up-front guard reads ``_parsed.port`` (urllib validates the port + range/syntax there) inside its try/except, so "https://example.com:99999/ + preset.zip" must produce a clean "Invalid URL" message rather than + leaking a raw ValueError traceback past the CLI. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.authentication.http.open_url") as open_url, + ): + result = runner.invoke( + app, + ["preset", "add", "--from", "https://example.com:99999/preset.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Invalid URL" in strip_ansi(result.output) + open_url.assert_not_called() + + def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir): + """A catalog download_url with a bracketed non-IP host must render cleanly. + + ``download_pack`` raises ``PresetError`` whose message embeds the raw URL + (e.g. ``https://[not-an-ip]/x``). The ``preset_add`` handler must escape + that message before printing so Rich does not interpret ``[not-an-ip]`` + as a markup tag and crash while rendering the error. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + bad_url = "https://[not-an-ip]/x" + catalog_data = { + "test-pack": { + "name": "Test Pack", + "version": "1.0.0", + "download_url": bad_url, + } + } + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object(PresetCatalog, "_get_merged_packs", return_value=catalog_data), + ): + result = runner.invoke( + app, + ["preset", "add", "test-pack"], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Error:" in output + # The malformed URL surfaces verbatim rather than crashing the renderer. + assert bad_url in output + + @pytest.mark.parametrize( + ("exc_type", "label"), + [ + (PresetCompatibilityError, "Compatibility Error"), + (PresetValidationError, "Validation Error"), + (PresetError, "Error"), + ], + ) + def test_preset_add_exception_handlers_escape_markup( + self, project_dir, exc_type, label + ): + """Preset install exceptions can include catalog-controlled values. + + The message must be escaped so Rich does not treat bracketed content as + markup and raise while rendering the error. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + dev_dir = project_dir / "dev-pack" + dev_dir.mkdir() + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetManager, + "install_from_directory", + side_effect=exc_type("bad [red]preset[/red]"), + ), + ): + result = runner.invoke( + app, + ["preset", "add", "--dev", str(dev_dir)], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + assert f"{label}:" in result.output + assert "bad [red]preset[/red]" in result.output + + def test_preset_add_from_url_redirect_error_describes_disallowed_url( + self, project_dir, monkeypatch, capsys + ): + """Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs.""" + import typer + + from specify_cli.presets._commands import preset_add + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "https:///preset.zip" + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: ( + FakeResponse(b"zip") + ), + ) + monkeypatch.setattr( + PresetManager, "install_from_zip", lambda *args, **kwargs: None + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + output = strip_ansi(capsys.readouterr().out) + assert "redirected to a disallowed URL" in output + assert "must use HTTPS with a hostname" in output + + def test_preset_add_from_url_reads_in_bounded_chunks( + self, project_dir, monkeypatch + ): + """URL installs read the response in bounded chunks.""" + from specify_cli.presets._commands import preset_add + + class FakeResponse(io.BytesIO): + def __init__(self, data): + super().__init__(data) + self.read_sizes = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "https://example.com/preset.zip" + + def read(self, size=-1): + assert size not in (-1, None) + self.read_sizes.append(size) + return super().read(size) + + response = FakeResponse(b"PK\x05\x06" + b"\x00" * 18) + installed = {} + + def fake_install_from_zip(self, zip_path, speckit_version, priority=10): + installed["zip_bytes"] = Path(zip_path).read_bytes() + installed["speckit_version"] = speckit_version + installed["priority"] = priority + return SimpleNamespace(name="Test Preset", version="1.0.0") + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: ( + response + ), + ) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=7, + ) + + assert response.read_sizes + assert installed == { + "zip_bytes": b"PK\x05\x06" + b"\x00" * 18, + "speckit_version": "0.6.0", + "priority": 7, + } + + def test_preset_add_from_url_rejects_oversized_download( + self, project_dir, monkeypatch, capsys + ): + """An oversized direct download fails before preset installation.""" + import typer + + from specify_cli._download_security import ( + read_response_limited as real_read_response_limited, + ) + from specify_cli.presets import _commands as preset_commands + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "https://example.com/preset.zip" + + def read_with_tiny_limit(response, **kwargs): + kwargs.pop("max_bytes", None) + return real_read_response_limited(response, max_bytes=4, **kwargs) + + installed = False + + def fake_install_from_zip(*_args, **_kwargs): + nonlocal installed + installed = True + + monkeypatch.setattr( + preset_commands, + "read_response_limited", + read_with_tiny_limit, + ) + monkeypatch.setattr( + "specify_cli._require_specify_project", + lambda: project_dir, + ) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda *_args, **_kwargs: FakeResponse(b"12345"), + ) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + with pytest.raises(typer.Exit) as exc_info: + preset_commands.preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + output = " ".join(strip_ansi(capsys.readouterr().out).split()) + assert "exceeds maximum size of 4 bytes" in output + assert installed is False + + def test_bundled_preset_missing_locally_cli_error(self, project_dir): + """CLI shows clear error when bundled preset cannot be found locally.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + # Patch _locate_bundled_preset to return None (simulating missing files) + # and mock the catalog to return a bundled entry for "lean" + fake_pack_info = { + "id": "lean", + "name": "Lean Workflow", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, + } + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli._locate_bundled_preset", return_value=None), + patch("specify_cli.presets.PresetCatalog") as MockCatalog, + ): + MockCatalog.return_value.get_pack_info.return_value = fake_pack_info + result = runner.invoke(app, ["preset", "add", "lean"]) + + # Should fail with a helpful error explaining this is a bundled preset + # and suggesting how to recover. + assert result.exit_code == 1 + output = strip_ansi(result.output).lower() + assert "bundled" in output, result.output + assert "reinstall" in output, result.output + + +class TestPresetAddFromUrlResolution: + """CLI-level tests for preset add --from GitHub release resolution.""" + + def test_preset_add_from_github_release_url_resolves_and_downloads( + self, project_dir + ): + """'preset add --from ' resolves to API asset URL.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + manifest_content = yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "my-preset", + "name": "My Preset", + "version": "1.0.0", + "description": "Test preset", + "author": "Test", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "t", + "file": "templates/t.md", + "description": "t", + } + ] + }, + } + ) + zip_buf = __import__("io").BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + def fake_open_url( + url, timeout=None, extra_headers=None, redirect_validator=None + ): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return io.BytesIO( + json.dumps( + { + "assets": [ + { + "name": "preset.zip", + "url": "https://api.github.com/repos/org/repo/releases/assets/42", + } + ] + } + ).encode() + ) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.get_speckit_version", return_value="1.0.0"), + patch( + "specify_cli.authentication.http.open_url", side_effect=fake_open_url + ), + ): + result = runner.invoke( + app, + [ + "preset", + "add", + "--from", + "https://github.com/org/repo/releases/download/v1.0/preset.zip", + ], + ) + + assert result.exit_code == 0, result.output + assert "My Preset" in result.output + # First call should resolve the release tag + assert any("releases/tags/v1.0" in url for url, _ in captured_urls) + # Second call should download from the resolved asset URL with octet-stream + asset_calls = [ + (url, h) for url, h in captured_urls if "releases/assets/" in url + ] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir): + """'preset add --from ' uses URL directly with octet-stream.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + manifest_content = yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "my-preset", + "name": "My Preset", + "version": "1.0.0", + "description": "Test preset", + "author": "Test", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "t", + "file": "templates/t.md", + "description": "t", + } + ] + }, + } + ) + zip_buf = __import__("io").BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + def fake_open_url( + url, timeout=None, extra_headers=None, redirect_validator=None + ): + captured_urls.append((url, extra_headers)) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.get_speckit_version", return_value="1.0.0"), + patch( + "specify_cli.authentication.http.open_url", side_effect=fake_open_url + ), + ): + result = runner.invoke( + app, + [ + "preset", + "add", + "--from", + "https://api.github.com/repos/org/repo/releases/assets/42", + ], + ) + + assert result.exit_code == 0, result.output + # Should go directly to the asset URL with Accept header + assert len(captured_urls) == 1 + assert ( + captured_urls[0][0] + == "https://api.github.com/repos/org/repo/releases/assets/42" + ) + assert captured_urls[0][1] == {"Accept": "application/octet-stream"} + + def test_preset_add_from_ghes_release_url_resolves_via_api_v3( + self, project_dir, monkeypatch + ): + """'preset add --from ' resolves via GHES /api/v3 endpoint.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr( + _auth_http, + "_config_override", + [ + AuthConfigEntry( + hosts=("ghes.example",), provider="github", auth="bearer", token="t" + ), + ], + ) + + manifest_content = yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "my-preset", + "name": "My Preset", + "version": "1.0.0", + "description": "Test preset", + "author": "Test", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "t", + "file": "templates/t.md", + "description": "t", + } + ] + }, + } + ) + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + def fake_open_url( + url, timeout=None, extra_headers=None, redirect_validator=None + ): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return io.BytesIO( + json.dumps( + { + "assets": [ + { + "name": "preset.zip", + "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42", + } + ] + } + ).encode() + ) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.get_speckit_version", return_value="1.0.0"), + patch( + "specify_cli.authentication.http.open_url", side_effect=fake_open_url + ), + ): + result = runner.invoke( + app, + [ + "preset", + "add", + "--from", + "https://ghes.example/org/repo/releases/download/v1.0/preset.zip", + ], + ) + + assert result.exit_code == 0, result.output + # The tag-lookup call must use the GHES /api/v3 endpoint + assert any( + "ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url + for url, _ in captured_urls + ) + # The asset download call must carry Accept: application/octet-stream + asset_calls = [ + (url, h) for url, h in captured_urls if "releases/assets/" in url + ] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} diff --git a/tests/specify_cli/presets/test_command_disable.py b/tests/specify_cli/presets/test_command_disable.py new file mode 100644 index 0000000000..b788fbbc80 --- /dev/null +++ b/tests/specify_cli/presets/test_command_disable.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path + +from specify_cli.presets import ( + PresetManager, +) + + +class TestPresetDisable: + """Test preset enable/disable CLI commands.""" + + def test_disable_preset(self, project_dir, pack_dir): + """Test disable command sets enabled=False.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Verify initially enabled + assert manager.registry.get("test-pack").get("enabled", True) is True + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "disabled" in result.output.lower() + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["enabled"] is False + + def test_disable_already_disabled(self, project_dir, pack_dir): + """Test disable on already disabled preset shows warning.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset and disable it + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.update("test-pack", {"enabled": False}) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "already disabled" in result.output.lower() + + def test_disable_not_installed(self, project_dir): + """Test disable fails for non-installed preset.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "nonexistent"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() + + def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): + """Test disable fails gracefully for corrupted registry entry.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset then corrupt the registry entry + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.data["presets"]["test-pack"] = "corrupted-string" + manager.registry._save() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 1 + assert "corrupted state" in result.output.lower() diff --git a/tests/specify_cli/presets/test_command_enable.py b/tests/specify_cli/presets/test_command_enable.py new file mode 100644 index 0000000000..4eb4ebfe9d --- /dev/null +++ b/tests/specify_cli/presets/test_command_enable.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path + +from specify_cli.presets import ( + PresetManager, +) +from tests.specify_cli.presets._helpers import ( + install_constitution_sync_preset, + install_self_test_preset, + make_convention_constitution_preset as _make_convention_constitution_preset, +) + + +class TestPresetEnable: + """Test preset enable/disable CLI commands.""" + + def test_enable_preset(self, project_dir, pack_dir): + """Test enable command sets enabled=True.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset and disable it + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.update("test-pack", {"enabled": False}) + + # Verify disabled + assert manager.registry.get("test-pack")["enabled"] is False + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "enabled" in result.output.lower() + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["enabled"] is True + + def test_enable_disable_reconciles_generated_constitution( + self, project_dir, temp_dir + ): + """Enable and disable rematerialize the winning constitution layer.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + manager.install_from_directory( + _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 + ) + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.read_text() == "# Convention Constitution\n" + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + disabled = runner.invoke( + app, ["preset", "disable", "convention-constitution"] + ) + + assert disabled.exit_code == 0, disabled.output + assert "preset:self-test" in memory.read_text() + + with patch.object(Path, "cwd", return_value=project_dir): + enabled = runner.invoke( + app, ["preset", "enable", "convention-constitution"] + ) + + assert enabled.exit_code == 0, enabled.output + assert memory.read_text() == "# Convention Constitution\n" + + def test_stack_changes_do_not_create_missing_constitution( + self, project_dir, pack_dir + ): + """Stack changes for non-providers do not seed a missing constitution.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5") + memory = project_dir / ".specify" / "memory" / "constitution.md" + runner = CliRunner() + + for args in ( + ["preset", "set-priority", "test-pack", "5"], + ["preset", "disable", "test-pack"], + ["preset", "enable", "test-pack"], + ): + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, args) + assert result.exit_code == 0, result.output + assert not memory.exists() + + def test_enable_already_enabled(self, project_dir, pack_dir): + """Test enable on already enabled preset shows warning.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset (enabled by default) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "already enabled" in result.output.lower() + + def test_enable_not_installed(self, project_dir): + """Test enable fails for non-installed preset.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "nonexistent"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() + + def test_enable_corrupted_registry_entry(self, project_dir, pack_dir): + """Test enable fails gracefully for corrupted registry entry.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset then corrupt the registry entry + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.data["presets"]["test-pack"] = "corrupted-string" + manager.registry._save() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 1 + assert "corrupted state" in result.output.lower() diff --git a/tests/specify_cli/presets/test_command_info.py b/tests/specify_cli/presets/test_command_info.py new file mode 100644 index 0000000000..1379e6e3a1 --- /dev/null +++ b/tests/specify_cli/presets/test_command_info.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from pathlib import Path +from typing import ClassVar + +import yaml + +from specify_cli.presets import ( + PresetCatalog, + PresetManager, +) +from tests.conftest import strip_ansi +from tests.specify_cli.presets._helpers import ( + default_catalog_entries, + seed_catalog, +) + + +class TestPresetInfoTags: + """Non-string catalog tags must not crash preset display commands. + + Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain + numbers or other non-strings. The display path joins them; a raw + ``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``. + Sibling command surfaces (extensions/integrations/workflows) already guard + this with ``str(t) for t in ...`` — presets must match. + """ + + def test_info_renders_non_string_tags(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, [1, 2]) + default_only = default_catalog_entries(catalog) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, "get_active_catalogs", return_value=default_only + ), + ): + result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Tags: 1, 2" in plain + + def test_info_tolerates_non_list_tags(self, project_dir): + """``preset info`` must not crash rendering a scalar ``tags:`` value.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, 5) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "numeric-tags" in plain + assert "Tags:" not in plain + + +class TestPresetInfoCatalogMarkup: + """Catalog metadata must render as literal text in Rich output.""" + + MARKUP_PRESET: ClassVar[dict[str, object]] = { + "id": "[red]markup-id[/red]", + "name": "[green]Markup Name[/green]", + "version": "[blue]1.0.0[/blue]", + "description": "[yellow]Markup Description[/yellow]", + "author": "[magenta]Markup Author[/magenta]", + "tags": ["[italic]markup-tag[/italic]"], + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def test_info_escapes_catalog_markup(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_pack_info", + return_value=self.MARKUP_PRESET, + ), + ): + result = CliRunner().invoke( + app, + ["preset", "info", self.MARKUP_PRESET["id"]], + ) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for field in ( + "id", + "name", + "version", + "description", + "author", + "repository", + "license", + ): + value = self.MARKUP_PRESET[field] + assert value in output + # Tags are joined into a single line, so assert on the rendered join. + assert ", ".join(self.MARKUP_PRESET["tags"]) in output + + +class TestPresetInfoInstalledMarkup: + """Locally installed preset metadata must render as literal text. + + ``preset.yml`` is user-editable, so its fields can contain ``[...]``. + ``TestPresetCatalogRichMarkup`` covers the catalog branch of these + commands; the installed-preset branch of ``preset list``/``preset info`` + and all of ``preset resolve`` were left unescaped, so a field like + ``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an + unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``, + aborting the command with a traceback. + """ + + MARKUP_FIELDS: ClassVar[dict[str, str]] = { + "name": "[green]Markup Name[/green]", + "version": "1.0.0", + "description": "[yellow]Markup Description[/yellow]", + "author": "[magenta]Markup Author[/magenta]", + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def _install( + self, + temp_dir, + project_dir, + preset_overrides=None, + strategy=None, + pack_id="markup-pack", + priority=10, + tmpl_description=None, + ): + """Install a preset from a directory built with the given manifest fields.""" + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + + preset_section = { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + } + preset_section.update(preset_overrides or {}) + tmpl = { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + if tmpl_description is not None: + tmpl["description"] = tmpl_description + if strategy: + tmpl["strategy"] = strategy + (src / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": preset_section, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": {"templates": [tmpl]}, + "tags": ["[italic]markup-tag[/italic]"], + } + ) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(src, "9.9.9", priority) + return manager + + def _invoke(self, project_dir, args): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir): + """Every ``preset.yml`` field must survive verbatim in list/info output.""" + self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS) + + for args in (["preset", "list"], ["preset", "info", "markup-pack"]): + result = self._invoke(project_dir, args) + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + # `preset list` does not render repository/license. + fields = ( + ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS + ) + for field in fields: + assert self.MARKUP_FIELDS[field] in output, (field, args, output) + assert "[italic]markup-tag[/italic]" in output, (args, output) + + def test_info_does_not_swallow_template_description(self, temp_dir, project_dir): + """The per-template line in ``preset info`` must escape the template description. + + ``name``/``type`` are format-restricted by manifest validation, but + ``description`` is free-form, so it is the field that can carry markup. + """ + self._install( + temp_dir, + project_dir, + tmpl_description="Template [desc] here", + ) + result = self._invoke(project_dir, ["preset", "info", "markup-pack"]) + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + assert "spec-template (template): Template [desc] here" in output, output + + def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir): + """An unbalanced tag must not raise MarkupError and abort the command.""" + self._install( + temp_dir, + project_dir, + preset_overrides={"description": "Broken [/red] tag"}, + ) + + for args in (["preset", "list"], ["preset", "info", "markup-pack"]): + result = self._invoke(project_dir, args) + assert result.exit_code == 0, (args, result.output, result.exception) + assert "Broken [/red] tag" in strip_ansi(result.output) diff --git a/tests/specify_cli/presets/test_command_list.py b/tests/specify_cli/presets/test_command_list.py new file mode 100644 index 0000000000..243bae5adf --- /dev/null +++ b/tests/specify_cli/presets/test_command_list.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specify_cli.presets import ( + PresetManager, +) +from tests.conftest import strip_ansi + + +class TestPresetListOrdering: + """``preset list`` must print presets in actual resolution/precedence order. + + Regression coverage for #4086: the printed order was registry/insertion + order, so a preset with a *higher* priority number (lower precedence) could + appear before one with a lower number, misleading users about which preset + wins. Output must be sorted by (priority, id) to match + ``PresetRegistry.list_by_priority()``. + """ + + def _install(self, temp_dir, project_dir, pack_id, priority): + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + (src / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + }, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + ] + }, + } + ) + ) + PresetManager(project_dir).install_from_directory(src, "9.9.9", priority) + + def _invoke(self, project_dir, args): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_list_sorted_by_priority(self, temp_dir, project_dir): + """Lower priority number is listed first regardless of install order.""" + # Install in an order that does NOT match precedence. + self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100) + self._install(temp_dir, project_dir, "lean", priority=10) + + result = self._invoke(project_dir, ["preset", "list"]) + assert result.exit_code == 0, result.output + output = strip_ansi(result.output) + # `lean` (priority 10) must appear before `copilot-sub-agents` (100). + assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output + assert "resolution order" in output, output + assert "Ties are broken by preset id" in output, output + + def test_list_ties_broken_by_id(self, temp_dir, project_dir): + """Equal priority ties are broken alphabetically by preset id.""" + self._install(temp_dir, project_dir, "zebra", priority=10) + self._install(temp_dir, project_dir, "alpha", priority=10) + + result = self._invoke(project_dir, ["preset", "list"]) + assert result.exit_code == 0, result.output + output = strip_ansi(result.output) + assert output.index("(alpha)") < output.index("(zebra)"), output diff --git a/tests/specify_cli/presets/test_command_remove.py b/tests/specify_cli/presets/test_command_remove.py new file mode 100644 index 0000000000..50a7e89f7b --- /dev/null +++ b/tests/specify_cli/presets/test_command_remove.py @@ -0,0 +1,21 @@ +"""Tests for the ``specify preset remove`` command.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.presets import PresetManager + + +def test_remove_installed_preset(project_dir, pack_dir): + PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke(app, ["preset", "remove", "test-pack"]) + + assert result.exit_code == 0, result.output + assert not PresetManager(project_dir).registry.is_installed("test-pack") diff --git a/tests/specify_cli/presets/test_command_resolve.py b/tests/specify_cli/presets/test_command_resolve.py new file mode 100644 index 0000000000..42e55d572a --- /dev/null +++ b/tests/specify_cli/presets/test_command_resolve.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specify_cli.presets import ( + PresetManager, +) +from tests.conftest import strip_ansi + + +class TestPresetResolve: + """Locally installed preset metadata must render as literal text. + + ``preset.yml`` is user-editable, so its fields can contain ``[...]``. + ``TestPresetCatalogRichMarkup`` covers the catalog branch of these + commands; the installed-preset branch of ``preset list``/``preset info`` + and all of ``preset resolve`` were left unescaped, so a field like + ``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an + unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``, + aborting the command with a traceback. + """ + + def _install( + self, + temp_dir, + project_dir, + preset_overrides=None, + strategy=None, + pack_id="markup-pack", + priority=10, + tmpl_description=None, + ): + """Install a preset from a directory built with the given manifest fields.""" + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + + preset_section = { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + } + preset_section.update(preset_overrides or {}) + tmpl = { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + if tmpl_description is not None: + tmpl["description"] = tmpl_description + if strategy: + tmpl["strategy"] = strategy + (src / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": preset_section, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": {"templates": [tmpl]}, + "tags": ["[italic]markup-tag[/italic]"], + } + ) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(src, "9.9.9", priority) + return manager + + def _invoke(self, project_dir, args): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_resolve_rejects_invalid_template_name(self, project_dir): + """``preset resolve`` rejects names before joining them into paths.""" + result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"]) + assert result.exit_code == 1, (result.output, result.exception) + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_rejects_path_traversal(self, project_dir): + """The resolver rejects traversal before joining names into paths.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "../../../README"], + ) + + assert result.exit_code == 1 + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_accepts_dotted_command_name(self, project_dir): + """Documented dotted command identifiers use command resolution.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "speckit.constitution"], + ) + + assert result.exit_code == 0, (result.output, result.exception) + assert "constitution.md" in "".join(strip_ansi(result.output).split()) + + def test_resolve_rejects_empty_command_segments(self, project_dir): + """Dotted command identifiers cannot contain empty path-like segments.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "speckit..constitution"], + ) + + assert result.exit_code == 1 + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_escapes_layer_path_and_source(self, project_dir): + """The top-layer path/source lines must render markup literally. + + A preset can be installed from any directory, so the resolved path can + contain ``[...]``; the layer source carries the pack id and version. + """ + from unittest.mock import patch + + from specify_cli.presets import PresetResolver + + # A closing tag cannot live inside a path segment: `Path` treats its + # `/` as a separator on POSIX and rewrites it to `\` on Windows. The + # opening tag covers the swallowing case for the path; the unbalanced + # closing tag rides on `source`, which is a plain string. + layer = { + "path": Path("/tmp/[red]dir/spec-template.md"), + "source": "pack [/red] v1.0.0", + "strategy": "replace", + } + with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "[red]dir" in output, output + assert "pack [/red] v1.0.0" in output, output + + def test_resolve_escapes_fallback_path_and_source(self, project_dir): + """The no-layer fallback branch must escape ``resolve_with_source`` output.""" + from unittest.mock import patch + + from specify_cli.presets import PresetResolver + + with ( + patch.object(PresetResolver, "collect_all_layers", return_value=[]), + patch.object( + PresetResolver, + "resolve_with_source", + return_value={ + "path": "/tmp/[blue]fallback[/blue]/spec-template.md", + "source": "fallback [/red] source", + }, + ), + ): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "[blue]fallback[/blue]" in output, output + assert "fallback [/red] source" in output, output + + def test_resolve_escapes_composition_error(self, project_dir): + """A composition exception message must not be parsed as markup.""" + from unittest.mock import patch + + from specify_cli.presets import PresetResolver + + layers = [ + { + "path": Path("/tmp/top/spec-template.md"), + "source": "top-pack v1.0.0", + "strategy": "append", + }, + { + "path": Path("/tmp/base/spec-template.md"), + "source": "base-pack v1.0.0", + "strategy": "append", + }, + ] + with ( + patch.object(PresetResolver, "collect_all_layers", return_value=layers), + patch.object( + PresetResolver, + "resolve_content", + side_effect=RuntimeError("compose failed: [/red] bad layer"), + ), + ): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "compose failed: [/red] bad layer" in output, output + + def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir): + """The composition chain's ``[]`` label must not be eaten as a tag.""" + self._install( + temp_dir, project_dir, strategy="replace", pack_id="base-pack", priority=20 + ) + self._install( + temp_dir, project_dir, strategy="append", pack_id="app-pack", priority=5 + ) + + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + assert result.exit_code == 0, (result.output, result.exception) + output = strip_ansi(result.output) + assert "Composition chain" in output, output + assert "[base]" in output, output + assert "[append]" in output, output diff --git a/tests/specify_cli/presets/test_command_search.py b/tests/specify_cli/presets/test_command_search.py new file mode 100644 index 0000000000..91e2447619 --- /dev/null +++ b/tests/specify_cli/presets/test_command_search.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from pathlib import Path +from typing import ClassVar + +from specify_cli.presets import PresetCatalog +from tests.conftest import strip_ansi +from tests.specify_cli.presets._helpers import ( + default_catalog_entries, + seed_catalog, +) + + +class TestPresetTagsNonString: + """Non-string catalog tags must not crash preset display commands. + + Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain + numbers or other non-strings. The display path joins them; a raw + ``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``. + Sibling command surfaces (extensions/integrations/workflows) already guard + this with ``str(t) for t in ...`` — presets must match. + """ + + def test_search_renders_non_string_tags(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, [1, 2]) + default_only = default_catalog_entries(catalog) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, "get_active_catalogs", return_value=default_only + ), + ): + result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Tags: 1, 2" in plain + + def test_search_by_author_tolerates_non_string_author(self, project_dir): + """``--author`` must not crash on a numeric catalog ``author``. + + ``PresetCatalog.search`` called ``.lower()`` straight on the raw value, + raising ``AttributeError: 'int' object has no attribute 'lower'``. The + sibling extension/integration catalogs coerce with ``str(...)`` first. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, ["ci"], extra={"author": 789}) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + result = CliRunner().invoke(app, ["preset", "search", "--author", "789"]) + + assert result.exit_code == 0, result.output + assert "Numeric Tags" in strip_ansi(result.output) + + def test_search_query_tolerates_non_string_name_and_description(self, project_dir): + """A query search must not crash on numeric ``name``/``description``. + + The searchable-text join passed the raw values through, raising + ``TypeError: sequence item 0: expected str instance, int found``. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog( + project_dir, ["ci"], extra={"name": 123, "description": 456} + ) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + result = CliRunner().invoke(app, ["preset", "search", "123"]) + + assert result.exit_code == 0, result.output + assert "numeric-tags" in strip_ansi(result.output) + + def test_search_tolerates_non_list_tags(self, project_dir): + """A scalar ``tags:`` value must not crash the tag filter or display. + + ``tags: 5`` is truthy but not iterable, so both the ``--tag`` filter and + the result-display join raised ``TypeError: 'int' object is not + iterable``. Siblings guard with ``isinstance(raw_tags, list)``. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, 5) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + filtered = CliRunner().invoke(app, ["preset", "search", "--tag", "ci"]) + displayed = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert filtered.exit_code == 0, filtered.output + assert "No presets found" in strip_ansi(filtered.output) + + assert displayed.exit_code == 0, displayed.output + plain = strip_ansi(displayed.output) + assert "Numeric Tags" in plain + assert "Tags:" not in plain + + def test_search_escapes_rich_markup_in_tags(self, project_dir): + """Bracketed tag text must survive Rich markup parsing. + + ``preset search`` printed tags unescaped, so a tag like ``[bold]`` was + swallowed as a style tag. ``preset list`` already escaped this. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, ["[bold]ci"]) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert result.exit_code == 0, result.output + assert "[bold]ci" in strip_ansi(result.output) + + +class TestPresetSearchRichMarkup: + """Catalog metadata must render as literal text in Rich output.""" + + MARKUP_PRESET: ClassVar[dict[str, object]] = { + "id": "[red]markup-id[/red]", + "name": "[green]Markup Name[/green]", + "version": "[blue]1.0.0[/blue]", + "description": "[yellow]Markup Description[/yellow]", + "author": "[magenta]Markup Author[/magenta]", + "tags": ["[italic]markup-tag[/italic]"], + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def test_search_escapes_catalog_markup(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "search", + return_value=[self.MARKUP_PRESET], + ), + ): + result = CliRunner().invoke(app, ["preset", "search"]) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for value in ( + self.MARKUP_PRESET["id"], + self.MARKUP_PRESET["name"], + self.MARKUP_PRESET["version"], + self.MARKUP_PRESET["description"], + ): + assert value in output diff --git a/tests/specify_cli/presets/test_command_set_priority.py b/tests/specify_cli/presets/test_command_set_priority.py new file mode 100644 index 0000000000..14afba58a0 --- /dev/null +++ b/tests/specify_cli/presets/test_command_set_priority.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from pathlib import Path + +from specify_cli.presets import ( + PresetManager, +) +from tests.conftest import strip_ansi +from tests.specify_cli.presets._helpers import ( + install_constitution_sync_preset, + install_self_test_preset, + make_convention_constitution_preset as _make_convention_constitution_preset, +) + + +class TestPresetSetPriority: + """Test preset set-priority CLI command.""" + + def test_set_priority_changes_priority(self, project_dir, pack_dir): + """Test set-priority command changes preset priority.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset with default priority + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Verify default priority + assert manager.registry.get("test-pack")["priority"] == 10 + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "priority changed: 10 → 5" in plain + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["priority"] == 5 + + def test_set_priority_reconciles_generated_constitution( + self, project_dir, temp_dir + ): + """Changing priority rematerializes an unchanged generated constitution.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + manager.install_from_directory( + _make_convention_constitution_preset(temp_dir), "0.1.5", priority=20 + ) + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert "preset:self-test" in memory.read_text() + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, + ["preset", "set-priority", "convention-constitution", "1"], + ) + + assert result.exit_code == 0, result.output + assert memory.read_text() == "# Convention Constitution\n" + + def test_set_priority_same_value_no_change(self, project_dir, pack_dir): + """Test set-priority with same value shows already set message.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset with priority 5 + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=5) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "already has priority 5" in plain + + def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir): + """A corrupted boolean priority must be repaired, not skipped. + + ``isinstance(True, int)`` is True and ``True == 1`` in Python, so a + stored ``True`` priority would short-circuit the ``already has + priority 1`` skip path and never get rewritten to a real int — + contradicting the comment that promises corrupted values are + repaired. The guard must exclude bools (like normalize_priority). + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=5) + # Inject a corrupted boolean priority (True == 1). + manager.registry.update("test-pack", {"priority": True}) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + # The corrupted bool must be repaired, not reported as already-set. + assert "already has priority" not in plain + assert "priority changed" in plain + + # The stored value is now a real int, not a bool. + reloaded = PresetManager(project_dir).registry.get("test-pack") + assert reloaded["priority"] == 1 + assert not isinstance(reloaded["priority"], bool) + + def test_set_priority_invalid_value(self, project_dir, pack_dir): + """Test set-priority rejects invalid priority values.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"]) + + assert result.exit_code == 1, result.output + assert "Priority must be a positive integer" in result.output + + def test_set_priority_not_installed(self, project_dir): + """Test set-priority fails for non-installed preset.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() diff --git a/tests/specify_cli/presets/test_command_update.py b/tests/specify_cli/presets/test_command_update.py new file mode 100644 index 0000000000..4b2cf2d3d1 --- /dev/null +++ b/tests/specify_cli/presets/test_command_update.py @@ -0,0 +1,282 @@ +"""Tests for the ``specify preset update`` command.""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +import sys +from types import SimpleNamespace + +import pytest +import typer + +from specify_cli.presets._commands import ( + _render_powershell_argv, + preset_update, +) +from tests.conftest import strip_ansi + + +class TestPresetUpdateCommand: + """Test the destructive remove-then-add update contract.""" + + @staticmethod + def _manager(monkeypatch, project_dir, installed=True): + from specify_cli.presets import _commands as commands + + registry = SimpleNamespace(is_installed=lambda _preset_id: installed) + manager = SimpleNamespace(registry=registry) + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.presets.PresetManager", lambda _root: manager) + return commands + + def test_unknown_preset_fails_without_remove_or_add(self, project_dir, monkeypatch): + commands = self._manager(monkeypatch, project_dir, installed=False) + calls = [] + monkeypatch.setattr( + commands, "preset_remove", lambda *_args: calls.append("remove") + ) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update("missing", from_url=None, dev=None, priority=10) + + assert exc_info.value.exit_code == 1 + assert calls == [] + + @pytest.mark.parametrize( + ("from_url", "dev"), + [ + ("https://example.com/preset.zip", "./preset"), + ("", "./preset"), + ("https://example.com/preset.zip", ""), + ], + ) + def test_mutually_exclusive_sources_are_rejected( + self, project_dir, monkeypatch, from_url, dev + ): + commands = self._manager(monkeypatch, project_dir) + calls = [] + monkeypatch.setattr( + commands, "preset_remove", lambda *_args: calls.append("remove") + ) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update( + "test-pack", + from_url=from_url, + dev=dev, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + assert calls == [] + + @pytest.mark.parametrize( + ("from_url", "dev", "option"), + [("", None, "--from"), (None, "", "--dev")], + ) + def test_empty_source_is_rejected_before_removal( + self, project_dir, monkeypatch, capsys, from_url, dev, option + ): + commands = self._manager(monkeypatch, project_dir) + calls = [] + monkeypatch.setattr( + commands, "preset_remove", lambda *_args: calls.append("remove") + ) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update( + "test-pack", + from_url=from_url, + dev=dev, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + assert calls == [] + assert f"{option} must not be empty" in strip_ansi(capsys.readouterr().out) + + def test_remove_failure_prevents_add(self, project_dir, monkeypatch): + commands = self._manager(monkeypatch, project_dir) + calls = [] + + def fail_remove(_preset_id): + calls.append("remove") + raise typer.Exit(1) + + monkeypatch.setattr(commands, "preset_remove", fail_remove) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update("test-pack", from_url=None, dev=None, priority=10) + + assert exc_info.value.exit_code == 1 + assert calls == ["remove"] + + def test_update_forwards_id_sources_and_priority_to_add( + self, project_dir, monkeypatch + ): + commands = self._manager(monkeypatch, project_dir) + calls = [] + monkeypatch.setattr( + commands, + "preset_remove", + lambda preset_id: calls.append(("remove", preset_id)), + ) + monkeypatch.setattr( + commands, + "preset_add", + lambda **kwargs: calls.append(("add", kwargs)), + ) + + preset_update( + "test-pack", + from_url="https://example.com/replacement.zip", + dev=None, + priority=4, + ) + + assert calls == [ + ("remove", "test-pack"), + ( + "add", + { + "preset_id": "test-pack", + "from_url": "https://example.com/replacement.zip", + "dev": None, + "priority": 4, + }, + ), + ] + + def test_add_failure_states_removed_and_prints_retry_command( + self, project_dir, monkeypatch, capsys + ): + commands = self._manager(monkeypatch, project_dir) + monkeypatch.setattr(commands, "preset_remove", lambda _preset_id: None) + + def fail_add(**_kwargs): + raise typer.Exit(1) + + monkeypatch.setattr(commands, "preset_add", fail_add) + + with pytest.raises(typer.Exit) as exc_info: + preset_update( + "test-pack", + from_url=None, + dev="/tmp/replacement preset", + priority=6, + ) + + assert exc_info.value.exit_code == 1 + output = strip_ansi(capsys.readouterr().out) + assert "previous preset was removed" in output + retry_args = [ + "specify", + "preset", + "add", + "test-pack", + "--dev", + "/tmp/replacement preset", + "--priority", + "6", + ] + expected = ( + _render_powershell_argv(retry_args) + if os.name == "nt" + else shlex.join(retry_args) + ) + assert expected in output + + def test_retry_command_quotes_powershell_metacharacters( + self, project_dir, monkeypatch, capsys + ): + """Windows retry commands keep PowerShell metacharacters literal.""" + commands = self._manager(monkeypatch, project_dir) + monkeypatch.setattr(commands, "preset_remove", lambda _preset_id: None) + + def fail_add(**_kwargs): + raise typer.Exit(1) + + monkeypatch.setattr(commands, "preset_add", fail_add) + monkeypatch.setattr(os, "name", "nt") + + with pytest.raises(typer.Exit) as exc_info: + preset_update( + "test-pack", + from_url=None, + dev=r"C:\replacement&$backup's presets", + priority=6, + ) + + assert exc_info.value.exit_code == 1 + output = strip_ansi(capsys.readouterr().out) + expected = ( + "& 'specify' 'preset' 'add' 'test-pack' '--dev' " + "'C:\\replacement&$backup''s presets' '--priority' '6'" + ) + assert "Retry in PowerShell: " in output + assert expected in output + + def test_powershell_retry_renderer_preserves_literal_arguments(self): + """The rendered command survives parsing by a real PowerShell.""" + powershell = shutil.which("pwsh") or shutil.which("powershell") + if powershell is None: + pytest.skip("PowerShell is not available") + + arguments = [ + "https://example.com/archive.zip?one=1&two=$value", + r"C:\owner's presets", + ] + rendered = _render_powershell_argv( + [ + sys.executable, + "-c", + "import json,sys; print(json.dumps(sys.argv[1:]))", + *arguments, + ] + ) + result = subprocess.run( + [powershell, "-NoProfile", "-Command", rendered], + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(result.stdout) == arguments + + def test_invalid_priority_rejected_before_removal( + self, project_dir, monkeypatch, capsys + ): + """--priority 0 must fail without removing the installed preset.""" + commands = self._manager(monkeypatch, project_dir) + calls = [] + monkeypatch.setattr( + commands, "preset_remove", lambda preset_id: calls.append("remove") + ) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update("test-pack", from_url=None, dev=None, priority=0) + + assert exc_info.value.exit_code == 1 + assert calls == [] + output = strip_ansi(capsys.readouterr().out) + assert "Priority must be a positive integer" in output + assert "previous preset was removed" not in output diff --git a/tests/specify_cli/presets/test_registration.py b/tests/specify_cli/presets/test_registration.py new file mode 100644 index 0000000000..0aa4d74b34 --- /dev/null +++ b/tests/specify_cli/presets/test_registration.py @@ -0,0 +1,40 @@ +"""Registration and compatibility boundaries for ``specify preset``.""" + +from __future__ import annotations + +from specify_cli.presets import _commands +from specify_cli.presets.catalog import catalog_app + + +def test_preset_commands_registered_once_in_stable_order(): + assert [command.name for command in _commands.preset_app.registered_commands] == [ + "list", + "add", + "remove", + "update", + "search", + "resolve", + "info", + "set-priority", + "enable", + "disable", + ] + assert [group.name for group in _commands.preset_app.registered_groups] == [ + "catalog" + ] + + +def test_catalog_commands_registered_once_in_stable_order(): + assert [command.name for command in catalog_app.registered_commands] == [ + "list", + "add", + "remove", + ] + + +def test_legacy_add_import_resolves_to_extracted_handler(): + from specify_cli.presets.command_add import preset_add + + assert _commands.preset_add.__name__ == "preset_add" + assert _commands.preset_add.__module__ == "specify_cli.presets._commands" + assert callable(preset_add) diff --git a/tests/test_presets.py b/tests/test_presets.py index 93959f877b..d71bd52588 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -13,25 +13,16 @@ import pytest import io import json -import os -import shlex -import subprocess -import sys -import tempfile import tarfile import shutil -import warnings import zipfile from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone -from types import SimpleNamespace from unittest.mock import MagicMock import yaml -import typer -from tests.conftest import strip_ansi from specify_cli.presets import ( PresetManifest, PresetRegistry, @@ -45,106 +36,19 @@ VALID_PRESET_TEMPLATE_TYPES, ) from specify_cli.extensions import ExtensionRegistry -from specify_cli._console import console -from specify_cli.presets._commands import ( - _render_powershell_argv, - _warn_unmet_extension_dependencies, - preset_update, +from tests.specify_cli.presets import _fixtures +from tests.specify_cli.presets._helpers import ( + CORE_TEMPLATE_NAMES, + SELF_TEST_PRESET_DIR, + install_constitution_sync_preset, + install_self_test_preset, + make_convention_constitution_preset as _make_convention_constitution_preset, ) - -# ===== Fixtures ===== - - -@pytest.fixture -def temp_dir(): - """Create a temporary directory for tests.""" - tmpdir = tempfile.mkdtemp() - yield Path(tmpdir) - shutil.rmtree(tmpdir) - - -@pytest.fixture -def valid_pack_data(): - """Valid preset manifest data.""" - return { - "schema_version": "1.0", - "preset": { - "id": "test-pack", - "name": "Test Preset", - "version": "1.0.0", - "description": "A test preset", - "author": "Test Author", - "repository": "https://github.com/test/test-pack", - "license": "MIT", - }, - "requires": { - "speckit_version": ">=0.1.0", - }, - "provides": { - "templates": [ - { - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "description": "Custom spec template", - "replaces": "spec-template", - } - ] - }, - "tags": ["testing", "example"], - } - - -@pytest.fixture -def pack_dir(temp_dir, valid_pack_data): - """Create a complete preset directory structure.""" - p_dir = temp_dir / "test-pack" - p_dir.mkdir() - - # Write manifest - manifest_path = p_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - - # Create templates directory - templates_dir = p_dir / "templates" - templates_dir.mkdir() - - # Write template file - tmpl_file = templates_dir / "spec-template.md" - tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n") - - return p_dir - - -@pytest.fixture -def project_dir(temp_dir): - """Create a mock spec-kit project directory.""" - proj_dir = temp_dir / "project" - proj_dir.mkdir() - - # Create .specify directory - specify_dir = proj_dir / ".specify" - specify_dir.mkdir() - - # Create templates directory with core templates - templates_dir = specify_dir / "templates" - templates_dir.mkdir() - - # Create core spec-template - core_spec = templates_dir / "spec-template.md" - core_spec.write_text("# Core Spec Template\n") - - # Create core plan-template - core_plan = templates_dir / "plan-template.md" - core_plan.write_text("# Core Plan Template\n") - - # Create commands subdirectory - commands_dir = templates_dir / "commands" - commands_dir.mkdir() - - return proj_dir +temp_dir = _fixtures.temp_dir +valid_pack_data = _fixtures.valid_pack_data +pack_dir = _fixtures.pack_dir +project_dir = _fixtures.project_dir # ===== PresetManifest Tests ===== @@ -1310,24 +1214,6 @@ def test_unsatisfied_version_constraint_reports_both_versions( assert unmet[0]["installed"] == "0.1.0" assert unmet[0]["version"] == ">=9.0.0" - def test_version_warning_does_not_promise_update_satisfies_constraint(self): - """Version remediation must handle constraints update cannot guarantee.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - { - "id": "speckit-inventory", - "reason": "version", - "installed": "3.0.0", - "version": "<2", - } - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = " ".join(strip_ansi(capture.get()).split()) - assert "Needs: a release of speckit-inventory satisfying <2" in output - assert "specify extension update" not in output def test_optional_dependency_is_never_reported( self, project_dir, temp_dir, valid_pack_data @@ -1428,71 +1314,8 @@ def test_corrupted_registry_entry_with_directory_is_not_satisfied( # so a plain `extension add` would be refused as already installed. assert [dep["reason"] for dep in unmet] == ["corrupt"] - def test_missing_and_stale_warnings_mention_discovery_only_catalogs(self): - """`extension add ` is rejected for discovery-only entries, so say so.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "missing", - "installed": None, "version": None} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = strip_ansi(capture.get()) - assert "discovery-only catalog" in output - assert "--from " in output - assert "Install with: specify extension add speckit-inventory" in output - - @pytest.mark.parametrize( - "reason, extra", - [ - ("missing", {"installed": None, "version": None}), - ("stale", {"installed": "0.1.0", "version": None}), - ("disabled", {"installed": "0.1.0", "version": None}), - ("version", {"installed": "0.1.0", "version": ">=9.0.0"}), - ], - ) - def test_leading_hyphen_id_is_not_emitted_into_a_command(self, reason, extra): - """A leading-hyphen id satisfies `^[a-z0-9-]+$` but breaks the command. - - Typer would read it as an option rather than the positional extension - argument, so the advertised fix would fail. Every remedy substitutes - the placeholder `_command_safe_id` returns. The id here is deliberately - not a real flag, so a match cannot be confused with `--force` appearing - legitimately in the stale remedy. - """ - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "--not-a-real-flag", "reason": reason, **extra} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = " ".join(strip_ansi(capture.get()).split()) - # Isolate the remedy: the description line legitimately shows the raw - # id, escaped for display; only the copyable command must not carry it. - label = next( - lbl for lbl in ("Install with:", "Reinstall with:", "Enable with:", "Needs:") - if lbl in output - ) - remedy = output.split(label, 1)[1].split("The preset is installed.")[0] - assert "--not-a-real-flag" not in remedy - assert "" in remedy - - def test_version_only_warning_omits_the_discovery_only_note(self): - """The note is about installing by id, which a version mismatch does not do.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "version", - "installed": "0.1.0", "version": ">=9.0.0"} - ] - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - assert "discovery-only" not in strip_ansi(capture.get()) def test_unregistered_extension_with_corrupt_registry_is_missing( self, project_dir, temp_dir, valid_pack_data @@ -1529,20 +1352,6 @@ def test_corrupted_entry_gets_a_forced_reinstall_remedy( assert [dep["reason"] for dep in unmet] == ["corrupt"] - def test_corrupt_warning_suggests_forced_reinstall(self): - """The corrupt remedy must use --force, since the id is still registered.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "corrupt", - "installed": None, "version": None} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = strip_ansi(capture.get()) - assert "unreadable registry entry" in output - assert "Reinstall with: specify extension add speckit-inventory --force" in output def test_unreadable_registry_does_not_raise( self, project_dir, temp_dir, valid_pack_data, monkeypatch @@ -1566,36 +1375,7 @@ def _boom(*args, **kwargs): manifest ) == [] - def test_version_only_footer_does_not_claim_the_feature_is_inert(self): - """A version mismatch still invokes the extension, so wording differs.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "version", - "installed": "0.1.0", "version": ">=9.0.0"} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = " ".join(strip_ansi(capture.get()).split()) - assert "may not behave as the preset expects" in output - assert "does nothing" not in output - assert "safe to use" not in output - - def test_unavailable_footer_states_the_feature_is_inert(self): - """An unavailable extension genuinely contributes nothing.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "missing", - "installed": None, "version": None} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - output = " ".join(strip_ansi(capture.get()).split()) - assert "does nothing" in output - assert "may not behave as the preset expects" not in output def test_exact_duplicate_declarations_warn_once( self, project_dir, temp_dir, valid_pack_data @@ -1663,24 +1443,6 @@ def test_stale_is_reported_ahead_of_disabled_and_version( assert [dep["reason"] for dep in unmet] == ["stale"] - def test_stale_warning_suggests_a_forced_reinstall(self): - """The stale remedy must restore the files, not re-add a registered id.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - { - "id": "speckit-inventory", - "reason": "stale", - "installed": "0.1.0", - "version": None, - } - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = strip_ansi(capture.get()) - assert "its files are missing" in output - assert "specify extension add speckit-inventory --force" in output def test_disabled_dependency_is_reported( self, project_dir, temp_dir, valid_pack_data @@ -3871,133 +3633,10 @@ def test_default_active_catalogs(self, project_dir): assert active[1].priority == 2 assert active[1].install_allowed is False - def test_catalog_list_escapes_rich_markup(self, project_dir): - """User-editable catalog name/url/description must not be parsed as Rich markup.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - entry = PresetCatalogEntry( - url="https://example.com/[cat].json", - name="Bracket [Catalog]", - priority=1, - install_allowed=True, - description="desc [with] brackets", - ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", return_value=[entry]): - result = runner.invoke(app, ["preset", "catalog", "list"]) - assert result.exit_code == 0, result.output - assert "Bracket [Catalog]" in result.output - assert "https://example.com/[cat].json" in result.output - assert "desc [with] brackets" in result.output - - def test_catalog_add_escapes_rich_markup(self, project_dir): - """`preset catalog add` must not parse the name/url as Rich markup. - - An unbalanced closing tag raised MarkupError *after* the entry was - already written to preset-catalogs.yml, so the user saw a traceback - and no confirmation for a catalog that had in fact been added. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - name = "[/red]my-catalog" - url = "https://example.com/[bold]c.json" - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, ["preset", "catalog", "add", url, "--name", name] - ) - assert result.exit_code == 0, result.output - # Rendered verbatim, not swallowed as markup. - assert name in result.output - assert url in result.output - # Only rendering is escaped: the raw values still round-trip to disk. - config = yaml.safe_load( - (project_dir / ".specify" / "preset-catalogs.yml").read_text( - encoding="utf-8" - ) - ) - assert config["catalogs"][0]["name"] == name - assert config["catalogs"][0]["url"] == url - - def test_catalog_remove_escapes_rich_markup(self, project_dir): - """`preset catalog remove` must not parse the name as Rich markup.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - name = "[/red]my-catalog" - (project_dir / ".specify" / "preset-catalogs.yml").write_text( - yaml.dump({ - "catalogs": [ - { - "name": name, - "url": "https://example.com/c.json", - "priority": 1, - "install_allowed": False, - } - ] - }), - encoding="utf-8", - ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "catalog", "remove", name]) - assert result.exit_code == 0, result.output - assert name in result.output - - def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir): - """The not-found error path renders the name too.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - (project_dir / ".specify" / "preset-catalogs.yml").write_text( - yaml.dump({"catalogs": []}), encoding="utf-8" - ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, ["preset", "catalog", "remove", "[/red]absent"] - ) - assert result.exit_code == 1 - assert "[/red]absent" in result.output - @pytest.mark.parametrize( - "args", - [ - [ - "preset", - "catalog", - "add", - "https://example.com/catalog.json", - "--name", - "example", - ], - ["preset", "catalog", "remove", "example"], - ], - ) - def test_catalog_mutation_rejects_non_mapping_config_root( - self, project_dir, args - ): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - config_path = project_dir / ".specify" / "preset-catalogs.yml" - original = "[]\n" - config_path.write_text(original, encoding="utf-8") - with patch.object(Path, "cwd", return_value=project_dir): - result = CliRunner().invoke(app, args) - assert result.exit_code == 1 - assert "expected a mapping" in result.output - assert config_path.read_text(encoding="utf-8") == original def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" @@ -4328,75 +3967,6 @@ def test_url_cache_expired(self, project_dir): # ===== Self-Test Preset Tests ===== -SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test" -CONSTITUTION_SYNC_PRESET_DIR = ( - Path(__file__).parent.parent / "presets" / "constitution-sync" -) -SELF_TEST_WRAP_WARNING = ( - r"Cannot compose command 'speckit\.wrap-test': no base layer\. " - r"Stale command files may remain\." -) - -CORE_TEMPLATE_NAMES = [ - "spec-template", - "plan-template", - "tasks-template", - "checklist-template", - "constitution-template", -] - - -def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1.5") -> PresetManifest: - """Install self-test while filtering its intentionally missing wrap base.""" - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - message=SELF_TEST_WRAP_WARNING, - category=UserWarning, - module=r"specify_cli\.presets", - ) - return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version) - - -def install_constitution_sync_preset(manager: PresetManager) -> PresetManifest: - """Enable guarded install-time constitution materialization.""" - return manager.install_from_directory(CONSTITUTION_SYNC_PRESET_DIR, "0.15.0") - - -def _make_convention_constitution_preset(temp_dir: Path) -> Path: - """Create a preset whose constitution is found by convention, not its manifest.""" - preset_dir = temp_dir / "convention-constitution" - (preset_dir / "templates").mkdir(parents=True) - (preset_dir / "templates" / "constitution-template.md").write_text( - "# Convention Constitution\n" - ) - (preset_dir / "templates" / "spec-template.md").write_text("# Spec\n") - (preset_dir / "preset.yml").write_text( - yaml.dump( - { - "schema_version": "1.0", - "preset": { - "id": "convention-constitution", - "name": "Convention Constitution", - "version": "1.0.0", - "description": "Convention-based constitution for testing", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - } - ] - }, - } - ) - ) - return preset_dir - - class TestSelfTestPreset: """Tests using the self-test preset that ships with the repo. @@ -10899,145 +10469,6 @@ def test_short_and_namespaced_commands_scaffold_consistently( assert manager.registry.get("ns-cmd")["registered_commands"] != {} -class TestPresetSetPriority: - """Test preset set-priority CLI command.""" - - def test_set_priority_changes_priority(self, project_dir, pack_dir): - """Test set-priority command changes preset priority.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset with default priority - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - # Verify default priority - assert manager.registry.get("test-pack")["priority"] == 10 - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "priority changed: 10 → 5" in plain - - # Reload registry to see updated value - manager2 = PresetManager(project_dir) - assert manager2.registry.get("test-pack")["priority"] == 5 - - def test_set_priority_reconciles_generated_constitution( - self, project_dir, temp_dir - ): - """Changing priority rematerializes an unchanged generated constitution.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - manager.install_from_directory( - _make_convention_constitution_preset(temp_dir), "0.1.5", priority=20 - ) - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert "preset:self-test" in memory.read_text() - - with patch.object(Path, "cwd", return_value=project_dir): - result = CliRunner().invoke( - app, - ["preset", "set-priority", "convention-constitution", "1"], - ) - - assert result.exit_code == 0, result.output - assert memory.read_text() == "# Convention Constitution\n" - - def test_set_priority_same_value_no_change(self, project_dir, pack_dir): - """Test set-priority with same value shows already set message.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset with priority 5 - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5", priority=5) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "already has priority 5" in plain - - def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir): - """A corrupted boolean priority must be repaired, not skipped. - - ``isinstance(True, int)`` is True and ``True == 1`` in Python, so a - stored ``True`` priority would short-circuit the ``already has - priority 1`` skip path and never get rewritten to a real int — - contradicting the comment that promises corrupted values are - repaired. The guard must exclude bools (like normalize_priority). - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5", priority=5) - # Inject a corrupted boolean priority (True == 1). - manager.registry.update("test-pack", {"priority": True}) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - # The corrupted bool must be repaired, not reported as already-set. - assert "already has priority" not in plain - assert "priority changed" in plain - - # The stored value is now a real int, not a bool. - reloaded = PresetManager(project_dir).registry.get("test-pack") - assert reloaded["priority"] == 1 - assert not isinstance(reloaded["priority"], bool) - - def test_set_priority_invalid_value(self, project_dir, pack_dir): - """Test set-priority rejects invalid priority values.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"]) - - assert result.exit_code == 1, result.output - assert "Priority must be a positive integer" in result.output - - def test_set_priority_not_installed(self, project_dir): - """Test set-priority fails for non-installed preset.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"]) - - assert result.exit_code == 1, result.output - assert "not installed" in result.output.lower() class TestPresetPriorityBackwardsCompatibility: @@ -11123,177 +10554,13 @@ def test_mixed_legacy_and_new_presets_ordering(self, temp_dir): class TestPresetEnableDisable: """Test preset enable/disable CLI commands.""" - def test_disable_preset(self, project_dir, pack_dir): - """Test disable command sets enabled=False.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - # Verify initially enabled - assert manager.registry.get("test-pack").get("enabled", True) is True - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "test-pack"]) - - assert result.exit_code == 0, result.output - assert "disabled" in result.output.lower() - - # Reload registry to see updated value - manager2 = PresetManager(project_dir) - assert manager2.registry.get("test-pack")["enabled"] is False - - def test_enable_preset(self, project_dir, pack_dir): - """Test enable command sets enabled=True.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset and disable it - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.update("test-pack", {"enabled": False}) - - # Verify disabled - assert manager.registry.get("test-pack")["enabled"] is False - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "enable", "test-pack"]) - assert result.exit_code == 0, result.output - assert "enabled" in result.output.lower() - # Reload registry to see updated value - manager2 = PresetManager(project_dir) - assert manager2.registry.get("test-pack")["enabled"] is True - def test_enable_disable_reconciles_generated_constitution( - self, project_dir, temp_dir - ): - """Enable and disable rematerialize the winning constitution layer.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - manager.install_from_directory( - _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 - ) - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert memory.read_text() == "# Convention Constitution\n" - runner = CliRunner() - - with patch.object(Path, "cwd", return_value=project_dir): - disabled = runner.invoke( - app, ["preset", "disable", "convention-constitution"] - ) - - assert disabled.exit_code == 0, disabled.output - assert "preset:self-test" in memory.read_text() - - with patch.object(Path, "cwd", return_value=project_dir): - enabled = runner.invoke( - app, ["preset", "enable", "convention-constitution"] - ) - - assert enabled.exit_code == 0, enabled.output - assert memory.read_text() == "# Convention Constitution\n" - - def test_stack_changes_do_not_create_missing_constitution( - self, project_dir, pack_dir - ): - """Stack changes for non-providers do not seed a missing constitution.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5") - memory = project_dir / ".specify" / "memory" / "constitution.md" - runner = CliRunner() - - for args in ( - ["preset", "set-priority", "test-pack", "5"], - ["preset", "disable", "test-pack"], - ["preset", "enable", "test-pack"], - ): - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, args) - assert result.exit_code == 0, result.output - assert not memory.exists() - - def test_disable_already_disabled(self, project_dir, pack_dir): - """Test disable on already disabled preset shows warning.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset and disable it - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.update("test-pack", {"enabled": False}) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "test-pack"]) - - assert result.exit_code == 0, result.output - assert "already disabled" in result.output.lower() - - def test_enable_already_enabled(self, project_dir, pack_dir): - """Test enable on already enabled preset shows warning.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset (enabled by default) - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "enable", "test-pack"]) - - assert result.exit_code == 0, result.output - assert "already enabled" in result.output.lower() - - def test_disable_not_installed(self, project_dir): - """Test disable fails for non-installed preset.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "nonexistent"]) - - assert result.exit_code == 1, result.output - assert "not installed" in result.output.lower() - def test_enable_not_installed(self, project_dir): - """Test enable fails for non-installed preset.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "enable", "nonexistent"]) - assert result.exit_code == 1, result.output - assert "not installed" in result.output.lower() def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir): """Test that disabled presets are excluded from template resolution.""" @@ -11321,54 +10588,16 @@ def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir): result2 = resolver2.resolve("test-template", "template") assert result2 is None - def test_enable_corrupted_registry_entry(self, project_dir, pack_dir): - """Test enable fails gracefully for corrupted registry entry.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset then corrupt the registry entry - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.data["presets"]["test-pack"] = "corrupted-string" - manager.registry._save() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "enable", "test-pack"]) - assert result.exit_code == 1 - assert "corrupted state" in result.output.lower() - def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): - """Test disable fails gracefully for corrupted registry entry.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app +# ===== Lean Preset Tests ===== - runner = CliRunner() - # Install preset then corrupt the registry entry - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.data["presets"]["test-pack"] = "corrupted-string" - manager.registry._save() - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "test-pack"]) - - assert result.exit_code == 1 - assert "corrupted state" in result.output.lower() - - -# ===== Lean Preset Tests ===== - - -LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" -CORE_CONSTITUTION_COMMAND = ( - Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" -) +LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" +CORE_CONSTITUTION_COMMAND = ( + Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" +) LEAN_COMMAND_NAMES = [ "speckit.specify", @@ -11493,256 +10722,6 @@ def test_lean_overrides_commands(self, project_dir): assert result is not None, f"Lean override for {name} not resolved" -# ===== Preset Update Command Tests ===== - - -class TestPresetUpdateCommand: - """Test the destructive remove-then-add update contract.""" - - @staticmethod - def _manager(monkeypatch, project_dir, installed=True): - from specify_cli.presets import _commands as commands - - registry = SimpleNamespace(is_installed=lambda _preset_id: installed) - manager = SimpleNamespace(registry=registry) - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.presets.PresetManager", lambda _root: manager) - return commands - - def test_unknown_preset_fails_without_remove_or_add(self, project_dir, monkeypatch): - commands = self._manager(monkeypatch, project_dir, installed=False) - calls = [] - monkeypatch.setattr(commands, "preset_remove", lambda *_args: calls.append("remove")) - monkeypatch.setattr(commands, "preset_add", lambda **_kwargs: calls.append("add")) - - with pytest.raises(typer.Exit) as exc_info: - preset_update("missing", from_url=None, dev=None, priority=10) - - assert exc_info.value.exit_code == 1 - assert calls == [] - - @pytest.mark.parametrize( - ("from_url", "dev"), - [ - ("https://example.com/preset.zip", "./preset"), - ("", "./preset"), - ("https://example.com/preset.zip", ""), - ], - ) - def test_mutually_exclusive_sources_are_rejected( - self, project_dir, monkeypatch, from_url, dev - ): - commands = self._manager(monkeypatch, project_dir) - calls = [] - monkeypatch.setattr(commands, "preset_remove", lambda *_args: calls.append("remove")) - monkeypatch.setattr(commands, "preset_add", lambda **_kwargs: calls.append("add")) - - with pytest.raises(typer.Exit) as exc_info: - preset_update( - "test-pack", - from_url=from_url, - dev=dev, - priority=10, - ) - - assert exc_info.value.exit_code == 1 - assert calls == [] - - @pytest.mark.parametrize( - ("from_url", "dev", "option"), - [("", None, "--from"), (None, "", "--dev")], - ) - def test_empty_source_is_rejected_before_removal( - self, project_dir, monkeypatch, capsys, from_url, dev, option - ): - commands = self._manager(monkeypatch, project_dir) - calls = [] - monkeypatch.setattr(commands, "preset_remove", lambda *_args: calls.append("remove")) - monkeypatch.setattr(commands, "preset_add", lambda **_kwargs: calls.append("add")) - - with pytest.raises(typer.Exit) as exc_info: - preset_update( - "test-pack", - from_url=from_url, - dev=dev, - priority=10, - ) - - assert exc_info.value.exit_code == 1 - assert calls == [] - assert f"{option} must not be empty" in strip_ansi(capsys.readouterr().out) - - def test_remove_failure_prevents_add(self, project_dir, monkeypatch): - commands = self._manager(monkeypatch, project_dir) - calls = [] - - def fail_remove(_preset_id): - calls.append("remove") - raise typer.Exit(1) - - monkeypatch.setattr(commands, "preset_remove", fail_remove) - monkeypatch.setattr(commands, "preset_add", lambda **_kwargs: calls.append("add")) - - with pytest.raises(typer.Exit) as exc_info: - preset_update("test-pack", from_url=None, dev=None, priority=10) - - assert exc_info.value.exit_code == 1 - assert calls == ["remove"] - - def test_update_forwards_id_sources_and_priority_to_add(self, project_dir, monkeypatch): - commands = self._manager(monkeypatch, project_dir) - calls = [] - monkeypatch.setattr(commands, "preset_remove", lambda preset_id: calls.append(("remove", preset_id))) - monkeypatch.setattr( - commands, - "preset_add", - lambda **kwargs: calls.append(("add", kwargs)), - ) - - preset_update( - "test-pack", - from_url="https://example.com/replacement.zip", - dev=None, - priority=4, - ) - - assert calls == [ - ("remove", "test-pack"), - ( - "add", - { - "preset_id": "test-pack", - "from_url": "https://example.com/replacement.zip", - "dev": None, - "priority": 4, - }, - ), - ] - - def test_add_failure_states_removed_and_prints_retry_command( - self, project_dir, monkeypatch, capsys - ): - commands = self._manager(monkeypatch, project_dir) - monkeypatch.setattr(commands, "preset_remove", lambda _preset_id: None) - - def fail_add(**_kwargs): - raise typer.Exit(1) - - monkeypatch.setattr(commands, "preset_add", fail_add) - - with pytest.raises(typer.Exit) as exc_info: - preset_update( - "test-pack", - from_url=None, - dev="/tmp/replacement preset", - priority=6, - ) - - assert exc_info.value.exit_code == 1 - output = strip_ansi(capsys.readouterr().out) - assert "previous preset was removed" in output - retry_args = [ - "specify", - "preset", - "add", - "test-pack", - "--dev", - "/tmp/replacement preset", - "--priority", - "6", - ] - expected = ( - _render_powershell_argv(retry_args) - if os.name == "nt" - else shlex.join(retry_args) - ) - assert expected in output - - def test_retry_command_quotes_powershell_metacharacters( - self, project_dir, monkeypatch, capsys - ): - """Windows retry commands keep PowerShell metacharacters literal.""" - commands = self._manager(monkeypatch, project_dir) - monkeypatch.setattr(commands, "preset_remove", lambda _preset_id: None) - - def fail_add(**_kwargs): - raise typer.Exit(1) - - monkeypatch.setattr(commands, "preset_add", fail_add) - monkeypatch.setattr(os, "name", "nt") - - with pytest.raises(typer.Exit) as exc_info: - preset_update( - "test-pack", - from_url=None, - dev=r"C:\replacement&$backup's presets", - priority=6, - ) - - assert exc_info.value.exit_code == 1 - output = strip_ansi(capsys.readouterr().out) - expected = ( - "& 'specify' 'preset' 'add' 'test-pack' '--dev' " - "'C:\\replacement&$backup''s presets' '--priority' '6'" - ) - assert "Retry in PowerShell: " in output - assert expected in output - - def test_powershell_retry_renderer_preserves_literal_arguments(self): - """The rendered command survives parsing by a real PowerShell.""" - powershell = shutil.which("pwsh") or shutil.which("powershell") - if powershell is None: - pytest.skip("PowerShell is not available") - - arguments = [ - "https://example.com/archive.zip?one=1&two=$value", - r"C:\owner's presets", - ] - rendered = _render_powershell_argv( - [ - sys.executable, - "-c", - "import json,sys; print(json.dumps(sys.argv[1:]))", - *arguments, - ] - ) - result = subprocess.run( - [powershell, "-NoProfile", "-Command", rendered], - check=True, - capture_output=True, - text=True, - ) - - assert json.loads(result.stdout) == arguments - - def test_invalid_priority_rejected_before_removal( - self, project_dir, monkeypatch, capsys - ): - """--priority 0 must fail without removing the installed preset. - - add rejects the same range, but only after remove has run. Validating - late would delete the preset and print a retry command carrying the - rejected priority, so the retry could never succeed. - """ - commands = self._manager(monkeypatch, project_dir) - calls = [] - monkeypatch.setattr( - commands, "preset_remove", lambda preset_id: calls.append("remove") - ) - monkeypatch.setattr( - commands, "preset_add", lambda **_kwargs: calls.append("add") - ) - - with pytest.raises(typer.Exit) as exc_info: - preset_update("test-pack", from_url=None, dev=None, priority=0) - - assert exc_info.value.exit_code == 1 - assert calls == [] - output = strip_ansi(capsys.readouterr().out) - assert "Priority must be a positive integer" in output - assert "previous preset was removed" not in output - - # ===== Bundled Preset Locator Tests ===== @@ -11772,393 +10751,17 @@ def test_locate_bundled_preset_rejects_invalid_id(self): assert _locate_bundled_preset("UPPERCASE") is None assert _locate_bundled_preset("has spaces") is None - def test_bundled_preset_add_via_cli(self, project_dir): - """Test that 'specify preset add lean' installs the bundled preset.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="0.6.0"): - result = runner.invoke(app, ["preset", "add", "lean"]) - - assert result.exit_code == 0, result.output - 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 - from specify_cli.presets._commands import preset_add - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def geturl(self): - return "http://example.com/preset.zip" - - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - assert redirect_validator is not None - redirect_validator(url, "http://example.com/preset.zip") - return FakeResponse(b"zip") - - monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) - - installed = False - - def fake_install_from_zip(self, zip_path, speckit_version, priority=10): - nonlocal installed - installed = True - - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - - with pytest.raises(typer.Exit) as exc_info: - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) - - assert exc_info.value.exit_code == 1 - assert installed is False - - def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir): - """URL installs reject HTTPS URLs without a hostname before downloading.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"]) - - assert result.exit_code == 1 - output = strip_ansi(result.output) - assert "URL must use HTTPS with a hostname" in output - assert "got https://" not in output - open_url.assert_not_called() - - def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir): - """A malformed IPv6 URL must produce a clean error, not a ValueError traceback.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://[::1/preset.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Invalid URL" in output - open_url.assert_not_called() - - def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir): - """A bracketed-but-invalid IPv6 host in --from must exit cleanly. - "https://[not-an-ip]/preset.zip" is a malformed authority that raises - ValueError during URL validation; the try/except guard around parsing - and the .hostname read must turn that into a clean "Invalid URL" message. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://[not-an-ip]/preset.zip"], - catch_exceptions=True, - ) - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Invalid URL" in output - open_url.assert_not_called() - def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir): - """An out-of-range port raises ValueError lazily on .port access. - The up-front guard reads ``_parsed.port`` (urllib validates the port - range/syntax there) inside its try/except, so "https://example.com:99999/ - preset.zip" must produce a clean "Invalid URL" message rather than - leaking a raw ValueError traceback past the CLI. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://example.com:99999/preset.zip"], - catch_exceptions=True, - ) - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - assert "Invalid URL" in strip_ansi(result.output) - open_url.assert_not_called() - def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir): - """A catalog download_url with a bracketed non-IP host must render cleanly. - ``download_pack`` raises ``PresetError`` whose message embeds the raw URL - (e.g. ``https://[not-an-ip]/x``). The ``preset_add`` handler must escape - that message before printing so Rich does not interpret ``[not-an-ip]`` - as a markup tag and crash while rendering the error. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - bad_url = "https://[not-an-ip]/x" - catalog_data = { - "test-pack": { - "name": "Test Pack", - "version": "1.0.0", - "download_url": bad_url, - } - } - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "_get_merged_packs", return_value=catalog_data): - result = runner.invoke( - app, - ["preset", "add", "test-pack"], - catch_exceptions=True, - ) - assert result.exit_code == 1, result.output - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Error:" in output - # The malformed URL surfaces verbatim rather than crashing the renderer. - assert bad_url in output - @pytest.mark.parametrize( - ("exc_type", "label"), - [ - (PresetCompatibilityError, "Compatibility Error"), - (PresetValidationError, "Validation Error"), - (PresetError, "Error"), - ], - ) - def test_preset_add_exception_handlers_escape_markup(self, project_dir, exc_type, label): - """Preset install exceptions can include catalog-controlled values. - - The message must be escaped so Rich does not treat bracketed content as - markup and raise while rendering the error. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - dev_dir = project_dir / "dev-pack" - dev_dir.mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object( - PresetManager, - "install_from_directory", - side_effect=exc_type("bad [red]preset[/red]"), - ): - result = runner.invoke( - app, - ["preset", "add", "--dev", str(dev_dir)], - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - assert result.exception is None or isinstance(result.exception, SystemExit) - assert f"{label}:" in result.output - assert "bad [red]preset[/red]" in result.output - - def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys): - """Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs.""" - import typer - from specify_cli.presets._commands import preset_add - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def geturl(self): - return "https:///preset.zip" - - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"), - ) - monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None) - - with pytest.raises(typer.Exit) as exc_info: - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) - - assert exc_info.value.exit_code == 1 - output = strip_ansi(capsys.readouterr().out) - assert "redirected to a disallowed URL" in output - assert "must use HTTPS with a hostname" in output - - def test_preset_add_from_url_reads_in_bounded_chunks(self, project_dir, monkeypatch): - """URL installs read the response in bounded chunks.""" - from specify_cli.presets._commands import preset_add - - class FakeResponse(io.BytesIO): - def __init__(self, data): - super().__init__(data) - self.read_sizes = [] - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def geturl(self): - return "https://example.com/preset.zip" - - def read(self, size=-1): - assert size not in (-1, None) - self.read_sizes.append(size) - return super().read(size) - - response = FakeResponse(b"PK\x05\x06" + b"\x00" * 18) - installed = {} - - def fake_install_from_zip(self, zip_path, speckit_version, priority=10): - installed["zip_bytes"] = Path(zip_path).read_bytes() - installed["speckit_version"] = speckit_version - installed["priority"] = priority - return SimpleNamespace(name="Test Preset", version="1.0.0") - - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda url, timeout=None, extra_headers=None, redirect_validator=None: response, - ) - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7) - - assert response.read_sizes - assert installed == { - "zip_bytes": b"PK\x05\x06" + b"\x00" * 18, - "speckit_version": "0.6.0", - "priority": 7, - } - - def test_preset_add_from_url_rejects_oversized_download( - self, project_dir, monkeypatch, capsys - ): - """An oversized direct download fails before preset installation.""" - import typer - from specify_cli._download_security import ( - read_response_limited as real_read_response_limited, - ) - from specify_cli.presets import _commands as preset_commands - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def geturl(self): - return "https://example.com/preset.zip" - - def read_with_tiny_limit(response, **kwargs): - kwargs.pop("max_bytes", None) - return real_read_response_limited(response, max_bytes=4, **kwargs) - - installed = False - - def fake_install_from_zip(*_args, **_kwargs): - nonlocal installed - installed = True - - monkeypatch.setattr( - preset_commands, - "read_response_limited", - read_with_tiny_limit, - ) - monkeypatch.setattr( - "specify_cli._require_specify_project", - lambda: project_dir, - ) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda *_args, **_kwargs: FakeResponse(b"12345"), - ) - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - - with pytest.raises(typer.Exit) as exc_info: - preset_commands.preset_add( - preset_id=None, - from_url="https://example.com/preset.zip", - dev=None, - priority=10, - ) - - assert exc_info.value.exit_code == 1 - output = " ".join(strip_ansi(capsys.readouterr().out).split()) - assert "exceeds maximum size of 4 bytes" in output - assert installed is False def test_bundled_preset_in_catalog(self): """Verify the lean preset is listed in catalog.json with bundled marker.""" @@ -12184,171 +10787,8 @@ def test_bundled_preset_download_raises_error(self, project_dir): with pytest.raises(PresetError, match="bundled with spec-kit"): catalog.download_pack("test-bundled") - def test_bundled_preset_missing_locally_cli_error(self, project_dir): - """CLI shows clear error when bundled preset cannot be found locally.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - # Patch _locate_bundled_preset to return None (simulating missing files) - # and mock the catalog to return a bundled entry for "lean" - fake_pack_info = { - "id": "lean", - "name": "Lean Workflow", - "version": "1.0.0", - "bundled": True, - "_install_allowed": True, - } - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli._locate_bundled_preset", return_value=None), \ - patch("specify_cli.presets.PresetCatalog") as MockCatalog: - MockCatalog.return_value.get_pack_info.return_value = fake_pack_info - result = runner.invoke(app, ["preset", "add", "lean"]) - - # Should fail with a helpful error explaining this is a bundled preset - # and suggesting how to recover. - assert result.exit_code == 1 - output = strip_ansi(result.output).lower() - assert "bundled" in output, result.output - assert "reinstall" in output, result.output - - -class TestPresetAddFromUrlResolution: - """CLI-level tests for preset add --from GitHub release resolution.""" - - def test_preset_add_from_github_release_url_resolves_and_downloads(self, project_dir): - """'preset add --from ' resolves to API asset URL.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = __import__("io").BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() - - captured_urls = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - if "releases/tags/" in url: - return io.BytesIO(json.dumps({ - "assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}] - }).encode()) - return io.BytesIO(zip_bytes) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://github.com/org/repo/releases/download/v1.0/preset.zip", - ]) - - assert result.exit_code == 0, result.output - assert "My Preset" in result.output - # First call should resolve the release tag - assert any("releases/tags/v1.0" in url for url, _ in captured_urls) - # Second call should download from the resolved asset URL with octet-stream - asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] - assert len(asset_calls) >= 1 - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir): - """'preset add --from ' uses URL directly with octet-stream.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = __import__("io").BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() - - captured_urls = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - return io.BytesIO(zip_bytes) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://api.github.com/repos/org/repo/releases/assets/42", - ]) - - assert result.exit_code == 0, result.output - # Should go directly to the asset URL with Accept header - assert len(captured_urls) == 1 - assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42" - assert captured_urls[0][1] == {"Accept": "application/octet-stream"} - - def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): - """'preset add --from ' resolves via GHES /api/v3 endpoint.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - from specify_cli.authentication import http as _auth_http - from specify_cli.authentication.config import AuthConfigEntry - - monkeypatch.setattr(_auth_http, "_config_override", [ - AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), - ]) - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = io.BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() - captured_urls = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - if "releases/tags/" in url: - return io.BytesIO(json.dumps({ - "assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] - }).encode()) - return io.BytesIO(zip_bytes) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip", - ]) - - assert result.exit_code == 0, result.output - # The tag-lookup call must use the GHES /api/v3 endpoint - assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) - # The asset download call must carry Accept: application/octet-stream - asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] - assert len(asset_calls) >= 1 - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} class TestWrapStrategy: @@ -14353,559 +12793,12 @@ def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir): assert "[PROJECT_NAME]" in content -class TestPresetTagsNonString: - """Non-string catalog tags must not crash preset display commands. - Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain - numbers or other non-strings. The display path joins them; a raw - ``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``. - Sibling command surfaces (extensions/integrations/workflows) already guard - this with ``str(t) for t in ...`` — presets must match. - """ - def _seed_catalog(self, project_dir, tags, extra=None): - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - pack = { - "name": "Numeric Tags", - "description": "Preset with non-string tags", - "version": "1.0.0", - "tags": tags, - } - if extra: - pack.update(extra) - catalog_data = { - "schema_version": "1.0", - "presets": { - "numeric-tags": pack, - }, - } - catalog.cache_file.write_text(json.dumps(catalog_data)) - catalog.cache_metadata_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - })) - return catalog - - def test_search_renders_non_string_tags(self, project_dir): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, [1, 2]) - default_only = [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only): - result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "Tags: 1, 2" in plain - - def test_info_renders_non_string_tags(self, project_dir): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, [1, 2]) - default_only = [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only): - result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "Tags: 1, 2" in plain - - def _default_only(self, catalog): - return [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] - - def test_search_by_author_tolerates_non_string_author(self, project_dir): - """``--author`` must not crash on a numeric catalog ``author``. - - ``PresetCatalog.search`` called ``.lower()`` straight on the raw value, - raising ``AttributeError: 'int' object has no attribute 'lower'``. The - sibling extension/integration catalogs coerce with ``str(...)`` first. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - catalog = self._seed_catalog(project_dir, ["ci"], extra={"author": 789}) - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "--author", "789"]) - assert result.exit_code == 0, result.output - assert "Numeric Tags" in strip_ansi(result.output) - def test_search_query_tolerates_non_string_name_and_description(self, project_dir): - """A query search must not crash on numeric ``name``/``description``. - - The searchable-text join passed the raw values through, raising - ``TypeError: sequence item 0: expected str instance, int found``. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog( - project_dir, ["ci"], extra={"name": 123, "description": 456} - ) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "123"]) - - assert result.exit_code == 0, result.output - assert "numeric-tags" in strip_ansi(result.output) - - def test_search_tolerates_non_list_tags(self, project_dir): - """A scalar ``tags:`` value must not crash the tag filter or display. - - ``tags: 5`` is truthy but not iterable, so both the ``--tag`` filter and - the result-display join raised ``TypeError: 'int' object is not - iterable``. Siblings guard with ``isinstance(raw_tags, list)``. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, 5) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - filtered = CliRunner().invoke(app, ["preset", "search", "--tag", "ci"]) - displayed = CliRunner().invoke(app, ["preset", "search", "Numeric"]) - - assert filtered.exit_code == 0, filtered.output - assert "No presets found" in strip_ansi(filtered.output) - - assert displayed.exit_code == 0, displayed.output - plain = strip_ansi(displayed.output) - assert "Numeric Tags" in plain - assert "Tags:" not in plain - - def test_info_tolerates_non_list_tags(self, project_dir): - """``preset info`` must not crash rendering a scalar ``tags:`` value.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, 5) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "numeric-tags" in plain - assert "Tags:" not in plain - - def test_search_escapes_rich_markup_in_tags(self, project_dir): - """Bracketed tag text must survive Rich markup parsing. - - ``preset search`` printed tags unescaped, so a tag like ``[bold]`` was - swallowed as a style tag. ``preset list`` already escaped this. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, ["[bold]ci"]) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) - - assert result.exit_code == 0, result.output - assert "[bold]ci" in strip_ansi(result.output) - - -class TestPresetCatalogRichMarkup: - """Catalog metadata must render as literal text in Rich output.""" - - MARKUP_PRESET = { - "id": "[red]markup-id[/red]", - "name": "[green]Markup Name[/green]", - "version": "[blue]1.0.0[/blue]", - "description": "[yellow]Markup Description[/yellow]", - "author": "[magenta]Markup Author[/magenta]", - "tags": ["[italic]markup-tag[/italic]"], - "repository": "[bold]Markup Repository[/bold]", - "license": "[cyan]Markup License[/cyan]", - } - - def test_search_escapes_catalog_markup(self, project_dir): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - with patch.object(Path, "cwd", return_value=project_dir), patch.object( - PresetCatalog, - "search", - return_value=[self.MARKUP_PRESET], - ): - result = CliRunner().invoke(app, ["preset", "search"]) - - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for value in ( - self.MARKUP_PRESET["id"], - self.MARKUP_PRESET["name"], - self.MARKUP_PRESET["version"], - self.MARKUP_PRESET["description"], - ): - assert value in output - - def test_info_escapes_catalog_markup(self, project_dir): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - with patch.object(Path, "cwd", return_value=project_dir), patch.object( - PresetCatalog, - "get_pack_info", - return_value=self.MARKUP_PRESET, - ): - result = CliRunner().invoke( - app, - ["preset", "info", self.MARKUP_PRESET["id"]], - ) - - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for field in ( - "id", - "name", - "version", - "description", - "author", - "repository", - "license", - ): - value = self.MARKUP_PRESET[field] - assert value in output - # Tags are joined into a single line, so assert on the rendered join. - assert ", ".join(self.MARKUP_PRESET["tags"]) in output - - -class TestInstalledPresetRichMarkup: - """Locally installed preset metadata must render as literal text. - - ``preset.yml`` is user-editable, so its fields can contain ``[...]``. - ``TestPresetCatalogRichMarkup`` covers the catalog branch of these - commands; the installed-preset branch of ``preset list``/``preset info`` - and all of ``preset resolve`` were left unescaped, so a field like - ``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an - unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``, - aborting the command with a traceback. - """ - - MARKUP_FIELDS = { - "name": "[green]Markup Name[/green]", - "version": "1.0.0", - "description": "[yellow]Markup Description[/yellow]", - "author": "[magenta]Markup Author[/magenta]", - "repository": "[bold]Markup Repository[/bold]", - "license": "[cyan]Markup License[/cyan]", - } - - def _install(self, temp_dir, project_dir, preset_overrides=None, strategy=None, - pack_id="markup-pack", priority=10, tmpl_description=None): - """Install a preset from a directory built with the given manifest fields.""" - from specify_cli.presets import PresetManager - - src = temp_dir / f"src-{pack_id}" - (src / "templates").mkdir(parents=True) - (src / "templates" / "spec-template.md").write_text("# tmpl\n") - - preset_section = { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": "plain description", - } - preset_section.update(preset_overrides or {}) - tmpl = { - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - } - if tmpl_description is not None: - tmpl["description"] = tmpl_description - if strategy: - tmpl["strategy"] = strategy - (src / "preset.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "preset": preset_section, - "requires": {"speckit_version": ">=0.0.1"}, - "provides": {"templates": [tmpl]}, - "tags": ["[italic]markup-tag[/italic]"], - })) - - manager = PresetManager(project_dir) - manager.install_from_directory(src, "9.9.9", priority) - return manager - - def _invoke(self, project_dir, args): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - with patch.object(Path, "cwd", return_value=project_dir): - return CliRunner().invoke(app, args) - - def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir): - """Every ``preset.yml`` field must survive verbatim in list/info output.""" - self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS) - - for args in (["preset", "list"], ["preset", "info", "markup-pack"]): - result = self._invoke(project_dir, args) - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - # `preset list` does not render repository/license. - fields = ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS - for field in fields: - assert self.MARKUP_FIELDS[field] in output, (field, args, output) - assert "[italic]markup-tag[/italic]" in output, (args, output) - - def test_info_does_not_swallow_template_description(self, temp_dir, project_dir): - """The per-template line in ``preset info`` must escape the template description. - - ``name``/``type`` are format-restricted by manifest validation, but - ``description`` is free-form, so it is the field that can carry markup. - """ - self._install( - temp_dir, - project_dir, - tmpl_description="Template [desc] here", - ) - result = self._invoke(project_dir, ["preset", "info", "markup-pack"]) - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - assert "spec-template (template): Template [desc] here" in output, output - - def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir): - """An unbalanced tag must not raise MarkupError and abort the command.""" - self._install( - temp_dir, - project_dir, - preset_overrides={"description": "Broken [/red] tag"}, - ) - - for args in (["preset", "list"], ["preset", "info", "markup-pack"]): - result = self._invoke(project_dir, args) - assert result.exit_code == 0, (args, result.output, result.exception) - assert "Broken [/red] tag" in strip_ansi(result.output) - - def test_resolve_rejects_invalid_template_name(self, project_dir): - """``preset resolve`` rejects names before joining them into paths.""" - result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"]) - assert result.exit_code == 1, (result.output, result.exception) - assert "invalid template name" in strip_ansi(result.output) - - def test_resolve_rejects_path_traversal(self, project_dir): - """The resolver rejects traversal before joining names into paths.""" - result = self._invoke( - project_dir, - ["preset", "resolve", "../../../README"], - ) - - assert result.exit_code == 1 - assert "invalid template name" in strip_ansi(result.output) - - def test_resolve_accepts_dotted_command_name(self, project_dir): - """Documented dotted command identifiers use command resolution.""" - result = self._invoke( - project_dir, - ["preset", "resolve", "speckit.constitution"], - ) - - assert result.exit_code == 0, (result.output, result.exception) - assert "constitution.md" in "".join(strip_ansi(result.output).split()) - - def test_resolve_rejects_empty_command_segments(self, project_dir): - """Dotted command identifiers cannot contain empty path-like segments.""" - result = self._invoke( - project_dir, - ["preset", "resolve", "speckit..constitution"], - ) - - assert result.exit_code == 1 - assert "invalid template name" in strip_ansi(result.output) - - def test_resolve_escapes_layer_path_and_source(self, project_dir): - """The top-layer path/source lines must render markup literally. - - A preset can be installed from any directory, so the resolved path can - contain ``[...]``; the layer source carries the pack id and version. - """ - from unittest.mock import patch - from specify_cli.presets import PresetResolver - - # A closing tag cannot live inside a path segment: `Path` treats its - # `/` as a separator on POSIX and rewrites it to `\` on Windows. The - # opening tag covers the swallowing case for the path; the unbalanced - # closing tag rides on `source`, which is a plain string. - layer = { - "path": Path("/tmp/[red]dir/spec-template.md"), - "source": "pack [/red] v1.0.0", - "strategy": "replace", - } - with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "[red]dir" in output, output - assert "pack [/red] v1.0.0" in output, output - - def test_resolve_escapes_fallback_path_and_source(self, project_dir): - """The no-layer fallback branch must escape ``resolve_with_source`` output.""" - from unittest.mock import patch - from specify_cli.presets import PresetResolver - - with patch.object( - PresetResolver, "collect_all_layers", return_value=[] - ), patch.object( - PresetResolver, - "resolve_with_source", - return_value={ - "path": "/tmp/[blue]fallback[/blue]/spec-template.md", - "source": "fallback [/red] source", - }, - ): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "[blue]fallback[/blue]" in output, output - assert "fallback [/red] source" in output, output - - def test_resolve_escapes_composition_error(self, project_dir): - """A composition exception message must not be parsed as markup.""" - from unittest.mock import patch - from specify_cli.presets import PresetResolver - - layers = [ - { - "path": Path("/tmp/top/spec-template.md"), - "source": "top-pack v1.0.0", - "strategy": "append", - }, - { - "path": Path("/tmp/base/spec-template.md"), - "source": "base-pack v1.0.0", - "strategy": "append", - }, - ] - with patch.object( - PresetResolver, "collect_all_layers", return_value=layers - ), patch.object( - PresetResolver, - "resolve_content", - side_effect=RuntimeError("compose failed: [/red] bad layer"), - ): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "compose failed: [/red] bad layer" in output, output - - def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir): - """The composition chain's ``[]`` label must not be eaten as a tag.""" - self._install(temp_dir, project_dir, strategy="replace", - pack_id="base-pack", priority=20) - self._install(temp_dir, project_dir, strategy="append", - pack_id="app-pack", priority=5) - - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - assert result.exit_code == 0, (result.output, result.exception) - output = strip_ansi(result.output) - assert "Composition chain" in output, output - assert "[base]" in output, output - assert "[append]" in output, output - - -class TestPresetListOrdering: - """``preset list`` must print presets in actual resolution/precedence order. - - Regression coverage for #4086: the printed order was registry/insertion - order, so a preset with a *higher* priority number (lower precedence) could - appear before one with a lower number, misleading users about which preset - wins. Output must be sorted by (priority, id) to match - ``PresetRegistry.list_by_priority()``. - """ - - def _install(self, temp_dir, project_dir, pack_id, priority): - from specify_cli.presets import PresetManager - - src = temp_dir / f"src-{pack_id}" - (src / "templates").mkdir(parents=True) - (src / "templates" / "spec-template.md").write_text("# tmpl\n") - (src / "preset.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "preset": { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": "plain description", - }, - "requires": {"speckit_version": ">=0.0.1"}, - "provides": {"templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - }]}, - })) - PresetManager(project_dir).install_from_directory(src, "9.9.9", priority) - - def _invoke(self, project_dir, args): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - with patch.object(Path, "cwd", return_value=project_dir): - return CliRunner().invoke(app, args) - - def test_list_sorted_by_priority(self, temp_dir, project_dir): - """Lower priority number is listed first regardless of install order.""" - # Install in an order that does NOT match precedence. - self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100) - self._install(temp_dir, project_dir, "lean", priority=10) - - result = self._invoke(project_dir, ["preset", "list"]) - assert result.exit_code == 0, result.output - output = strip_ansi(result.output) - # `lean` (priority 10) must appear before `copilot-sub-agents` (100). - assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output - assert "resolution order" in output, output - assert "Ties are broken by preset id" in output, output - - def test_list_ties_broken_by_id(self, temp_dir, project_dir): - """Equal priority ties are broken alphabetically by preset id.""" - self._install(temp_dir, project_dir, "zebra", priority=10) - self._install(temp_dir, project_dir, "alpha", priority=10) - - result = self._invoke(project_dir, ["preset", "list"]) - assert result.exit_code == 0, result.output - output = strip_ansi(result.output) - assert output.index("(alpha)") < output.index("(zebra)"), output class TestConstitutionSyncPreset: