From 971f702f76aab707738d7934be642912c96261c6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:26:00 -0500 Subject: [PATCH] refactor: reorganize bundle CLI commands Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 4 +- src/specify_cli/bundler.py | 5 + .../bundler/commands_impl/__init__.py | 2 - src/specify_cli/bundler/lib/__init__.py | 2 - src/specify_cli/bundler/models/__init__.py | 2 - src/specify_cli/bundler/services/__init__.py | 2 - .../{bundler => bundles}/__init__.py | 3 +- src/specify_cli/bundles/_commands.py | 163 +++ .../{bundler/services => bundles}/adapters.py | 14 +- src/specify_cli/bundles/catalog/__init__.py | 20 + .../bundles/catalog/command_add.py | 41 + .../bundles/catalog/command_list.py | 38 + .../bundles/catalog/command_remove.py | 31 + .../catalog_config.py | 6 +- .../services => bundles}/catalog_stack.py | 4 +- .../models/catalog.py => bundles/catalogs.py} | 4 +- src/specify_cli/bundles/command_add.py | 32 + src/specify_cli/bundles/command_build.py | 38 + src/specify_cli/bundles/command_info.py | 157 +++ src/specify_cli/bundles/command_init.py | 51 + src/specify_cli/bundles/command_install.py | 137 ++ src/specify_cli/bundles/command_list.py | 45 + src/specify_cli/bundles/command_remove.py | 32 + src/specify_cli/bundles/command_search.py | 65 + src/specify_cli/bundles/command_update.py | 69 + src/specify_cli/bundles/command_validate.py | 65 + .../{bundler/services => bundles}/conflict.py | 4 +- .../services => bundles}/installer.py | 6 +- .../{bundler/models => bundles}/manifest.py | 6 +- .../{bundler/services => bundles}/packager.py | 6 +- .../services => bundles}/primitives.py | 44 +- .../{bundler/lib => bundles}/project.py | 4 +- .../{bundler/models => bundles}/records.py | 4 +- .../services => bundles}/references.py | 26 +- .../{bundler/services => bundles}/resolver.py | 6 +- src/specify_cli/bundles/sources.py | 346 +++++ .../services => bundles}/validator.py | 6 +- .../{bundler/lib => bundles}/versioning.py | 2 +- .../{bundler/lib => bundles}/yamlio.py | 4 +- src/specify_cli/commands/bundle/__init__.py | 1135 --------------- tests/contract/test_bundle_cli.py | 1231 ----------------- tests/contract/test_catalog_schema.py | 16 +- tests/contract/test_manifest_schema.py | 4 +- .../integration/test_bundler_init_install.py | 106 -- tests/specify_cli/bundles/__init__.py | 0 tests/specify_cli/bundles/_command_helpers.py | 50 + tests/specify_cli/bundles/catalog/__init__.py | 0 .../bundles/catalog/test_command_add.py | 35 + .../bundles/catalog/test_command_list.py | 23 + .../bundles/catalog/test_command_remove.py | 21 + tests/specify_cli/bundles/conftest.py | 12 + .../bundles/helpers.py} | 4 +- .../bundles/test_adapters.py} | 4 +- tests/specify_cli/bundles/test_bundles.py | 8 + .../bundles/test_catalog_config.py} | 2 +- .../bundles/test_catalog_stack.py} | 6 +- tests/specify_cli/bundles/test_command_add.py | 91 ++ .../specify_cli/bundles/test_command_build.py | 58 + .../specify_cli/bundles/test_command_info.py | 606 ++++++++ .../specify_cli/bundles/test_command_init.py | 40 + .../bundles/test_command_install.py} | 410 +++--- .../specify_cli/bundles/test_command_list.py | 103 ++ .../bundles/test_command_remove.py | 55 + .../bundles/test_command_search.py | 106 ++ .../bundles/test_command_update.py | 71 + .../bundles/test_command_validate.py | 84 ++ tests/specify_cli/bundles/test_commands.py | 71 + .../bundles/test_conflict.py} | 8 +- .../bundles/test_installer.py} | 18 +- .../bundles/test_offline.py} | 14 +- .../bundles/test_packager.py} | 4 +- .../bundles/test_primitives.py} | 20 +- .../bundles/test_records.py} | 4 +- .../bundles/test_references.py} | 6 +- .../bundles/test_resolver.py} | 6 +- .../bundles/test_security_paths.py} | 24 +- tests/specify_cli/bundles/test_sources.py | 332 +++++ .../bundles/test_validator.py} | 8 +- .../bundles/test_versioning.py} | 4 +- .../bundles/test_yamlio.py} | 2 +- tests/test_init_dir_cli.py | 50 - tests/unit/test_bundle_download_url.py | 162 --- 82 files changed, 3491 insertions(+), 3019 deletions(-) create mode 100644 src/specify_cli/bundler.py delete mode 100644 src/specify_cli/bundler/commands_impl/__init__.py delete mode 100644 src/specify_cli/bundler/lib/__init__.py delete mode 100644 src/specify_cli/bundler/models/__init__.py delete mode 100644 src/specify_cli/bundler/services/__init__.py rename src/specify_cli/{bundler => bundles}/__init__.py (86%) create mode 100644 src/specify_cli/bundles/_commands.py rename src/specify_cli/{bundler/services => bundles}/adapters.py (97%) create mode 100644 src/specify_cli/bundles/catalog/__init__.py create mode 100644 src/specify_cli/bundles/catalog/command_add.py create mode 100644 src/specify_cli/bundles/catalog/command_list.py create mode 100644 src/specify_cli/bundles/catalog/command_remove.py rename src/specify_cli/{bundler/commands_impl => bundles}/catalog_config.py (98%) rename src/specify_cli/{bundler/services => bundles}/catalog_stack.py (98%) rename src/specify_cli/{bundler/models/catalog.py => bundles/catalogs.py} (99%) create mode 100644 src/specify_cli/bundles/command_add.py create mode 100644 src/specify_cli/bundles/command_build.py create mode 100644 src/specify_cli/bundles/command_info.py create mode 100644 src/specify_cli/bundles/command_init.py create mode 100644 src/specify_cli/bundles/command_install.py create mode 100644 src/specify_cli/bundles/command_list.py create mode 100644 src/specify_cli/bundles/command_remove.py create mode 100644 src/specify_cli/bundles/command_search.py create mode 100644 src/specify_cli/bundles/command_update.py create mode 100644 src/specify_cli/bundles/command_validate.py rename src/specify_cli/{bundler/services => bundles}/conflict.py (95%) rename src/specify_cli/{bundler/services => bundles}/installer.py (98%) rename src/specify_cli/{bundler/models => bundles}/manifest.py (99%) rename src/specify_cli/{bundler/services => bundles}/packager.py (98%) rename src/specify_cli/{bundler/services => bundles}/primitives.py (94%) rename src/specify_cli/{bundler/lib => bundles}/project.py (98%) rename src/specify_cli/{bundler/models => bundles}/records.py (99%) rename src/specify_cli/{bundler/services => bundles}/references.py (86%) rename src/specify_cli/{bundler/services => bundles}/resolver.py (97%) create mode 100644 src/specify_cli/bundles/sources.py rename src/specify_cli/{bundler/services => bundles}/validator.py (93%) rename src/specify_cli/{bundler/lib => bundles}/versioning.py (99%) rename src/specify_cli/{bundler/lib => bundles}/yamlio.py (99%) delete mode 100644 src/specify_cli/commands/bundle/__init__.py delete mode 100644 tests/contract/test_bundle_cli.py delete mode 100644 tests/integration/test_bundler_init_install.py create mode 100644 tests/specify_cli/bundles/__init__.py create mode 100644 tests/specify_cli/bundles/_command_helpers.py create mode 100644 tests/specify_cli/bundles/catalog/__init__.py create mode 100644 tests/specify_cli/bundles/catalog/test_command_add.py create mode 100644 tests/specify_cli/bundles/catalog/test_command_list.py create mode 100644 tests/specify_cli/bundles/catalog/test_command_remove.py create mode 100644 tests/specify_cli/bundles/conftest.py rename tests/{bundler_helpers.py => specify_cli/bundles/helpers.py} (96%) rename tests/{unit/test_bundler_adapters.py => specify_cli/bundles/test_adapters.py} (99%) create mode 100644 tests/specify_cli/bundles/test_bundles.py rename tests/{unit/test_bundler_catalog_config.py => specify_cli/bundles/test_catalog_config.py} (99%) rename tests/{integration/test_bundler_catalog_stack.py => specify_cli/bundles/test_catalog_stack.py} (95%) create mode 100644 tests/specify_cli/bundles/test_command_add.py create mode 100644 tests/specify_cli/bundles/test_command_build.py create mode 100644 tests/specify_cli/bundles/test_command_info.py create mode 100644 tests/specify_cli/bundles/test_command_init.py rename tests/{integration/test_bundler_local_install.py => specify_cli/bundles/test_command_install.py} (54%) create mode 100644 tests/specify_cli/bundles/test_command_list.py create mode 100644 tests/specify_cli/bundles/test_command_remove.py create mode 100644 tests/specify_cli/bundles/test_command_search.py create mode 100644 tests/specify_cli/bundles/test_command_update.py create mode 100644 tests/specify_cli/bundles/test_command_validate.py create mode 100644 tests/specify_cli/bundles/test_commands.py rename tests/{unit/test_bundler_conflict.py => specify_cli/bundles/test_conflict.py} (87%) rename tests/{integration/test_bundler_install_flow.py => specify_cli/bundles/test_installer.py} (97%) rename tests/{integration/test_bundler_offline.py => specify_cli/bundles/test_offline.py} (91%) rename tests/{unit/test_bundler_packager.py => specify_cli/bundles/test_packager.py} (98%) rename tests/{unit/test_bundler_primitives.py => specify_cli/bundles/test_primitives.py} (97%) rename tests/{unit/test_bundler_records.py => specify_cli/bundles/test_records.py} (98%) rename tests/{unit/test_bundler_references.py => specify_cli/bundles/test_references.py} (95%) rename tests/{unit/test_bundler_resolver.py => specify_cli/bundles/test_resolver.py} (95%) rename tests/{integration/test_bundler_security_paths.py => specify_cli/bundles/test_security_paths.py} (90%) create mode 100644 tests/specify_cli/bundles/test_sources.py rename tests/{unit/test_bundler_validator.py => specify_cli/bundles/test_validator.py} (79%) rename tests/{unit/test_bundler_versioning.py => specify_cli/bundles/test_versioning.py} (93%) rename tests/{unit/test_bundler_yamlio.py => specify_cli/bundles/test_yamlio.py} (96%) delete mode 100644 tests/unit/test_bundle_download_url.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9840698975..eee6dce00b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -578,8 +578,8 @@ def _require_specify_project() -> Path: # ===== Bundle Commands ===== -# Bundler subcommand group (specify bundle ...) — see commands/bundle/. -from .commands.bundle import register as _register_bundle_cmds # noqa: E402 +# Bundle subcommand group (specify bundle ...) — see bundles/_commands.py. +from .bundles._commands import register as _register_bundle_cmds # noqa: E402 _register_bundle_cmds(app) diff --git a/src/specify_cli/bundler.py b/src/specify_cli/bundler.py new file mode 100644 index 0000000000..cec21f026c --- /dev/null +++ b/src/specify_cli/bundler.py @@ -0,0 +1,5 @@ +"""Compatibility import for the renamed :mod:`specify_cli.bundles` package.""" + +from .bundles import BundlerError + +__all__ = ["BundlerError"] diff --git a/src/specify_cli/bundler/commands_impl/__init__.py b/src/specify_cli/bundler/commands_impl/__init__.py deleted file mode 100644 index ae91e9190a..0000000000 --- a/src/specify_cli/bundler/commands_impl/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Bundler command-implementation helpers (kept thin; logic lives in services).""" -from __future__ import annotations diff --git a/src/specify_cli/bundler/lib/__init__.py b/src/specify_cli/bundler/lib/__init__.py deleted file mode 100644 index f0c89c4a0f..0000000000 --- a/src/specify_cli/bundler/lib/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Shared, dependency-light helpers for the bundler (YAML/JSON IO, versioning, project detection).""" -from __future__ import annotations diff --git a/src/specify_cli/bundler/models/__init__.py b/src/specify_cli/bundler/models/__init__.py deleted file mode 100644 index 2a5136287b..0000000000 --- a/src/specify_cli/bundler/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Bundler data models (manifest, catalog, records).""" -from __future__ import annotations diff --git a/src/specify_cli/bundler/services/__init__.py b/src/specify_cli/bundler/services/__init__.py deleted file mode 100644 index 1db5b56614..0000000000 --- a/src/specify_cli/bundler/services/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Bundler services (catalog stack, resolver, installer, conflict, validator, packager).""" -from __future__ import annotations diff --git a/src/specify_cli/bundler/__init__.py b/src/specify_cli/bundles/__init__.py similarity index 86% rename from src/specify_cli/bundler/__init__.py rename to src/specify_cli/bundles/__init__.py index dac5347b67..f5a0d18b99 100644 --- a/src/specify_cli/bundler/__init__.py +++ b/src/specify_cli/bundles/__init__.py @@ -3,7 +3,8 @@ This package holds the models, services, and helpers behind the ``specify bundle`` subcommand. It is intentionally free of any Typer/CLI imports so the orchestration logic can be unit-tested independently of the command surface (Constitution -Principle I). The CLI wiring lives in ``specify_cli.commands.bundle``. +Principle I). The CLI wiring lives in ``specify_cli.bundles._commands`` and adjacent +``command_*.py`` modules. """ from __future__ import annotations diff --git a/src/specify_cli/bundles/_commands.py b/src/specify_cli/bundles/_commands.py new file mode 100644 index 0000000000..5619ecc04b --- /dev/null +++ b/src/specify_cli/bundles/_commands.py @@ -0,0 +1,163 @@ +"""Shared infrastructure and registration for ``specify bundle`` commands. + +Command handlers live in ``command_*.py`` modules. The nested ``catalog`` +namespace registers through ``bundles.catalog``; domain behavior remains in +Typer-free modules in this package. +""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import err_console +from . import BundlerError +from .project import active_integration +from .records import load_records + +bundle_app = typer.Typer( + name="bundle", + help="Discover, install, and author Spec Kit bundles", + add_completion=False, +) + + +def _fail(message: str) -> None: + """Print an actionable error to stderr and exit non-zero.""" + # Use the stderr console so the error never lands on stdout, which under + # ``--json`` carries the machine-readable payload and must stay parseable. + # Escape the message: every caller passes ``str(exc)`` from a BundlerError + # that interpolates untrusted data (a CLI argument, a catalog url, a + # bundle.yml field), so a '[...]' in it would be parsed as a Rich style tag + # -- silently swallowing the text, or raising MarkupError on an unbalanced + # closer and replacing the whole message with a traceback. + err_console.print(f"[red]Error:[/red] {_escape_markup(message)}", style=None) + raise typer.Exit(code=1) + + +def _user_config_dir() -> Path: + # User-scope Spec Kit config lives under ~/.specify (same convention as + # auth.json, extension/preset catalogs). Passing this through to the source + # stack is what makes the documented project > user > built-in precedence + # reachable from the CLI. + return Path.home() / ".specify" + + +def _build_stack(project_root: Path, *, offline: bool): + from .adapters import make_catalog_fetcher + from .catalog_stack import CatalogStack + + fetcher = make_catalog_fetcher(allow_network=not offline) + return CatalogStack.load(project_root, fetcher, user_config_dir=_user_config_dir()) + + +def _speckit_version() -> str: + from .._assets import get_speckit_version + + return get_speckit_version() + + +def _trust_level(verified: bool) -> str: + """Trust framing for a catalog entry (FR-010): org-curated vs community.""" + return "verified" if verified else "community" + + +def _trust_badge(verified: bool) -> str: + return "[green]✔ verified[/green]" if verified else "[yellow]community[/yellow]" + + +def _default_script_type() -> str: + """OS-appropriate default script flavor (FR-013).""" + import os + + return "ps" if os.name == "nt" else "sh" + + +def _run_init(integration: str, *, script_type: str, offline: bool = False) -> None: + """Idempotently scaffold a Spec Kit project here via the existing ``init`` machinery. + + Reuses the real ``specify init`` command callback in-process (Principle I) + with ``--here --force`` so it is non-interactive and merges into the current + directory. + """ + from .. import app + + init_cb = next( + c.callback + for c in app.registered_commands + if c.callback and c.callback.__name__ == "init" + ) + try: + init_cb( + project_name=None, + script_type=script_type, + ignore_agent_tools=True, + here=True, + force=True, + skip_tls=False, + debug=False, + github_token=None, + offline=offline, + preset=None, + integration=integration, + integration_options=None, + extensions=None, + trust_extension_urls=False, + ) + except typer.Exit as exc: + if exc.exit_code: + raise BundlerError( + f"Failed to initialize a Spec Kit project (integration '{integration}')." + ) from exc + + +def _resolve_init_integration(override: str | None, manifest) -> str: + """Precedence (FR-013): explicit override → bundle-declared → default.""" + from .._agent_config import resolve_default_init_integration + + if override: + return override + if manifest is not None and manifest.integration is not None: + return manifest.integration.id + return resolve_default_init_integration() + + +def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]: + """Return informational overlaps between *manifest* and installed bundles.""" + if manifest is None: + return [] + try: + from .conflict import detect_conflicts + + report = detect_conflicts( + manifest, + active_integration(project_root), + load_records(project_root), + ) + return list(report.overlaps) + except BundlerError: + return [] + + +def register(app: typer.Typer) -> None: + """Attach the bundle command group to the root Typer app.""" + from .catalog import register as register_catalog + + register_catalog(bundle_app) + + # isort: off + from . import command_search # noqa: F401 — registers handler via decorator + from . import command_info # noqa: F401 — registers handler via decorator + from . import command_list # noqa: F401 — registers handler via decorator + from . import command_install # noqa: F401 — registers handler via decorator + from . import command_add # noqa: F401 — registers handler via decorator + from . import command_update # noqa: F401 — registers handler via decorator + from . import command_remove # noqa: F401 — registers handler via decorator + from . import command_validate # noqa: F401 — registers handler via decorator + from . import command_build # noqa: F401 — registers handler via decorator + from . import command_init # noqa: F401 — registers handler via decorator + # isort: on + + app.add_typer(bundle_app, name="bundle") diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundles/adapters.py similarity index 97% rename from src/specify_cli/bundler/services/adapters.py rename to src/specify_cli/bundles/adapters.py index 7700f5e63e..69b0f903a1 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundles/adapters.py @@ -19,12 +19,12 @@ from urllib.parse import ParseResult, urlparse from urllib.request import url2pathname -from ..._assets import _locate_core_pack, _repo_root -from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited -from .. import BundlerError -from ..lib.yamlio import load_json, loads_json -from ..models.catalog import CatalogSource -from ..models.manifest import ComponentRef +from .._assets import _locate_core_pack, _repo_root +from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited +from . import BundlerError +from .yamlio import load_json, loads_json +from .catalogs import CatalogSource +from .manifest import ComponentRef COMMUNITY_CATALOG_URL = ( "https://raw.githubusercontent.com/github/spec-kit/main/" @@ -218,7 +218,7 @@ def _http_get_json(source_id: str, url: str) -> dict: HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end rather than only on the initial URL. """ - from ...authentication.http import RedirectPolicyError, open_url + from ..authentication.http import RedirectPolicyError, open_url def _validate_redirect(_old_url: str, new_url: str) -> None: _validate_remote_url(source_id, new_url) diff --git a/src/specify_cli/bundles/catalog/__init__.py b/src/specify_cli/bundles/catalog/__init__.py new file mode 100644 index 0000000000..dc2e1c6651 --- /dev/null +++ b/src/specify_cli/bundles/catalog/__init__.py @@ -0,0 +1,20 @@ +"""Registration for the nested ``specify bundle catalog`` command group.""" + +from __future__ import annotations + +import typer + +catalog_app = typer.Typer( + name="catalog", + help="Manage bundle catalog sources", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the catalog command group to the bundle Typer app.""" + 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 + + app.add_typer(catalog_app, name="catalog") diff --git a/src/specify_cli/bundles/catalog/command_add.py b/src/specify_cli/bundles/catalog/command_add.py new file mode 100644 index 0000000000..b734878f1c --- /dev/null +++ b/src/specify_cli/bundles/catalog/command_add.py @@ -0,0 +1,41 @@ +"""Implementation of ``specify bundle catalog add``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from ..._console import console +from .. import BundlerError +from .._commands import _fail +from ..project import require_project_root +from . import catalog_app + + +@catalog_app.command("add") +def catalog_add( + url: str = typer.Argument(..., help="Catalog URL"), + policy: str = typer.Option( + "install-allowed", "--policy", help="install-allowed | discovery-only" + ), + priority: int = typer.Option( + 10, "--priority", help="Source priority (lower = higher)" + ), + source_id: str = typer.Option(None, "--id", help="Explicit source id"), +) -> None: + """Register a project-scoped catalog source and persist it.""" + try: + project_root = require_project_root() + from ..catalog_config import add_source + + source = add_source( + project_root, url, policy=policy, priority=priority, source_id=source_id + ) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' " + f"(priority {source.priority}, {source.install_policy.value})." + ) diff --git a/src/specify_cli/bundles/catalog/command_list.py b/src/specify_cli/bundles/catalog/command_list.py new file mode 100644 index 0000000000..b3bbb42fb9 --- /dev/null +++ b/src/specify_cli/bundles/catalog/command_list.py @@ -0,0 +1,38 @@ +"""Implementation of ``specify bundle catalog list``.""" + +from __future__ import annotations + +from rich.markup import escape as _escape_markup + +from ..._console import console +from .. import BundlerError +from .._commands import _fail, _user_config_dir +from ..project import require_project_root +from . import catalog_app + + +@catalog_app.command("list") +def catalog_list() -> None: + """Print the active, priority-ordered catalog stack with scope and policy.""" + try: + project_root = require_project_root() + from ..catalogs import Scope, load_source_stack + + sources = load_source_stack(project_root, user_config_dir=_user_config_dir()) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + "\n[bold cyan]Catalog stack[/bold cyan] (highest precedence first):\n" + ) + only_builtin = all(s.scope == Scope.BUILTIN for s in sources) + for source in sources: + console.print( + f" [bold]{_escape_markup(str(source.id))}[/bold] " + f"priority={source.priority} " + f"policy={source.install_policy.value} scope={source.scope.value}" + ) + console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]") + if only_builtin: + console.print("\n[dim]Using the built-in default stack.[/dim]") diff --git a/src/specify_cli/bundles/catalog/command_remove.py b/src/specify_cli/bundles/catalog/command_remove.py new file mode 100644 index 0000000000..2f7b07eb4b --- /dev/null +++ b/src/specify_cli/bundles/catalog/command_remove.py @@ -0,0 +1,31 @@ +"""Implementation of ``specify bundle catalog remove``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from ..._console import console +from .. import BundlerError +from .._commands import _fail +from ..project import require_project_root +from . import catalog_app + + +@catalog_app.command("remove") +def catalog_remove( + id_or_url: str = typer.Argument(..., help="Source id or url to remove"), +) -> None: + """Remove a project-scoped catalog source (built-in defaults can't be deleted).""" + try: + project_root = require_project_root() + from ..catalog_config import remove_source + + removed = remove_source(project_root, id_or_url) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Removed catalog source '{_escape_markup(str(removed))}'." + ) diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundles/catalog_config.py similarity index 98% rename from src/specify_cli/bundler/commands_impl/catalog_config.py rename to src/specify_cli/bundles/catalog_config.py index f763a21c65..abd9156b90 100644 --- a/src/specify_cli/bundler/commands_impl/catalog_config.py +++ b/src/specify_cli/bundles/catalog_config.py @@ -10,9 +10,9 @@ from urllib.parse import urlparse import re -from .. import BundlerError -from ..lib.yamlio import dump_yaml, ensure_within, load_yaml -from ..models.catalog import ( +from . import BundlerError +from .yamlio import dump_yaml, ensure_within, load_yaml +from .catalogs import ( CONFIG_FILENAME, CONFIG_SCHEMA_VERSION, BUILTIN_DEFAULT_STACK, diff --git a/src/specify_cli/bundler/services/catalog_stack.py b/src/specify_cli/bundles/catalog_stack.py similarity index 98% rename from src/specify_cli/bundler/services/catalog_stack.py rename to src/specify_cli/bundles/catalog_stack.py index a6c1d23522..6f96fd658b 100644 --- a/src/specify_cli/bundler/services/catalog_stack.py +++ b/src/specify_cli/bundles/catalog_stack.py @@ -10,8 +10,8 @@ from pathlib import Path from typing import Callable -from .. import BundlerError -from ..models.catalog import ( +from . import BundlerError +from .catalogs import ( CatalogEntry, CatalogSource, load_catalog_payload, diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundles/catalogs.py similarity index 99% rename from src/specify_cli/bundler/models/catalog.py rename to src/specify_cli/bundles/catalogs.py index 2ef882d576..839fc42204 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundles/catalogs.py @@ -11,8 +11,8 @@ from pathlib import Path from typing import Any -from .. import BundlerError -from ..lib.yamlio import ensure_within, load_yaml +from . import BundlerError +from .yamlio import ensure_within, load_yaml CONFIG_FILENAME = "bundle-catalogs.yml" # Supported bundle-catalogs.yml schema (major version). Both readers of the diff --git a/src/specify_cli/bundles/command_add.py b/src/specify_cli/bundles/command_add.py new file mode 100644 index 0000000000..004ef1117f --- /dev/null +++ b/src/specify_cli/bundles/command_add.py @@ -0,0 +1,32 @@ +"""Implementation of ``specify bundle add``.""" + +from __future__ import annotations + +import typer + +from ._commands import bundle_app +from .command_install import bundle_install + + +@bundle_app.command("add") +def bundle_add( + bundle_id: str = typer.Argument( + ..., + help="Bundle id (from the catalog stack) or a local path to a .zip " + "artifact, bundle directory, or bundle.yml", + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + refresh: bool = typer.Option( + False, + "--refresh", + help="Refresh owned components from this bundle source", + ), +) -> None: + """Install a bundle's full component set (alias for install).""" + bundle_install( + bundle_id=bundle_id, + integration=integration, + offline=offline, + refresh=refresh, + ) diff --git a/src/specify_cli/bundles/command_build.py b/src/specify_cli/bundles/command_build.py new file mode 100644 index 0000000000..2ec488aa81 --- /dev/null +++ b/src/specify_cli/bundles/command_build.py @@ -0,0 +1,38 @@ +"""Implementation of ``specify bundle build``.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _fail, bundle_app + + +@bundle_app.command("build") +def bundle_build( + path: Path = typer.Option(None, "--path", help="Bundle directory (default: cwd)"), + output: Path = typer.Option( + None, "--output", help="Output directory for the artifact" + ), +) -> None: + """Produce a single versioned distributable artifact (.zip).""" + try: + bundle_dir = (path or Path.cwd()).resolve() + if bundle_dir.is_file(): + bundle_dir = bundle_dir.parent + from .packager import build_bundle + + result = build_bundle(bundle_dir, output_dir=output) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Built {_escape_markup(result.artifact_path.name)} " + f"({result.file_count} files) → " + f"{_escape_markup(str(result.artifact_path))}" + ) diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py new file mode 100644 index 0000000000..f1c315ac1f --- /dev/null +++ b/src/specify_cli/bundles/command_info.py @@ -0,0 +1,157 @@ +"""Implementation of ``specify bundle info``.""" + +from __future__ import annotations + +import json as _json +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import ( + _build_stack, + _bundle_overlaps, + _fail, + _trust_badge, + _trust_level, + bundle_app, +) +from .project import find_project_root +from .sources import _download_manifest + + +@bundle_app.command("info") +def bundle_info( + bundle_id: str = typer.Argument(..., help="Bundle id to inspect"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """Show full metadata and the fully expanded component set (== what install adds).""" + try: + project_root = find_project_root() or Path.cwd() + stack = _build_stack(project_root, offline=offline) + resolved = stack.resolve(bundle_id) + # `info` must show the fully expanded component set that `install` would + # apply (contracts/cli-commands.md). Expansion happens regardless of + # install policy — discovery-only bundles stay inspectable; only + # `install` is refused. But if the manifest itself can't be resolved + # (e.g. --offline against an https:// download_url, or a download + # failure), fail loudly and exit non-zero rather than silently + # degrading to catalog `provides` counts, so users never mistake an + # unverifiable bundle for a known/installable one. + manifest = _download_manifest(resolved, offline=offline) + except BundlerError as exc: + _fail(str(exc)) + return + + overlaps = _bundle_overlaps(project_root, manifest, offline=offline) + components = _manifest_component_view(manifest) + + entry = resolved.entry + if as_json: + payload = { + "id": entry.id, + "name": entry.name, + "version": entry.version, + "role": entry.role, + "description": entry.description, + "author": entry.author, + "license": entry.license, + "source": resolved.source.id, + "install_policy": resolved.source.install_policy.value, + "provides": entry.provides, + "requires": {"speckit_version": entry.requires_speckit_version}, + "verified": entry.verified, + "trust": _trust_level(entry.verified), + "integration": ( + manifest.integration.id if manifest and manifest.integration else None + ), + "components": components, + "overlaps": overlaps, + } + print(_json.dumps(payload, indent=2)) + return + + console.print( + f"\n[bold cyan]{_escape_markup(str(entry.id))}[/bold cyan] " + f"v{_escape_markup(str(entry.version))} — " + f"{_escape_markup(str(entry.name))}" + ) + console.print(f" Role: {_escape_markup(str(entry.role))}") + console.print(f" {_escape_markup(str(entry.description))}") + console.print( + f" Author: {_escape_markup(str(entry.author))} " + f"License: {_escape_markup(str(entry.license))}" + ) + console.print( + f" Source: {_escape_markup(str(resolved.source.id))} " + f"({resolved.source.install_policy.value})" + ) + console.print(f" Trust: {_trust_badge(entry.verified)}") + if entry.requires_speckit_version: + console.print( + f" Requires Spec Kit: " + f"{_escape_markup(str(entry.requires_speckit_version))}" + ) + if manifest and manifest.integration: + console.print(f" Integration: {_escape_markup(str(manifest.integration.id))}") + + if components: + console.print("\n [bold]Components[/bold] (added on install):") + for kind in ("extensions", "presets", "steps", "workflows"): + items = [c for c in components if c["kind"] == kind] + if not items: + continue + console.print(f" [bold]{kind}:[/bold]") + for item in items: + console.print(f" - {_escape_markup(_format_component(item))}") + else: + console.print("\n [bold]Provides:[/bold]") + for kind in ("extensions", "presets", "steps", "workflows"): + count = entry.provides.get(kind, 0) + if count: + console.print(f" {kind}: {_escape_markup(str(count))}") + + if overlaps: + console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]") + for overlap in overlaps: + console.print(f" [yellow]-[/yellow] {_escape_markup(str(overlap))}") + + if not resolved.install_allowed: + console.print( + "\n [yellow]This source is discovery-only; the bundle cannot be " + "installed from here.[/yellow]" + ) + + +def _manifest_component_view(manifest) -> list[dict]: + """Flatten a manifest's components to JSON-friendly dicts (id, version, ...).""" + if manifest is None: + return [] + view: list[dict] = [] + for component in manifest.components: + item = { + "kind": component.kind, + "id": component.id, + "version": component.version, + } + if component.priority is not None: + item["priority"] = component.priority + if component.strategy is not None: + item["strategy"] = component.strategy + view.append(item) + return view + + +def _format_component(item: dict) -> str: + label = f"{item['id']} v{item['version']}" if item.get("version") else item["id"] + extras = [] + if item.get("priority") is not None: + extras.append(f"priority={item['priority']}") + if item.get("strategy") is not None: + extras.append(f"strategy={item['strategy']}") + if extras: + label += f" ({', '.join(extras)})" + return label diff --git a/src/specify_cli/bundles/command_init.py b/src/specify_cli/bundles/command_init.py new file mode 100644 index 0000000000..0b2e8133c1 --- /dev/null +++ b/src/specify_cli/bundles/command_init.py @@ -0,0 +1,51 @@ +"""Implementation of ``specify bundle init``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import ( + _default_script_type, + _fail, + _resolve_init_integration, + _run_init, + bundle_app, +) +from .command_install import bundle_install +from .project import require_project_root + + +@bundle_app.command("init") +def bundle_init( + bundle: str = typer.Argument(None, help="Optional bundle to install after init"), + integration: str = typer.Option(None, "--integration", help="Integration override"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Ensure the project is initialized (idempotent), then optionally install a bundle.""" + from .project import find_project_root + + try: + project_root = find_project_root() + if project_root is None: + init_integration = _resolve_init_integration(integration, None) + console.print( + f"[cyan]Initializing a Spec Kit project with integration " + f"'{_escape_markup(str(init_integration))}'…[/cyan]" + ) + _run_init( + init_integration, script_type=_default_script_type(), offline=offline + ) + project_root = require_project_root() + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Spec Kit project ready at " + f"{_escape_markup(str(project_root))}." + ) + if bundle: + bundle_install(bundle, integration=integration, offline=offline) diff --git a/src/specify_cli/bundles/command_install.py b/src/specify_cli/bundles/command_install.py new file mode 100644 index 0000000000..c80a96f18c --- /dev/null +++ b/src/specify_cli/bundles/command_install.py @@ -0,0 +1,137 @@ +"""Implementation of ``specify bundle install``.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import ( + _build_stack, + _bundle_overlaps, + _default_script_type, + _fail, + _resolve_init_integration, + _run_init, + _speckit_version, + bundle_app, +) +from .project import active_integration, require_project_root +from .sources import ( + _download_manifest, + _local_manifest_source, + _validate_manifest_structure, +) + + +@bundle_app.command("install") +def bundle_install( + bundle_id: str = typer.Argument( + ..., + help="Bundle id (from the catalog stack) or a local path to a .zip " + "artifact, bundle directory, or bundle.yml", + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + refresh: bool = typer.Option( + False, + "--refresh", + help="Refresh owned components from this bundle source", + ), +) -> None: + """Install a bundle's full component set through each primitive's machinery. + + ``bundle_id`` may be a catalog bundle id, or a local path to a built + artifact (``.zip``), a bundle directory, or a ``bundle.yml`` file. Local + sources install directly without consulting the catalog stack. Use + ``--refresh`` to update owned components from a newer local source. + """ + try: + from .project import find_project_root + from .adapters import DefaultPrimitiveInstaller + from .installer import install_bundle + from .resolver import resolve_install_plan + + project_root = find_project_root() + + local_manifest = _local_manifest_source(bundle_id) + if local_manifest is not None: + manifest = local_manifest + _validate_manifest_structure( + manifest, + source=f"Local bundle source {bundle_id!r}", + ) + else: + stack = _build_stack(project_root or Path.cwd(), offline=offline) + resolved = stack.resolve(bundle_id) + + if not resolved.install_allowed: + raise BundlerError( + f"Bundle '{bundle_id}' resolves only from a discovery-only source " + f"('{resolved.source.id}'); it cannot be installed from there." + ) + manifest = _download_manifest(resolved, offline=offline) + + if project_root is None: + init_integration = _resolve_init_integration(integration, manifest) + # Resolve all hard compatibility gates before ``specify init``. + # Otherwise an incompatible but structurally valid bundle would + # initialize a project and only then fail its version/integration + # checks, leaving state behind after a failed install. + resolve_install_plan( + manifest, + speckit_version=_speckit_version(), + active_integration=init_integration, + integration_explicit=True, + ) + console.print( + f"[cyan]No Spec Kit project here; initializing with integration " + f"'{_escape_markup(str(init_integration))}'…[/cyan]" + ) + _run_init( + init_integration, script_type=_default_script_type(), offline=offline + ) + project_root = require_project_root() + + for overlap in _bundle_overlaps(project_root, manifest, offline=offline): + console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}") + + # For an already-initialized project, the project's recorded active + # integration is authoritative — an explicit --integration must not be + # able to bypass the FR-019 integration-clash guard. The override only + # selects the integration at init time (handled above) or confirms the + # target when the active integration cannot be determined. + detected = active_integration(project_root) + plan = resolve_install_plan( + manifest, + speckit_version=_speckit_version(), + active_integration=detected if detected is not None else integration, + integration_explicit=bool(integration) and detected is None, + ) + for warning in plan.warnings: + console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") + + result = install_bundle( + project_root, + plan, + DefaultPrimitiveInstaller(allow_network=not offline), + manifest=manifest, + refresh=refresh, + ) + except BundlerError as exc: + _fail(str(exc)) + return + + refresh_summary = ( + f", {len(result.refreshed)} refreshed, {len(result.uninstalled)} removed" + if refresh + else "" + ) + console.print( + f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' " + f"({len(result.installed)} added, {len(result.skipped)} already present" + f"{refresh_summary})." + ) diff --git a/src/specify_cli/bundles/command_list.py b/src/specify_cli/bundles/command_list.py new file mode 100644 index 0000000000..42acea2c38 --- /dev/null +++ b/src/specify_cli/bundles/command_list.py @@ -0,0 +1,45 @@ +"""Implementation of ``specify bundle list``.""" + +from __future__ import annotations + +import json as _json + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _fail, bundle_app +from .project import require_project_root +from .records import load_records + + +@bundle_app.command("list") +def bundle_list( + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """List bundles currently installed in the project with versions.""" + try: + project_root = require_project_root() + records = load_records(project_root) + except BundlerError as exc: + _fail(str(exc)) + return + + if as_json: + print(_json.dumps([r.to_dict() for r in records], indent=2)) + return + + if not records: + console.print("[yellow]No bundles installed.[/yellow]") + console.print("\nInstall one with: [cyan]specify bundle install [/cyan]") + return + + console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n") + for record in records: + console.print( + f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] " + f"v{_escape_markup(str(record.version))} " + f"[dim]({len(record.contributed_components)} components, " + f"installed {_escape_markup(str(record.installed_at))})[/dim]" + ) diff --git a/src/specify_cli/bundles/command_remove.py b/src/specify_cli/bundles/command_remove.py new file mode 100644 index 0000000000..69c729ff3f --- /dev/null +++ b/src/specify_cli/bundles/command_remove.py @@ -0,0 +1,32 @@ +"""Implementation of ``specify bundle remove``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _fail, bundle_app +from .project import require_project_root + + +@bundle_app.command("remove") +def bundle_remove( + bundle_id: str = typer.Argument(..., help="Installed bundle id to remove"), +) -> None: + """Uninstall only the components this bundle contributed (no collateral removals).""" + try: + project_root = require_project_root() + from .adapters import DefaultPrimitiveInstaller + from .installer import remove_bundle + + result = remove_bundle(project_root, bundle_id, DefaultPrimitiveInstaller()) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Removed '{_escape_markup(str(result.bundle_id))}' " + f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)." + ) diff --git a/src/specify_cli/bundles/command_search.py b/src/specify_cli/bundles/command_search.py new file mode 100644 index 0000000000..5c62def010 --- /dev/null +++ b/src/specify_cli/bundles/command_search.py @@ -0,0 +1,65 @@ +"""Implementation of ``specify bundle search``.""" + +from __future__ import annotations + +import json as _json +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import bundle_app, _build_stack, _fail, _trust_badge, _trust_level +from .project import find_project_root + + +@bundle_app.command("search") +def bundle_search( + query: str = typer.Argument("", help="Optional text query"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """List matching bundles across the active catalog stack.""" + try: + project_root = find_project_root() or Path.cwd() + stack = _build_stack(project_root, offline=offline) + results = stack.search(query) + except BundlerError as exc: + _fail(str(exc)) + return + + if as_json: + payload = [ + { + "id": r.entry.id, + "name": r.entry.name, + "role": r.entry.role, + "version": r.entry.version, + "description": r.entry.description, + "source": r.source.id, + "install_policy": r.source.install_policy.value, + "verified": r.entry.verified, + "trust": _trust_level(r.entry.verified), + } + for r in results + ] + print(_json.dumps(payload, indent=2)) + return + + if not results: + console.print("[yellow]No matching bundles found.[/yellow]") + return + + console.print("\n[bold cyan]Bundles:[/bold cyan]\n") + for r in results: + policy = "[dim](discovery-only)[/dim]" if not r.source.install_allowed else "" + console.print( + f" [bold]{_escape_markup(str(r.entry.id))}[/bold] " + f"v{_escape_markup(str(r.entry.version))} — " + f"{_escape_markup(str(r.entry.name))} " + f"[dim]({_escape_markup(str(r.entry.role))})[/dim] " + f"{_trust_badge(r.entry.verified)} {policy}" + ) + console.print(f" {_escape_markup(str(r.entry.description))}") + console.print(f" [dim]source: {_escape_markup(str(r.source.id))}[/dim]") diff --git a/src/specify_cli/bundles/command_update.py b/src/specify_cli/bundles/command_update.py new file mode 100644 index 0000000000..753085ed9c --- /dev/null +++ b/src/specify_cli/bundles/command_update.py @@ -0,0 +1,69 @@ +"""Implementation of ``specify bundle update``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _build_stack, _fail, _speckit_version, bundle_app +from .project import active_integration, require_project_root +from .records import load_records +from .sources import _download_manifest + + +@bundle_app.command("update") +def bundle_update( + bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"), + all_bundles: bool = typer.Option( + False, "--all", help="Update every installed bundle" + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Re-resolve and refresh a bundle's components via each primitive's update path.""" + try: + project_root = require_project_root() + records = load_records(project_root) + if not all_bundles and not bundle_id: + raise BundlerError("Specify a bundle id or use --all.") + targets = [r.bundle_id for r in records] if all_bundles else [bundle_id] + if not targets: + console.print("[yellow]No installed bundles to update.[/yellow]") + return + + stack = _build_stack(project_root, offline=offline) + from .adapters import DefaultPrimitiveInstaller + from .installer import install_bundle + from .resolver import resolve_install_plan + + installer = DefaultPrimitiveInstaller(allow_network=not offline) + for target in targets: + if not any(r.bundle_id == target for r in records): + raise BundlerError(f"Bundle '{target}' is not installed.") + resolved = stack.resolve(target) + if not resolved.install_allowed: + raise BundlerError( + f"Bundle '{target}' resolves only from a discovery-only source " + f"('{resolved.source.id}'); it cannot be updated from there. " + "Update requires an install-allowed source (FR-025)." + ) + manifest = _download_manifest(resolved, offline=offline) + detected = active_integration(project_root) + plan = resolve_install_plan( + manifest, + speckit_version=_speckit_version(), + active_integration=detected if detected is not None else integration, + integration_explicit=bool(integration) and detected is None, + ) + install_bundle( + project_root, plan, installer, manifest=manifest, refresh=True + ) + console.print( + f"[green]✓[/green] Updated '{_escape_markup(str(target))}' " + f"to v{_escape_markup(str(plan.version))}." + ) + except BundlerError as exc: + _fail(str(exc)) + return diff --git a/src/specify_cli/bundles/command_validate.py b/src/specify_cli/bundles/command_validate.py new file mode 100644 index 0000000000..be7aeea3c8 --- /dev/null +++ b/src/specify_cli/bundles/command_validate.py @@ -0,0 +1,65 @@ +"""Implementation of ``specify bundle validate``.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _fail, bundle_app + + +@bundle_app.command("validate") +def bundle_validate( + path: Path = typer.Option( + None, "--path", help="Bundle directory or bundle.yml (default: cwd)" + ), + offline: bool = typer.Option( + False, + "--offline", + help="Do not access catalogs; verify references against bundled/installed only", + ), +) -> None: + """Report whether the manifest is well-formed and references resolve.""" + try: + manifest_path = _resolve_manifest_path(path) + from .project import find_project_root + from .manifest import BundleManifest + from .references import make_reference_checker + from .validator import validate_manifest + + manifest = BundleManifest.from_file(manifest_path) + ref_root = find_project_root(manifest_path.parent) or Path.cwd() + ref_warnings: list[str] = [] + checker = make_reference_checker( + ref_root, allow_network=not offline, warnings=ref_warnings + ) + report = validate_manifest(manifest, reference_checker=checker) + report.warnings.extend(ref_warnings) + except BundlerError as exc: + _fail(str(exc)) + return + + for warning in report.warnings: + console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") + if not report.ok: + console.print("[red]Manifest is invalid:[/red]") + for error in report.errors: + console.print(f" [red]-[/red] {_escape_markup(str(error))}") + raise typer.Exit(code=1) + console.print( + f"[green]✓[/green] {_escape_markup(str(manifest.bundle.id))} " + "is well-formed and valid." + ) + + +def _resolve_manifest_path(path: Path | None) -> Path: + target = (path or Path.cwd()).resolve() + if target.is_dir(): + target = target / "bundle.yml" + if not target.exists(): + raise BundlerError(f"No bundle.yml found at '{target}'.") + return target diff --git a/src/specify_cli/bundler/services/conflict.py b/src/specify_cli/bundles/conflict.py similarity index 95% rename from src/specify_cli/bundler/services/conflict.py rename to src/specify_cli/bundles/conflict.py index e7cf356283..78938c5887 100644 --- a/src/specify_cli/bundler/services/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -10,8 +10,8 @@ from dataclasses import dataclass, field -from ..models.manifest import BundleManifest -from ..models.records import InstalledBundleRecord +from .manifest import BundleManifest +from .records import InstalledBundleRecord @dataclass diff --git a/src/specify_cli/bundler/services/installer.py b/src/specify_cli/bundles/installer.py similarity index 98% rename from src/specify_cli/bundler/services/installer.py rename to src/specify_cli/bundles/installer.py index cd877864c5..77bfba7a6e 100644 --- a/src/specify_cli/bundler/services/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -15,9 +15,9 @@ from pathlib import Path from typing import Protocol -from .. import BundlerError -from ..models.manifest import BundleManifest, ComponentRef -from ..models.records import ( +from . import BundlerError +from .manifest import BundleManifest, ComponentRef +from .records import ( InstalledBundleRecord, components_still_needed, find_record, diff --git a/src/specify_cli/bundler/models/manifest.py b/src/specify_cli/bundles/manifest.py similarity index 99% rename from src/specify_cli/bundler/models/manifest.py rename to src/specify_cli/bundles/manifest.py index 39684b2327..71304bc682 100644 --- a/src/specify_cli/bundler/models/manifest.py +++ b/src/specify_cli/bundles/manifest.py @@ -11,9 +11,9 @@ from pathlib import Path from typing import Any -from .. import BundlerError -from ..lib.versioning import is_semver -from ..lib.yamlio import load_yaml +from . import BundlerError +from .versioning import is_semver +from .yamlio import load_yaml SUPPORTED_SCHEMA_VERSIONS = {"1.0"} PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} diff --git a/src/specify_cli/bundler/services/packager.py b/src/specify_cli/bundles/packager.py similarity index 98% rename from src/specify_cli/bundler/services/packager.py rename to src/specify_cli/bundles/packager.py index 4e14934e0a..bd80bf7b7c 100644 --- a/src/specify_cli/bundler/services/packager.py +++ b/src/specify_cli/bundles/packager.py @@ -13,9 +13,9 @@ from dataclasses import dataclass from pathlib import Path -from .. import BundlerError -from ..lib.yamlio import ensure_within -from ..models.manifest import BundleManifest +from . import BundlerError +from .yamlio import ensure_within +from .manifest import BundleManifest from .validator import validate_manifest # Files/dirs never included in an artifact. diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundles/primitives.py similarity index 94% rename from src/specify_cli/bundler/services/primitives.py rename to src/specify_cli/bundles/primitives.py index 94bbc0b8ee..b32342e68d 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundles/primitives.py @@ -24,8 +24,8 @@ from pathlib import Path from typing import Protocol -from .. import BundlerError -from ..models.manifest import ComponentRef +from . import BundlerError +from .manifest import ComponentRef DEFAULT_PRIORITY = 10 @@ -46,7 +46,7 @@ def _assert_pinned_version( actual = str(advertised).strip() if not actual: return - from ..lib.versioning import parse_version + from .versioning import parse_version try: matches = parse_version(actual) == parse_version(pinned) @@ -144,7 +144,7 @@ def _delegate_command(action: str, label: str, call) -> None: class _PresetKindManager: def __init__(self, project_root: Path, allow_network: bool) -> None: - from ...presets import PresetManager + from ..presets import PresetManager self._root = project_root self._allow_network = allow_network @@ -163,8 +163,8 @@ def refresh(self, component: ComponentRef) -> None: self._do_install(component, force=True) def _do_install(self, component: ComponentRef, *, force: bool) -> None: - from ... import get_speckit_version - from ..._assets import _locate_bundled_preset + from .. import get_speckit_version + from .._assets import _locate_bundled_preset speckit_version = get_speckit_version() priority = DEFAULT_PRIORITY if component.priority is None else component.priority @@ -192,7 +192,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: "network access; re-run without --offline." ) - from ...presets import PresetCatalog + from ..presets import PresetCatalog catalog = PresetCatalog(self._root) info = catalog.get_pack_info(component.id) @@ -231,7 +231,7 @@ def remove(self, component: ComponentRef) -> None: class _ExtensionKindManager: def __init__(self, project_root: Path, allow_network: bool) -> None: - from ...extensions import ExtensionManager + from ..extensions import ExtensionManager self._root = project_root self._allow_network = allow_network @@ -250,8 +250,8 @@ def refresh(self, component: ComponentRef) -> None: self._do_install(component, force=True) def _do_install(self, component: ComponentRef, *, force: bool) -> None: - from ... import get_speckit_version - from ..._assets import _locate_bundled_extension + from .. import get_speckit_version + from .._assets import _locate_bundled_extension speckit_version = get_speckit_version() priority = DEFAULT_PRIORITY if component.priority is None else component.priority @@ -280,7 +280,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: "network access; re-run without --offline." ) - from ...extensions import ExtensionCatalog + from ..extensions import ExtensionCatalog catalog = ExtensionCatalog(self._root) info = catalog.get_extension_info(component.id) @@ -322,7 +322,7 @@ def remove(self, component: ComponentRef) -> None: class _WorkflowKindManager: def __init__(self, project_root: Path, allow_network: bool) -> None: - from ...workflows.catalog import WorkflowRegistry + from ..workflows.catalog import WorkflowRegistry self._root = project_root self._allow_network = allow_network @@ -335,13 +335,13 @@ def is_installed(self, component: ComponentRef) -> bool: return False def install(self, component: ComponentRef) -> None: - from ..._assets import _locate_bundled_workflow + from .._assets import _locate_bundled_workflow bundled = _locate_bundled_workflow(component.id) if bundled is not None: workflow_file = bundled / "workflow.yml" try: - from ...workflows.engine import WorkflowDefinition + from ..workflows.engine import WorkflowDefinition definition = WorkflowDefinition.from_yaml(workflow_file) except (OSError, ValueError) as exc: @@ -356,7 +356,7 @@ def install(self, component: ComponentRef) -> None: _assert_pinned_version( "Workflow", component.id, component.version, definition.version ) - from ... import workflow_add + from .. import workflow_add with _chdir(self._root): _delegate_command( @@ -373,7 +373,7 @@ def install(self, component: ComponentRef) -> None: "requires network access; re-run without --offline." ) self._assert_pinned_version(component) - from ... import workflow_add + from .. import workflow_add with _chdir(self._root): _delegate_command( @@ -390,7 +390,7 @@ def _assert_pinned_version(self, component: ComponentRef) -> None: if not component.version: return try: - from ...workflows.catalog import WorkflowCatalog + from ..workflows.catalog import WorkflowCatalog info = WorkflowCatalog(self._root).get_workflow_info(component.id) except Exception: # noqa: BLE001 - catalog unreachable: cannot enforce @@ -401,7 +401,7 @@ def _assert_pinned_version(self, component: ComponentRef) -> None: ) def remove(self, component: ComponentRef) -> None: - from ... import workflow_remove + from .. import workflow_remove with _chdir(self._root): _delegate_command( @@ -412,7 +412,7 @@ def remove(self, component: ComponentRef) -> None: class _StepKindManager: def __init__(self, project_root: Path, allow_network: bool) -> None: - from ...workflows.catalog import StepRegistry + from ..workflows.catalog import StepRegistry self._root = project_root self._allow_network = allow_network @@ -431,7 +431,7 @@ def install(self, component: ComponentRef) -> None: "is disabled. Installing or refreshing this component requires " "network access; re-run without --offline." ) - from ... import workflow_step_add + from .. import workflow_step_add with _chdir(self._root): _delegate_command( @@ -472,7 +472,7 @@ def refresh(self, component: ComponentRef) -> None: # came back but stayed unregistered: ``workflow step list`` # stopped showing it and ``workflow step add`` then refused with # "Step directory already exists". - from ...workflows.catalog import StepRegistry + from ..workflows.catalog import StepRegistry current = StepRegistry(self._root) if metadata is not None and not current.is_installed(component.id): @@ -491,7 +491,7 @@ def refresh(self, component: ComponentRef) -> None: shutil.rmtree(backup_dir.parent, ignore_errors=True) def remove(self, component: ComponentRef) -> None: - from ... import workflow_step_remove + from .. import workflow_step_remove with _chdir(self._root): _delegate_command( diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundles/project.py similarity index 98% rename from src/specify_cli/bundler/lib/project.py rename to src/specify_cli/bundles/project.py index c895bf579d..6476b5c054 100644 --- a/src/specify_cli/bundler/lib/project.py +++ b/src/specify_cli/bundles/project.py @@ -3,8 +3,8 @@ from pathlib import Path -from ..._project import _resolve_init_dir_override -from .. import BundlerError +from .._project import _resolve_init_dir_override +from . import BundlerError from .yamlio import ensure_within, load_json DEFAULT_INTEGRATION = "copilot" diff --git a/src/specify_cli/bundler/models/records.py b/src/specify_cli/bundles/records.py similarity index 99% rename from src/specify_cli/bundler/models/records.py rename to src/specify_cli/bundles/records.py index 748b23759a..1b0628833e 100644 --- a/src/specify_cli/bundler/models/records.py +++ b/src/specify_cli/bundles/records.py @@ -11,8 +11,8 @@ from pathlib import Path from typing import Any -from .. import BundlerError -from ..lib.yamlio import dump_json, ensure_within, load_json +from . import BundlerError +from .yamlio import dump_json, ensure_within, load_json from .manifest import COMPONENT_KINDS, ComponentRef, _text RECORDS_FILENAME = "bundle-records.json" diff --git a/src/specify_cli/bundler/services/references.py b/src/specify_cli/bundles/references.py similarity index 86% rename from src/specify_cli/bundler/services/references.py rename to src/specify_cli/bundles/references.py index b5419237d5..822b3d991a 100644 --- a/src/specify_cli/bundler/services/references.py +++ b/src/specify_cli/bundles/references.py @@ -12,36 +12,36 @@ from pathlib import Path -from ..models.manifest import ComponentRef +from .manifest import ComponentRef def _resolved_locally(root: Path, component: ComponentRef) -> bool: kind = component.kind try: if kind == "presets": - from ..._assets import _locate_bundled_preset - from ...presets import PresetManager + from .._assets import _locate_bundled_preset + from ..presets import PresetManager if _locate_bundled_preset(component.id) is not None: return True return PresetManager(root).get_pack(component.id) is not None if kind == "extensions": - from ..._assets import _locate_bundled_extension - from ...extensions import ExtensionManager + from .._assets import _locate_bundled_extension + from ..extensions import ExtensionManager if _locate_bundled_extension(component.id) is not None: return True return ExtensionManager(root).registry.is_installed(component.id) if kind == "workflows": - from ..._assets import _locate_bundled_workflow - from ...workflows.catalog import WorkflowRegistry + from .._assets import _locate_bundled_workflow + from ..workflows.catalog import WorkflowRegistry if _locate_bundled_workflow(component.id) is not None: return True return WorkflowRegistry(root).is_installed(component.id) if kind == "steps": - from ...workflows import BUILTIN_STEP_TYPES - from ...workflows.catalog import StepRegistry + from ..workflows import BUILTIN_STEP_TYPES + from ..workflows.catalog import StepRegistry # Step types ship with Spec Kit as built-ins (shell, gate, if, ...) # rather than as an on-disk asset directory, so there is no @@ -66,19 +66,19 @@ def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | None: kind = component.kind try: if kind == "presets": - from ...presets import PresetCatalog + from ..presets import PresetCatalog return PresetCatalog(root).get_pack_info(component.id) is not None if kind == "extensions": - from ...extensions import ExtensionCatalog + from ..extensions import ExtensionCatalog return ExtensionCatalog(root).get_extension_info(component.id) is not None if kind == "workflows": - from ...workflows.catalog import WorkflowCatalog + from ..workflows.catalog import WorkflowCatalog return WorkflowCatalog(root).get_workflow_info(component.id) is not None if kind == "steps": - from ...workflows.catalog import StepCatalog + from ..workflows.catalog import StepCatalog return StepCatalog(root).get_step_info(component.id) is not None except Exception: # noqa: BLE001 - catalog may be unreachable/misconfigured diff --git a/src/specify_cli/bundler/services/resolver.py b/src/specify_cli/bundles/resolver.py similarity index 97% rename from src/specify_cli/bundler/services/resolver.py rename to src/specify_cli/bundles/resolver.py index 9d9c61e79f..0e8f4ec274 100644 --- a/src/specify_cli/bundler/services/resolver.py +++ b/src/specify_cli/bundles/resolver.py @@ -10,9 +10,9 @@ from dataclasses import dataclass, field from pathlib import Path -from .. import BundlerError -from ..lib.versioning import satisfies -from ..models.manifest import BundleManifest, ComponentRef +from . import BundlerError +from .versioning import satisfies +from .manifest import BundleManifest, ComponentRef @dataclass diff --git a/src/specify_cli/bundles/sources.py b/src/specify_cli/bundles/sources.py new file mode 100644 index 0000000000..3d78d29736 --- /dev/null +++ b/src/specify_cli/bundles/sources.py @@ -0,0 +1,346 @@ +"""Resolve local and remote bundle manifests for bundle consumers.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .._download_security import MAX_DOWNLOAD_BYTES, read_response_limited +from . import BundlerError + +# ZIP magic-byte signatures cover local headers, empty archives, and spanning markers. +_ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08") + + +def _local_manifest_source(arg: str): + """Return a :class:`BundleManifest` if *arg* points at a local bundle. + + Supports a built ``.zip`` artifact, a bundle directory, or a ``bundle.yml`` + file. Returns ``None`` when *arg* is not an existing path, so callers fall + back to catalog-stack resolution by bundle id. + """ + from .manifest import BundleManifest + + candidate = Path(arg).expanduser() + if not candidate.exists(): + return None + + if candidate.is_dir(): + manifest_path = candidate / "bundle.yml" + if not manifest_path.exists(): + raise BundlerError(f"No bundle.yml found in '{candidate}'.") + return BundleManifest.from_file(manifest_path) + + if candidate.suffix == ".zip": + import yaml as _yaml + + from .._download_security import open_zip_bounded, read_zip_member_limited + + with open_zip_bounded(candidate, error_type=BundlerError) as archive: + try: + archive.getinfo("bundle.yml") + except KeyError as exc: + raise BundlerError( + f"Artifact '{candidate}' does not contain a bundle.yml." + ) from exc + raw = read_zip_member_limited( + archive, + "bundle.yml", + error_type=BundlerError, + label="bundle manifest", + ) + # The bounded-zip helpers above keep archive failures inside the + # BundlerError contract, but the manifest bytes need the same + # treatment as yamlio.load_yaml: decode as UTF-8 explicitly — + # feeding PyYAML the byte stream would let its Reader auto-detect + # a UTF-16 BOM and accept a manifest the directory and bundle.yml + # sources reject. + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise BundlerError( + f"Could not read bundle.yml inside '{candidate}': {exc}" + ) from exc + try: + data = _yaml.safe_load(text) + except _yaml.YAMLError as exc: + # The sibling directory/bundle.yml branches reach YAML through + # load_yaml(), which turns a parse failure into a BundlerError. This + # branch parses inline, so without this it raises a raw YAMLError -- + # neither a ValueError nor an OSError -- which escapes + # bundle_install()'s `except BundlerError` as a traceback. + raise BundlerError( + f"Invalid YAML in bundle.yml inside '{candidate}': {exc}" + ) from exc + return BundleManifest.from_dict(data) + + if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"): + return BundleManifest.from_file(candidate) + + raise BundlerError( + f"'{candidate}' is not a recognised bundle source (.zip artifact, bundle " + "directory, or bundle.yml)." + ) + + +def _download_manifest(resolved, *, offline: bool): + """Resolve a bundle's manifest from its catalog ``download_url``. + + Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost), + matching the extensions/presets/workflows catalog systems. Remote URLs are + fetched with the shared authenticated, redirect-validated HTTP client, and + only when not ``--offline``. + + Local and ``file://`` sources are intentionally not resolved here: to + install a bundle from disk, pass the path positionally + (``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a + ``.zip`` artifact also works), which :func:`_local_manifest_source` handles + before catalog resolution and which never touches ``download_url``. + """ + from urllib.parse import urlparse + + url = resolved.entry.download_url + if not url: + raise BundlerError( + f"Catalog entry '{resolved.entry.id}' has no download_url; cannot resolve " + "its manifest." + ) + # A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``) + # makes urlparse raise ValueError. Surface it as the documented + # BundlerError, like the sibling ``_validate_remote_url``, rather than + # leaking a raw ValueError past the callers, which only catch BundlerError. + try: + parsed = urlparse(url) + except ValueError: + raise BundlerError( + f"Catalog entry '{resolved.entry.id}' has a malformed download_url: {url}" + ) from None + scheme = parsed.scheme.lower() + + # ``file://`` URLs and bare filesystem paths (including Windows drive paths + # like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme) + # are not valid catalog download URLs. Catalog URLs are HTTPS-only across + # every catalog system; installing from disk is done by passing the path + # positionally, which never reaches URL resolution. Give an actionable + # error rather than accepting a scheme the rest of the codebase rejects. + if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url): + raise BundlerError( + f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url " + f"({url}); catalog download URLs must be HTTPS (http for localhost) — " + "a file:// URL, a local filesystem path, or a scheme-less value " + "(e.g. 'example.com/bundle.zip') is not accepted. " + "To install a bundle from disk, pass the path directly: " + "'specify bundle install '." + ) + + # Validate the scheme/host *before* the offline gate so an invalid or + # non-HTTPS download_url reports the real problem in every mode, rather + # than a misleading "Network access disabled" under --offline. + # (_download_remote_manifest re-checks this, but only once network access + # is permitted.) HTTPS-only, http allowed for localhost. + _require_https(f"bundle '{resolved.entry.id}'", url) + + if offline: + raise BundlerError( + f"Network access disabled; cannot download bundle '{resolved.entry.id}' " + f"from {url}." + ) + manifest = _download_remote_manifest( + resolved.entry.id, + url, + expected_sha256=getattr(resolved.entry, "sha256", None), + ) + _validate_catalog_manifest(resolved.entry, manifest) + return manifest + + +def _require_https(label: str, url: str) -> None: + from urllib.parse import urlparse + + # urlparse / hostname access raise ValueError on a malformed authority; + # keep the documented BundlerError contract (older Pythons surface this via + # the .hostname access below rather than at the urlparse call). + try: + parsed = urlparse(url) + hostname = parsed.hostname + # Accessing ``port`` performs urllib's syntax/range validation. + _ = parsed.port + except ValueError: + raise BundlerError( + f"Refusing to download {label}: URL is malformed: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + raise BundlerError(f"Refusing to download {label} over non-HTTPS URL: {url}") + if not parsed.hostname: + raise BundlerError(f"Refusing to download {label} from URL with no host: {url}") + + +def _download_remote_manifest( + entry_id: str, + url: str, + *, + expected_sha256: str | None = None, +): + """Fetch a remote bundle artifact over HTTPS and extract its manifest.""" + import tempfile + from pathlib import PurePosixPath + from urllib.parse import urlparse as _urlparse + + import yaml as _yaml + + from ..authentication.http import github_provider_hosts, open_url + from .._github_http import resolve_github_release_asset_api_url + from .manifest import BundleManifest + from ..shared_infra import verify_archive_sha256 + + def _validate_redirect(old_url: str, new_url: str) -> None: + _require_https(f"bundle '{entry_id}'", new_url) + + _require_https(f"bundle '{entry_id}'", url) + + # For private/SSO-protected GitHub repos, browser release download URLs + # (https://github.com///releases/download//) + # redirect to an HTML/SSO page instead of delivering the asset. Resolve + # such URLs to the GitHub REST API asset URL so the authenticated client + # can download the actual file. + extra_headers = None + effective_url = url + resolved = resolve_github_release_asset_api_url( + url, open_url, timeout=30, github_hosts=github_provider_hosts() + ) + if resolved: + effective_url = resolved + _require_https(f"bundle '{entry_id}'", effective_url) + extra_headers = {"Accept": "application/octet-stream"} + + # Human-readable description of where the bytes came from, reused across + # all post-download error messages so failures point at the catalog URL + # (and resolved API URL, if any) instead of an opaque temp path. + if effective_url != url: + _source_desc = f"{url} (resolved to {effective_url})" + else: + _source_desc = url + + try: + with open_url( + effective_url, + timeout=30, + redirect_validator=_validate_redirect, + extra_headers=extra_headers, + ) as resp: + _require_https(f"bundle '{entry_id}'", resp.geturl()) + raw = read_response_limited( + resp, + max_bytes=MAX_DOWNLOAD_BYTES, + error_type=BundlerError, + label=f"bundle '{entry_id}' download", + ) + verify_archive_sha256( + raw, + expected_sha256, + entry_id, + BundlerError, + ) + except BundlerError: + raise + except Exception as exc: # noqa: BLE001 + # Report the original catalog URL so users know which entry to fix, + # and include the resolved URL when it differs for easier debugging. + raise BundlerError( + f"Failed to download bundle '{entry_id}' from {_source_desc}: {exc}" + ) from exc + + # A .zip artifact is written to a temp file and parsed via the local-source + # path (which extracts bundle.yml); any other payload is treated as YAML. + # Detection uses the path component of the original catalog URL (via + # PurePosixPath so query strings and fragments are ignored, and URL paths + # are always treated as POSIX regardless of host OS), falling back to the + # module-level _ZIP_SIGNATURES magic-byte check for direct REST API asset + # URLs which carry no file extension. + _url_ext = PurePosixPath(_urlparse(url).path).suffix.lower() + try: + if _url_ext == ".zip" or raw[:4] in _ZIP_SIGNATURES: + with tempfile.TemporaryDirectory() as tmp: + artifact = Path(tmp) / "bundle.zip" + artifact.write_bytes(raw) + # Wrap ZIP parsing so any failure (BadZipFile, missing + # bundle.yml, etc.) references the source URL rather than the + # opaque temporary path, consistent with the download-error + # handling above. + try: + manifest = _local_manifest_source(str(artifact)) + except Exception as exc: # noqa: BLE001 + raise BundlerError( + f"Downloaded artifact for bundle '{entry_id}' from " + f"{_source_desc} is not a valid bundle: {exc}" + ) from exc + # _local_manifest_source returns None only when the file does + # not exist; since we just wrote *artifact* that cannot happen + # here. The explicit guard ensures callers never receive None + # and silently degrade instead of raising a clear error. + if manifest is None: + raise BundlerError( + f"Downloaded artifact for bundle '{entry_id}' from " + f"{_source_desc} is not a valid bundle." + ) + return manifest + + # Decode as UTF-8 explicitly -- matching yamlio.load_yaml's contract -- + # instead of feeding PyYAML the raw byte stream. PyYAML's Reader + # auto-detects a UTF-16 BOM and would silently *accept* a manifest + # that the local directory/bundle.yml sources reject, letting this + # remote-download path diverge from them (see the sibling .zip fix + # for _local_manifest_source, which had the identical bug). + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise BundlerError( + f"Downloaded content for bundle '{entry_id}' from " + f"{_source_desc} could not be read: {exc}" + ) from exc + data = _yaml.safe_load(text) + return BundleManifest.from_dict(data) + except BundlerError: + raise + except _yaml.YAMLError as exc: + raise BundlerError( + f"Downloaded content for bundle '{entry_id}' from {_source_desc} " + f"is not valid YAML: {exc}" + ) from exc + except Exception as exc: # noqa: BLE001 + raise BundlerError( + f"Failed to parse downloaded bundle '{entry_id}' from {_source_desc}: {exc}" + ) from exc + + +def _validate_manifest_structure(manifest, *, source: str) -> None: + """Reject a malformed manifest before any project mutation can occur.""" + from .validator import validate_manifest + + report = validate_manifest(manifest) + if report.ok: + return + raise BundlerError( + f"{source} contains an invalid bundle manifest:\n - " + + "\n - ".join(report.errors) + ) + + +def _validate_catalog_manifest(entry, manifest) -> None: + """Bind a downloaded manifest to the catalog identity that selected it.""" + if manifest.bundle.id != entry.id: + raise BundlerError( + f"Downloaded bundle id mismatch: catalog entry {entry.id!r} points to " + f"a manifest for {manifest.bundle.id!r}." + ) + if manifest.bundle.version != entry.version: + raise BundlerError( + f"Downloaded bundle version mismatch for {entry.id!r}: catalog declares " + f"{entry.version!r}, but the manifest declares " + f"{manifest.bundle.version!r}." + ) + _validate_manifest_structure( + manifest, + source=f"Downloaded bundle {entry.id!r}", + ) diff --git a/src/specify_cli/bundler/services/validator.py b/src/specify_cli/bundles/validator.py similarity index 93% rename from src/specify_cli/bundler/services/validator.py rename to src/specify_cli/bundles/validator.py index a1b3ae6c93..0259b1800a 100644 --- a/src/specify_cli/bundler/services/validator.py +++ b/src/specify_cli/bundles/validator.py @@ -10,9 +10,9 @@ from dataclasses import dataclass, field from typing import Callable -from .. import BundlerError -from ..lib.versioning import parse_constraint -from ..models.manifest import BundleManifest, ComponentRef +from . import BundlerError +from .versioning import parse_constraint +from .manifest import BundleManifest, ComponentRef # A reference checker returns None when resolvable, or an error string. ReferenceChecker = Callable[[ComponentRef], str | None] diff --git a/src/specify_cli/bundler/lib/versioning.py b/src/specify_cli/bundles/versioning.py similarity index 99% rename from src/specify_cli/bundler/lib/versioning.py rename to src/specify_cli/bundles/versioning.py index 552f21950c..da980de94a 100644 --- a/src/specify_cli/bundler/lib/versioning.py +++ b/src/specify_cli/bundles/versioning.py @@ -6,7 +6,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import InvalidVersion, Version -from .. import BundlerError +from . import BundlerError # Common SemVer prerelease spellings (``1.2.3-rc1``, ``1.2.3-alpha.1``) that # PEP 440 / ``packaging`` rejects verbatim. Normalized to PEP 440 before diff --git a/src/specify_cli/bundler/lib/yamlio.py b/src/specify_cli/bundles/yamlio.py similarity index 99% rename from src/specify_cli/bundler/lib/yamlio.py rename to src/specify_cli/bundles/yamlio.py index a63d05ba4e..40b3be4924 100644 --- a/src/specify_cli/bundler/lib/yamlio.py +++ b/src/specify_cli/bundles/yamlio.py @@ -1,7 +1,7 @@ """YAML/JSON read-write helpers with path confinement (Constitution Principles IV & V). All reads/writes go through these functions so that: -- IO failures degrade into actionable :class:`~specify_cli.bundler.BundlerError`s +- IO failures degrade into actionable :class:`~specify_cli.bundles.BundlerError`s rather than raw tracebacks, and - every path can be confined to an allowed root via :func:`ensure_within`. """ @@ -17,7 +17,7 @@ import yaml -from .. import BundlerError +from . import BundlerError def ensure_within(root: Path, candidate: Path) -> Path: diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py deleted file mode 100644 index fb7a0c73c2..0000000000 --- a/src/specify_cli/commands/bundle/__init__.py +++ /dev/null @@ -1,1135 +0,0 @@ -"""``specify bundle`` command group — discover, install, author Spec Kit bundles. - -This module is the CLI/UX layer only (Principle I: thin commands over services). -Each command resolves a project, builds a catalog stack, delegates to a bundler -service, and renders Rich output. ``--json`` emits machine-readable data on -stdout; human logs go to stderr/console. -""" -from __future__ import annotations - -import json as _json -import re -from pathlib import Path - -import typer -from rich.markup import escape as _escape_markup - -from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited -from ..._console import console, err_console -from ...bundler import BundlerError -from ...bundler.lib.project import ( - active_integration, - find_project_root, - require_project_root, -) -from ...bundler.models.records import load_records - -bundle_app = typer.Typer( - name="bundle", - help="Discover, install, and author Spec Kit bundles", - add_completion=False, -) - -bundle_catalog_app = typer.Typer( - name="catalog", - help="Manage bundle catalog sources", - add_completion=False, -) -bundle_app.add_typer(bundle_catalog_app, name="catalog") - - -# ===== helpers ===== - - -def _fail(message: str) -> None: - """Print an actionable error to stderr and exit non-zero.""" - # Use the stderr console so the error never lands on stdout, which under - # ``--json`` carries the machine-readable payload and must stay parseable. - # Escape the message: every caller passes ``str(exc)`` from a BundlerError - # that interpolates untrusted data (a CLI argument, a catalog url, a - # bundle.yml field), so a '[...]' in it would be parsed as a Rich style tag - # -- silently swallowing the text, or raising MarkupError on an unbalanced - # closer and replacing the whole message with a traceback. - err_console.print(f"[red]Error:[/red] {_escape_markup(message)}", style=None) - raise typer.Exit(code=1) - - -def _user_config_dir() -> Path: - # User-scope Spec Kit config lives under ~/.specify (same convention as - # auth.json, extension/preset catalogs). Passing this through to the source - # stack is what makes the documented project > user > built-in precedence - # reachable from the CLI. - return Path.home() / ".specify" - - -def _build_stack(project_root: Path, *, offline: bool): - from ...bundler.services.adapters import make_catalog_fetcher - from ...bundler.services.catalog_stack import CatalogStack - - fetcher = make_catalog_fetcher(allow_network=not offline) - return CatalogStack.load(project_root, fetcher, user_config_dir=_user_config_dir()) - - -def _speckit_version() -> str: - from ..._assets import get_speckit_version - - return get_speckit_version() - - -def _trust_level(verified: bool) -> str: - """Trust framing for a catalog entry (FR-010): org-curated vs community.""" - return "verified" if verified else "community" - - -def _trust_badge(verified: bool) -> str: - return ( - "[green]✔ verified[/green]" - if verified - else "[yellow]community[/yellow]" - ) - - -def _default_script_type() -> str: - """OS-appropriate default script flavor (FR-013).""" - import os - - return "ps" if os.name == "nt" else "sh" - - -def _run_init(integration: str, *, script_type: str, offline: bool = False) -> None: - """Idempotently scaffold a Spec Kit project here via the existing ``init`` machinery. - - Reuses the real ``specify init`` command callback in-process (Principle I) - with ``--here --force`` so it is non-interactive and merges into the current - directory. - """ - from ... import app - - init_cb = next( - c.callback - for c in app.registered_commands - if c.callback and c.callback.__name__ == "init" - ) - try: - init_cb( - project_name=None, - script_type=script_type, - ignore_agent_tools=True, - here=True, - force=True, - skip_tls=False, - debug=False, - github_token=None, - offline=offline, - preset=None, - integration=integration, - integration_options=None, - extensions=None, - trust_extension_urls=False, - ) - except typer.Exit as exc: - if exc.exit_code: - raise BundlerError( - f"Failed to initialize a Spec Kit project (integration '{integration}')." - ) from exc - - -def _resolve_init_integration(override: str | None, manifest) -> str: - """Precedence (FR-013): explicit override → bundle-declared → default.""" - from ..._agent_config import resolve_default_init_integration - - if override: - return override - if manifest is not None and manifest.integration is not None: - return manifest.integration.id - return resolve_default_init_integration() - - -# ===== Consume ===== - - -@bundle_app.command("search") -def bundle_search( - query: str = typer.Argument("", help="Optional text query"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), - as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), -) -> None: - """List matching bundles across the active catalog stack.""" - try: - project_root = find_project_root() or Path.cwd() - stack = _build_stack(project_root, offline=offline) - results = stack.search(query) - except BundlerError as exc: - _fail(str(exc)) - return - - if as_json: - payload = [ - { - "id": r.entry.id, - "name": r.entry.name, - "role": r.entry.role, - "version": r.entry.version, - "description": r.entry.description, - "source": r.source.id, - "install_policy": r.source.install_policy.value, - "verified": r.entry.verified, - "trust": _trust_level(r.entry.verified), - } - for r in results - ] - print(_json.dumps(payload, indent=2)) - return - - if not results: - console.print("[yellow]No matching bundles found.[/yellow]") - return - - console.print("\n[bold cyan]Bundles:[/bold cyan]\n") - for r in results: - policy = ( - "[dim](discovery-only)[/dim]" - if not r.source.install_allowed - else "" - ) - console.print( - f" [bold]{_escape_markup(str(r.entry.id))}[/bold] " - f"v{_escape_markup(str(r.entry.version))} — " - f"{_escape_markup(str(r.entry.name))} " - f"[dim]({_escape_markup(str(r.entry.role))})[/dim] " - f"{_trust_badge(r.entry.verified)} {policy}" - ) - console.print(f" {_escape_markup(str(r.entry.description))}") - console.print( - f" [dim]source: {_escape_markup(str(r.source.id))}[/dim]" - ) - - -@bundle_app.command("info") -def bundle_info( - bundle_id: str = typer.Argument(..., help="Bundle id to inspect"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), - as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), -) -> None: - """Show full metadata and the fully expanded component set (== what install adds).""" - try: - project_root = find_project_root() or Path.cwd() - stack = _build_stack(project_root, offline=offline) - resolved = stack.resolve(bundle_id) - # `info` must show the fully expanded component set that `install` would - # apply (contracts/cli-commands.md). Expansion happens regardless of - # install policy — discovery-only bundles stay inspectable; only - # `install` is refused. But if the manifest itself can't be resolved - # (e.g. --offline against an https:// download_url, or a download - # failure), fail loudly and exit non-zero rather than silently - # degrading to catalog `provides` counts, so users never mistake an - # unverifiable bundle for a known/installable one. - manifest = _download_manifest(resolved, offline=offline) - except BundlerError as exc: - _fail(str(exc)) - return - - overlaps = _bundle_overlaps(project_root, manifest, offline=offline) - components = _manifest_component_view(manifest) - - entry = resolved.entry - if as_json: - payload = { - "id": entry.id, - "name": entry.name, - "version": entry.version, - "role": entry.role, - "description": entry.description, - "author": entry.author, - "license": entry.license, - "source": resolved.source.id, - "install_policy": resolved.source.install_policy.value, - "provides": entry.provides, - "requires": {"speckit_version": entry.requires_speckit_version}, - "verified": entry.verified, - "trust": _trust_level(entry.verified), - "integration": (manifest.integration.id if manifest and manifest.integration else None), - "components": components, - "overlaps": overlaps, - } - print(_json.dumps(payload, indent=2)) - return - - console.print( - f"\n[bold cyan]{_escape_markup(str(entry.id))}[/bold cyan] " - f"v{_escape_markup(str(entry.version))} — " - f"{_escape_markup(str(entry.name))}" - ) - console.print(f" Role: {_escape_markup(str(entry.role))}") - console.print(f" {_escape_markup(str(entry.description))}") - console.print( - f" Author: {_escape_markup(str(entry.author))} " - f"License: {_escape_markup(str(entry.license))}" - ) - console.print( - f" Source: {_escape_markup(str(resolved.source.id))} " - f"({resolved.source.install_policy.value})" - ) - console.print(f" Trust: {_trust_badge(entry.verified)}") - if entry.requires_speckit_version: - console.print( - f" Requires Spec Kit: " - f"{_escape_markup(str(entry.requires_speckit_version))}" - ) - if manifest and manifest.integration: - console.print( - f" Integration: {_escape_markup(str(manifest.integration.id))}" - ) - - if components: - console.print("\n [bold]Components[/bold] (added on install):") - for kind in ("extensions", "presets", "steps", "workflows"): - items = [c for c in components if c["kind"] == kind] - if not items: - continue - console.print(f" [bold]{kind}:[/bold]") - for item in items: - console.print( - f" - {_escape_markup(_format_component(item))}" - ) - else: - console.print("\n [bold]Provides:[/bold]") - for kind in ("extensions", "presets", "steps", "workflows"): - count = entry.provides.get(kind, 0) - if count: - console.print(f" {kind}: {_escape_markup(str(count))}") - - if overlaps: - console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]") - for overlap in overlaps: - console.print( - f" [yellow]-[/yellow] {_escape_markup(str(overlap))}" - ) - - if not resolved.install_allowed: - console.print( - "\n [yellow]This source is discovery-only; the bundle cannot be " - "installed from here.[/yellow]" - ) - - -@bundle_app.command("list") -def bundle_list( - as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), -) -> None: - """List bundles currently installed in the project with versions.""" - try: - project_root = require_project_root() - records = load_records(project_root) - except BundlerError as exc: - _fail(str(exc)) - return - - if as_json: - print(_json.dumps([r.to_dict() for r in records], indent=2)) - return - - if not records: - console.print("[yellow]No bundles installed.[/yellow]") - console.print("\nInstall one with: [cyan]specify bundle install [/cyan]") - return - - console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n") - for record in records: - console.print( - f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] " - f"v{_escape_markup(str(record.version))} " - f"[dim]({len(record.contributed_components)} components, " - f"installed {_escape_markup(str(record.installed_at))})[/dim]" - ) - - -@bundle_app.command("install") -def bundle_install( - bundle_id: str = typer.Argument( - ..., - help="Bundle id (from the catalog stack) or a local path to a .zip " - "artifact, bundle directory, or bundle.yml", - ), - integration: str = typer.Option(None, "--integration", help="Override integration"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), - refresh: bool = typer.Option( - False, "--refresh", help="Refresh owned components from this bundle source", - ), -) -> None: - """Install a bundle's full component set through each primitive's machinery. - - ``bundle_id`` may be a catalog bundle id, or a local path to a built - artifact (``.zip``), a bundle directory, or a ``bundle.yml`` file. Local - sources install directly without consulting the catalog stack. Use - ``--refresh`` to update owned components from a newer local source. - """ - try: - from ...bundler.lib.project import find_project_root - from ...bundler.services.adapters import DefaultPrimitiveInstaller - from ...bundler.services.installer import install_bundle - from ...bundler.services.resolver import resolve_install_plan - - project_root = find_project_root() - - local_manifest = _local_manifest_source(bundle_id) - if local_manifest is not None: - manifest = local_manifest - _validate_manifest_structure( - manifest, - source=f"Local bundle source {bundle_id!r}", - ) - else: - stack = _build_stack(project_root or Path.cwd(), offline=offline) - resolved = stack.resolve(bundle_id) - - if not resolved.install_allowed: - raise BundlerError( - f"Bundle '{bundle_id}' resolves only from a discovery-only source " - f"('{resolved.source.id}'); it cannot be installed from there." - ) - manifest = _download_manifest(resolved, offline=offline) - - if project_root is None: - init_integration = _resolve_init_integration(integration, manifest) - # Resolve all hard compatibility gates before ``specify init``. - # Otherwise an incompatible but structurally valid bundle would - # initialize a project and only then fail its version/integration - # checks, leaving state behind after a failed install. - resolve_install_plan( - manifest, - speckit_version=_speckit_version(), - active_integration=init_integration, - integration_explicit=True, - ) - console.print( - f"[cyan]No Spec Kit project here; initializing with integration " - f"'{_escape_markup(str(init_integration))}'…[/cyan]" - ) - _run_init(init_integration, script_type=_default_script_type(), offline=offline) - project_root = require_project_root() - - for overlap in _bundle_overlaps(project_root, manifest, offline=offline): - console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}") - - # For an already-initialized project, the project's recorded active - # integration is authoritative — an explicit --integration must not be - # able to bypass the FR-019 integration-clash guard. The override only - # selects the integration at init time (handled above) or confirms the - # target when the active integration cannot be determined. - detected = active_integration(project_root) - plan = resolve_install_plan( - manifest, - speckit_version=_speckit_version(), - active_integration=detected if detected is not None else integration, - integration_explicit=bool(integration) and detected is None, - ) - for warning in plan.warnings: - console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") - - result = install_bundle( - project_root, - plan, - DefaultPrimitiveInstaller(allow_network=not offline), - manifest=manifest, - refresh=refresh, - ) - except BundlerError as exc: - _fail(str(exc)) - return - - refresh_summary = ( - f", {len(result.refreshed)} refreshed, {len(result.uninstalled)} removed" - if refresh else "" - ) - console.print( - f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' " - f"({len(result.installed)} added, {len(result.skipped)} already present" - f"{refresh_summary})." - ) - - -@bundle_app.command("add") -def bundle_add( - bundle_id: str = typer.Argument( - ..., - help="Bundle id (from the catalog stack) or a local path to a .zip " - "artifact, bundle directory, or bundle.yml", - ), - integration: str = typer.Option(None, "--integration", help="Override integration"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), - refresh: bool = typer.Option( - False, "--refresh", help="Refresh owned components from this bundle source", - ), -) -> None: - """Install a bundle's full component set (alias for install).""" - return bundle_install( - bundle_id=bundle_id, - integration=integration, - offline=offline, - refresh=refresh, - ) - - -@bundle_app.command("update") -def bundle_update( - bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"), - all_bundles: bool = typer.Option(False, "--all", help="Update every installed bundle"), - integration: str = typer.Option(None, "--integration", help="Override integration"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), -) -> None: - """Re-resolve and refresh a bundle's components via each primitive's update path.""" - try: - project_root = require_project_root() - records = load_records(project_root) - if not all_bundles and not bundle_id: - raise BundlerError("Specify a bundle id or use --all.") - targets = ( - [r.bundle_id for r in records] - if all_bundles - else [bundle_id] - ) - if not targets: - console.print("[yellow]No installed bundles to update.[/yellow]") - return - - stack = _build_stack(project_root, offline=offline) - from ...bundler.services.adapters import DefaultPrimitiveInstaller - from ...bundler.services.installer import install_bundle - from ...bundler.services.resolver import resolve_install_plan - - installer = DefaultPrimitiveInstaller(allow_network=not offline) - for target in targets: - if not any(r.bundle_id == target for r in records): - raise BundlerError(f"Bundle '{target}' is not installed.") - resolved = stack.resolve(target) - if not resolved.install_allowed: - raise BundlerError( - f"Bundle '{target}' resolves only from a discovery-only source " - f"('{resolved.source.id}'); it cannot be updated from there. " - "Update requires an install-allowed source (FR-025)." - ) - manifest = _download_manifest(resolved, offline=offline) - detected = active_integration(project_root) - plan = resolve_install_plan( - manifest, - speckit_version=_speckit_version(), - active_integration=detected if detected is not None else integration, - integration_explicit=bool(integration) and detected is None, - ) - install_bundle(project_root, plan, installer, manifest=manifest, refresh=True) - console.print( - f"[green]✓[/green] Updated '{_escape_markup(str(target))}' " - f"to v{_escape_markup(str(plan.version))}." - ) - except BundlerError as exc: - _fail(str(exc)) - return - - -@bundle_app.command("remove") -def bundle_remove( - bundle_id: str = typer.Argument(..., help="Installed bundle id to remove"), -) -> None: - """Uninstall only the components this bundle contributed (no collateral removals).""" - try: - project_root = require_project_root() - from ...bundler.services.adapters import DefaultPrimitiveInstaller - from ...bundler.services.installer import remove_bundle - - result = remove_bundle(project_root, bundle_id, DefaultPrimitiveInstaller()) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Removed '{_escape_markup(str(result.bundle_id))}' " - f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)." - ) - - -# ===== Author ===== - - -@bundle_app.command("validate") -def bundle_validate( - path: Path = typer.Option( - None, "--path", help="Bundle directory or bundle.yml (default: cwd)" - ), - offline: bool = typer.Option( - False, - "--offline", - help="Do not access catalogs; verify references against bundled/installed only", - ), -) -> None: - """Report whether the manifest is well-formed and references resolve.""" - try: - manifest_path = _resolve_manifest_path(path) - from ...bundler.lib.project import find_project_root - from ...bundler.models.manifest import BundleManifest - from ...bundler.services.references import make_reference_checker - from ...bundler.services.validator import validate_manifest - - manifest = BundleManifest.from_file(manifest_path) - ref_root = find_project_root(manifest_path.parent) or Path.cwd() - ref_warnings: list[str] = [] - checker = make_reference_checker( - ref_root, allow_network=not offline, warnings=ref_warnings - ) - report = validate_manifest(manifest, reference_checker=checker) - report.warnings.extend(ref_warnings) - except BundlerError as exc: - _fail(str(exc)) - return - - for warning in report.warnings: - console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") - if not report.ok: - console.print("[red]Manifest is invalid:[/red]") - for error in report.errors: - console.print(f" [red]-[/red] {_escape_markup(str(error))}") - raise typer.Exit(code=1) - console.print( - f"[green]✓[/green] {_escape_markup(str(manifest.bundle.id))} " - "is well-formed and valid." - ) - - -@bundle_app.command("build") -def bundle_build( - path: Path = typer.Option( - None, "--path", help="Bundle directory (default: cwd)" - ), - output: Path = typer.Option(None, "--output", help="Output directory for the artifact"), -) -> None: - """Produce a single versioned distributable artifact (.zip).""" - try: - bundle_dir = (path or Path.cwd()).resolve() - if bundle_dir.is_file(): - bundle_dir = bundle_dir.parent - from ...bundler.services.packager import build_bundle - - result = build_bundle(bundle_dir, output_dir=output) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Built {_escape_markup(result.artifact_path.name)} " - f"({result.file_count} files) → " - f"{_escape_markup(str(result.artifact_path))}" - ) - - -@bundle_app.command("init") -def bundle_init( - bundle: str = typer.Argument(None, help="Optional bundle to install after init"), - integration: str = typer.Option(None, "--integration", help="Integration override"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), -) -> None: - """Ensure the project is initialized (idempotent), then optionally install a bundle.""" - from ...bundler.lib.project import find_project_root - - try: - project_root = find_project_root() - if project_root is None: - init_integration = _resolve_init_integration(integration, None) - console.print( - f"[cyan]Initializing a Spec Kit project with integration " - f"'{_escape_markup(str(init_integration))}'…[/cyan]" - ) - _run_init(init_integration, script_type=_default_script_type(), offline=offline) - project_root = require_project_root() - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Spec Kit project ready at " - f"{_escape_markup(str(project_root))}." - ) - if bundle: - bundle_install(bundle, integration=integration, offline=offline) - - -# ===== Catalog management ===== - - -@bundle_catalog_app.command("list") -def catalog_list() -> None: - """Print the active, priority-ordered catalog stack with scope and policy.""" - try: - project_root = require_project_root() - from ...bundler.models.catalog import Scope, load_source_stack - - sources = load_source_stack(project_root, user_config_dir=_user_config_dir()) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print("\n[bold cyan]Catalog stack[/bold cyan] (highest precedence first):\n") - only_builtin = all(s.scope == Scope.BUILTIN for s in sources) - for source in sources: - console.print( - f" [bold]{_escape_markup(str(source.id))}[/bold] " - f"priority={source.priority} " - f"policy={source.install_policy.value} scope={source.scope.value}" - ) - console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]") - if only_builtin: - console.print("\n[dim]Using the built-in default stack.[/dim]") - - -@bundle_catalog_app.command("add") -def catalog_add( - url: str = typer.Argument(..., help="Catalog URL"), - policy: str = typer.Option( - "install-allowed", "--policy", help="install-allowed | discovery-only" - ), - priority: int = typer.Option(10, "--priority", help="Source priority (lower = higher)"), - source_id: str = typer.Option(None, "--id", help="Explicit source id"), -) -> None: - """Register a project-scoped catalog source and persist it.""" - try: - project_root = require_project_root() - from ...bundler.commands_impl.catalog_config import add_source - - source = add_source(project_root, url, policy=policy, priority=priority, source_id=source_id) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' " - f"(priority {source.priority}, {source.install_policy.value})." - ) - - -@bundle_catalog_app.command("remove") -def catalog_remove( - id_or_url: str = typer.Argument(..., help="Source id or url to remove"), -) -> None: - """Remove a project-scoped catalog source (built-in defaults can't be deleted).""" - try: - project_root = require_project_root() - from ...bundler.commands_impl.catalog_config import remove_source - - removed = remove_source(project_root, id_or_url) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Removed catalog source " - f"'{_escape_markup(str(removed))}'." - ) - - -# ZIP magic-byte signatures used to detect .zip payloads from REST API asset -# URLs, which carry no file extension. The three signatures cover all valid -# ZIP variants (PK\x03\x04 = local file header, PK\x05\x06 = empty archive, -# PK\x07\x08 = spanning marker) without the false-positive risk of checking -# only the 2-byte "PK" prefix. -_ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08") - - -# ===== internal helpers ===== - - -def _manifest_component_view(manifest) -> list[dict]: - """Flatten a manifest's components to JSON-friendly dicts (id, version, ...).""" - if manifest is None: - return [] - view: list[dict] = [] - for component in manifest.components: - item = { - "kind": component.kind, - "id": component.id, - "version": component.version, - } - if component.priority is not None: - item["priority"] = component.priority - if component.strategy is not None: - item["strategy"] = component.strategy - view.append(item) - return view - - -def _format_component(item: dict) -> str: - label = f"{item['id']} v{item['version']}" if item.get("version") else item["id"] - extras = [] - if item.get("priority") is not None: - extras.append(f"priority={item['priority']}") - if item.get("strategy") is not None: - extras.append(f"strategy={item['strategy']}") - if extras: - label += f" ({', '.join(extras)})" - return label - - -def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]: - """Return informational overlaps between *manifest* and installed bundles.""" - if manifest is None: - return [] - try: - from ...bundler.services.conflict import detect_conflicts - - report = detect_conflicts( - manifest, - active_integration(project_root), - load_records(project_root), - ) - return list(report.overlaps) - except BundlerError: - return [] - - -def _local_manifest_source(arg: str): - """Return a :class:`BundleManifest` if *arg* points at a local bundle. - - Supports a built ``.zip`` artifact, a bundle directory, or a ``bundle.yml`` - file. Returns ``None`` when *arg* is not an existing path, so callers fall - back to catalog-stack resolution by bundle id. - """ - from ...bundler.models.manifest import BundleManifest - - candidate = Path(arg).expanduser() - if not candidate.exists(): - return None - - if candidate.is_dir(): - manifest_path = candidate / "bundle.yml" - if not manifest_path.exists(): - raise BundlerError(f"No bundle.yml found in '{candidate}'.") - return BundleManifest.from_file(manifest_path) - - if candidate.suffix == ".zip": - import yaml as _yaml - - from ..._download_security import open_zip_bounded, read_zip_member_limited - - with open_zip_bounded(candidate, error_type=BundlerError) as archive: - try: - archive.getinfo("bundle.yml") - except KeyError as exc: - raise BundlerError( - f"Artifact '{candidate}' does not contain a bundle.yml." - ) from exc - raw = read_zip_member_limited( - archive, - "bundle.yml", - error_type=BundlerError, - label="bundle manifest", - ) - # The bounded-zip helpers above keep archive failures inside the - # BundlerError contract, but the manifest bytes need the same - # treatment as yamlio.load_yaml: decode as UTF-8 explicitly — - # feeding PyYAML the byte stream would let its Reader auto-detect - # a UTF-16 BOM and accept a manifest the directory and bundle.yml - # sources reject. - try: - text = raw.decode("utf-8") - except UnicodeError as exc: - raise BundlerError( - f"Could not read bundle.yml inside '{candidate}': {exc}" - ) from exc - try: - data = _yaml.safe_load(text) - except _yaml.YAMLError as exc: - # The sibling directory/bundle.yml branches reach YAML through - # load_yaml(), which turns a parse failure into a BundlerError. This - # branch parses inline, so without this it raises a raw YAMLError -- - # neither a ValueError nor an OSError -- which escapes - # bundle_install()'s `except BundlerError` as a traceback. - raise BundlerError( - f"Invalid YAML in bundle.yml inside '{candidate}': {exc}" - ) from exc - return BundleManifest.from_dict(data) - - if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"): - return BundleManifest.from_file(candidate) - - raise BundlerError( - f"'{candidate}' is not a recognised bundle source (.zip artifact, bundle " - "directory, or bundle.yml)." - ) - - -def _resolve_manifest_path(path: Path | None) -> Path: - target = (path or Path.cwd()).resolve() - if target.is_dir(): - target = target / "bundle.yml" - if not target.exists(): - raise BundlerError(f"No bundle.yml found at '{target}'.") - return target - - -def _download_manifest(resolved, *, offline: bool): - """Resolve a bundle's manifest from its catalog ``download_url``. - - Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost), - matching the extensions/presets/workflows catalog systems. Remote URLs are - fetched with the shared authenticated, redirect-validated HTTP client, and - only when not ``--offline``. - - Local and ``file://`` sources are intentionally not resolved here: to - install a bundle from disk, pass the path positionally - (``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a - ``.zip`` artifact also works), which :func:`_local_manifest_source` handles - before catalog resolution and which never touches ``download_url``. - """ - from urllib.parse import urlparse - - url = resolved.entry.download_url - if not url: - raise BundlerError( - f"Catalog entry '{resolved.entry.id}' has no download_url; cannot resolve " - "its manifest." - ) - # A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``) - # makes urlparse raise ValueError. Surface it as the documented - # BundlerError, like the sibling ``_validate_remote_url``, rather than - # leaking a raw ValueError past the callers, which only catch BundlerError. - try: - parsed = urlparse(url) - except ValueError: - raise BundlerError( - f"Catalog entry '{resolved.entry.id}' has a malformed download_url: {url}" - ) from None - scheme = parsed.scheme.lower() - - # ``file://`` URLs and bare filesystem paths (including Windows drive paths - # like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme) - # are not valid catalog download URLs. Catalog URLs are HTTPS-only across - # every catalog system; installing from disk is done by passing the path - # positionally, which never reaches URL resolution. Give an actionable - # error rather than accepting a scheme the rest of the codebase rejects. - if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url): - raise BundlerError( - f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url " - f"({url}); catalog download URLs must be HTTPS (http for localhost) — " - "a file:// URL, a local filesystem path, or a scheme-less value " - "(e.g. 'example.com/bundle.zip') is not accepted. " - "To install a bundle from disk, pass the path directly: " - "'specify bundle install '." - ) - - # Validate the scheme/host *before* the offline gate so an invalid or - # non-HTTPS download_url reports the real problem in every mode, rather - # than a misleading "Network access disabled" under --offline. - # (_download_remote_manifest re-checks this, but only once network access - # is permitted.) HTTPS-only, http allowed for localhost. - _require_https(f"bundle '{resolved.entry.id}'", url) - - if offline: - raise BundlerError( - f"Network access disabled; cannot download bundle '{resolved.entry.id}' " - f"from {url}." - ) - manifest = _download_remote_manifest( - resolved.entry.id, - url, - expected_sha256=getattr(resolved.entry, "sha256", None), - ) - _validate_catalog_manifest(resolved.entry, manifest) - return manifest - - -def _require_https(label: str, url: str) -> None: - from urllib.parse import urlparse - - # urlparse / hostname access raise ValueError on a malformed authority; - # keep the documented BundlerError contract (older Pythons surface this via - # the .hostname access below rather than at the urlparse call). - try: - parsed = urlparse(url) - hostname = parsed.hostname - # Accessing ``port`` performs urllib's syntax/range validation. - _ = parsed.port - except ValueError: - raise BundlerError( - f"Refusing to download {label}: URL is malformed: {url}" - ) from None - is_localhost = hostname in ("localhost", "127.0.0.1", "::1") - if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): - raise BundlerError( - f"Refusing to download {label} over non-HTTPS URL: {url}" - ) - if not parsed.hostname: - raise BundlerError(f"Refusing to download {label} from URL with no host: {url}") - - -def _download_remote_manifest( - entry_id: str, - url: str, - *, - expected_sha256: str | None = None, -): - """Fetch a remote bundle artifact over HTTPS and extract its manifest.""" - import tempfile - from pathlib import PurePosixPath - from urllib.parse import urlparse as _urlparse - - import yaml as _yaml - - from ...authentication.http import github_provider_hosts, open_url - from ..._github_http import resolve_github_release_asset_api_url - from ...bundler.models.manifest import BundleManifest - from ...shared_infra import verify_archive_sha256 - - def _validate_redirect(old_url: str, new_url: str) -> None: - _require_https(f"bundle '{entry_id}'", new_url) - - _require_https(f"bundle '{entry_id}'", url) - - # For private/SSO-protected GitHub repos, browser release download URLs - # (https://github.com///releases/download//) - # redirect to an HTML/SSO page instead of delivering the asset. Resolve - # such URLs to the GitHub REST API asset URL so the authenticated client - # can download the actual file. - extra_headers = None - effective_url = url - resolved = resolve_github_release_asset_api_url( - url, open_url, timeout=30, github_hosts=github_provider_hosts() - ) - if resolved: - effective_url = resolved - _require_https(f"bundle '{entry_id}'", effective_url) - extra_headers = {"Accept": "application/octet-stream"} - - # Human-readable description of where the bytes came from, reused across - # all post-download error messages so failures point at the catalog URL - # (and resolved API URL, if any) instead of an opaque temp path. - if effective_url != url: - _source_desc = f"{url} (resolved to {effective_url})" - else: - _source_desc = url - - try: - with open_url( - effective_url, - timeout=30, - redirect_validator=_validate_redirect, - extra_headers=extra_headers, - ) as resp: - _require_https(f"bundle '{entry_id}'", resp.geturl()) - raw = read_response_limited( - resp, - max_bytes=MAX_DOWNLOAD_BYTES, - error_type=BundlerError, - label=f"bundle '{entry_id}' download", - ) - verify_archive_sha256( - raw, - expected_sha256, - entry_id, - BundlerError, - ) - except BundlerError: - raise - except Exception as exc: # noqa: BLE001 - # Report the original catalog URL so users know which entry to fix, - # and include the resolved URL when it differs for easier debugging. - raise BundlerError( - f"Failed to download bundle '{entry_id}' from {_source_desc}: {exc}" - ) from exc - - # A .zip artifact is written to a temp file and parsed via the local-source - # path (which extracts bundle.yml); any other payload is treated as YAML. - # Detection uses the path component of the original catalog URL (via - # PurePosixPath so query strings and fragments are ignored, and URL paths - # are always treated as POSIX regardless of host OS), falling back to the - # module-level _ZIP_SIGNATURES magic-byte check for direct REST API asset - # URLs which carry no file extension. - _url_ext = PurePosixPath(_urlparse(url).path).suffix.lower() - try: - if _url_ext == ".zip" or raw[:4] in _ZIP_SIGNATURES: - with tempfile.TemporaryDirectory() as tmp: - artifact = Path(tmp) / "bundle.zip" - artifact.write_bytes(raw) - # Wrap ZIP parsing so any failure (BadZipFile, missing - # bundle.yml, etc.) references the source URL rather than the - # opaque temporary path, consistent with the download-error - # handling above. - try: - manifest = _local_manifest_source(str(artifact)) - except Exception as exc: # noqa: BLE001 - raise BundlerError( - f"Downloaded artifact for bundle '{entry_id}' from " - f"{_source_desc} is not a valid bundle: {exc}" - ) from exc - # _local_manifest_source returns None only when the file does - # not exist; since we just wrote *artifact* that cannot happen - # here. The explicit guard ensures callers never receive None - # and silently degrade instead of raising a clear error. - if manifest is None: - raise BundlerError( - f"Downloaded artifact for bundle '{entry_id}' from " - f"{_source_desc} is not a valid bundle." - ) - return manifest - - # Decode as UTF-8 explicitly -- matching yamlio.load_yaml's contract -- - # instead of feeding PyYAML the raw byte stream. PyYAML's Reader - # auto-detects a UTF-16 BOM and would silently *accept* a manifest - # that the local directory/bundle.yml sources reject, letting this - # remote-download path diverge from them (see the sibling .zip fix - # for _local_manifest_source, which had the identical bug). - try: - text = raw.decode("utf-8") - except UnicodeError as exc: - raise BundlerError( - f"Downloaded content for bundle '{entry_id}' from " - f"{_source_desc} could not be read: {exc}" - ) from exc - data = _yaml.safe_load(text) - return BundleManifest.from_dict(data) - except BundlerError: - raise - except _yaml.YAMLError as exc: - raise BundlerError( - f"Downloaded content for bundle '{entry_id}' from {_source_desc} " - f"is not valid YAML: {exc}" - ) from exc - except Exception as exc: # noqa: BLE001 - raise BundlerError( - f"Failed to parse downloaded bundle '{entry_id}' from " - f"{_source_desc}: {exc}" - ) from exc - - -def _validate_manifest_structure(manifest, *, source: str) -> None: - """Reject a malformed manifest before any project mutation can occur.""" - from ...bundler.services.validator import validate_manifest - - report = validate_manifest(manifest) - if report.ok: - return - raise BundlerError( - f"{source} contains an invalid bundle manifest:\n - " - + "\n - ".join(report.errors) - ) - - -def _validate_catalog_manifest(entry, manifest) -> None: - """Bind a downloaded manifest to the catalog identity that selected it.""" - if manifest.bundle.id != entry.id: - raise BundlerError( - f"Downloaded bundle id mismatch: catalog entry {entry.id!r} points to " - f"a manifest for {manifest.bundle.id!r}." - ) - if manifest.bundle.version != entry.version: - raise BundlerError( - f"Downloaded bundle version mismatch for {entry.id!r}: catalog declares " - f"{entry.version!r}, but the manifest declares " - f"{manifest.bundle.version!r}." - ) - _validate_manifest_structure( - manifest, - source=f"Downloaded bundle {entry.id!r}", - ) - - -def register(app: typer.Typer) -> None: - """Attach the bundle command group to the root Typer app.""" - app.add_typer(bundle_app, name="bundle") diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py deleted file mode 100644 index 4cf8d35150..0000000000 --- a/tests/contract/test_bundle_cli.py +++ /dev/null @@ -1,1231 +0,0 @@ -"""Contract test for the `specify bundle` CLI surface (Typer integration). - -Exercises the wired commands end-to-end via CliRunner against a temp project, -asserting exit codes and the cross-cutting error guarantees from -contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error). -""" -from __future__ import annotations - -import io -import json -from pathlib import Path -from unittest.mock import patch - -import pytest -import yaml -from typer.testing import CliRunner - -from specify_cli import app -from specify_cli.bundler.services.adapters import FIRSTPARTY_CATALOG_URL -from specify_cli.bundler.services.packager import build_bundle -from tests.conftest import strip_ansi -from tests.bundler_helpers import ( - catalog_entry_dict, - valid_manifest_dict, - write_catalog_file, -) - -runner = CliRunner() -REPO_ROOT = Path(__file__).parents[2] - -MARKUP_BUNDLE_ID = "[red]markup-id[/red]" -MARKUP_SOURCE_ID = "[underline]markup-source[/underline]" - - -def _configure_markup_catalog(project: Path, **overrides: object) -> dict: - entry = catalog_entry_dict( - MARKUP_BUNDLE_ID, - name="[green]Markup Name[/green]", - version="[blue]1.0.0[/blue]", - role="[magenta]Markup Role[/magenta]", - description="[yellow]Markup Description[/yellow]", - author="[cyan]Markup Author[/cyan]", - license="[bold]Markup License[/bold]", - download_url="https://example.com/markup-bundle.zip", - requires={"speckit_version": "[italic]>=0.1.0[/italic]"}, - **overrides, - ) - catalog = project / "markup-catalog.json" - write_catalog_file(catalog, {MARKUP_BUNDLE_ID: entry}) - config = { - "schema_version": "1.0", - "catalogs": [ - { - "id": MARKUP_SOURCE_ID, - "url": str(catalog), - "priority": 1, - "install_policy": "install-allowed", - } - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), - encoding="utf-8", - ) - return entry - - -@pytest.fixture() -def project(tmp_path: Path, monkeypatch) -> Path: - (tmp_path / ".specify").mkdir() - monkeypatch.chdir(tmp_path) - return tmp_path - - -def test_bundle_help_lists_all_commands(): - result = runner.invoke(app, ["bundle", "--help"]) - assert result.exit_code == 0 - for cmd in ("search", "info", "list", "install", "add", "update", "remove", - "validate", "build", "init", "catalog"): - assert cmd in result.output - - -def test_update_accepts_integration_override(): - # Update must expose --integration so integration-pinned bundles can be - # updated in projects where the active integration can't be auto-detected. - # Rich may insert ANSI escapes between the two leading dashes, so match the - # un-split option word rather than the literal "--integration". - result = runner.invoke(app, ["bundle", "update", "--help"]) - assert result.exit_code == 0 - assert "integration" in result.output - - -def test_add_forwards_refresh_default_without_refreshing(project: Path): - from specify_cli.commands import bundle as bundle_commands - - with patch.object(bundle_commands, "bundle_install") as install: - result = runner.invoke(app, ["bundle", "add", "demo"]) - - assert result.exit_code == 0, result.output - install.assert_called_once_with( - bundle_id="demo", - integration=None, - offline=False, - refresh=False, - ) - - -def test_list_empty_project(project: Path): - result = runner.invoke(app, ["bundle", "list"]) - assert result.exit_code == 0 - assert "No bundles installed" in result.output - - -def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch): - monkeypatch.chdir(tmp_path) # no .specify/ - result = runner.invoke(app, ["bundle", "list"]) - assert result.exit_code == 1 - assert "Spec Kit project" in result.output - - -def test_remove_reports_clean_error_when_primitive_raises_raw_exception( - project: Path, -): - """A raw exception from a primitive installer (e.g. an OSError from an - unreadable workflow registry surfacing through _WorkflowKindManager's - fail-closed construction) must not propagate uncaught through - `specify bundle remove` -- the command only catches BundlerError, so - without a conversion at the remove_bundle boundary this would exit - with an unhandled exception and empty/raw output instead of a clean, - actionable message, and no removal side effects should occur either.""" - from specify_cli.bundler.models.manifest import BundleManifest - from specify_cli.bundler.models.records import load_records - from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller - from specify_cli.bundler.services.installer import install_bundle - from specify_cli.bundler.services.resolver import resolve_install_plan - from tests.bundler_helpers import FakeInstaller - - manifest = BundleManifest.from_dict(valid_manifest_dict()) - plan = resolve_install_plan( - manifest, speckit_version="0.11.2", active_integration="copilot" - ) - install_bundle(project, plan, FakeInstaller(), manifest=manifest) - - def boom(self, project_root, component): - raise OSError("workflow registry unreadable") - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom) - result = runner.invoke(app, ["bundle", "remove", "demo-bundle"]) - - assert result.exit_code != 0 - assert result.output.strip() != "" - assert result.exception is None or isinstance(result.exception, SystemExit) - assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"} - - -def test_fail_writes_error_to_stderr_not_stdout(capsys): - """_fail must write to stderr, not stdout: every bundle command routes errors - through it, and under --json the error would otherwise corrupt the JSON payload - that consumers read from stdout.""" - import typer - - from specify_cli.commands.bundle import _fail - - with pytest.raises(typer.Exit): - _fail("something broke") - captured = capsys.readouterr() - assert "something broke" in captured.err - assert "something broke" not in captured.out - - -def test_search_works_without_a_project(tmp_path: Path, monkeypatch): - # Discovery commands fall back to the built-in/user catalog stack and must - # not require a Spec Kit project (matches README/quickstart examples). - monkeypatch.chdir(tmp_path) # no .specify/ - result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) - assert result.exit_code == 0, result.output - assert result.output.strip().startswith("[") - - -def test_search_escapes_catalog_markup(project: Path): - entry = _configure_markup_catalog(project) - - result = runner.invoke(app, ["bundle", "search", "--offline"]) - - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for value in ( - entry["id"], - entry["name"], - entry["version"], - entry["role"], - entry["description"], - MARKUP_SOURCE_ID, - ): - assert value in output - - -def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch): - monkeypatch.chdir(tmp_path) # no .specify/ - result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"]) - # Reaches catalog resolution (not the project gate) and reports a clean miss. - assert result.exit_code == 1 - assert "Spec Kit project" not in result.output - - -def test_catalog_list_shows_builtin_defaults(project: Path): - result = runner.invoke(app, ["bundle", "catalog", "list"]) - assert result.exit_code == 0 - assert "default" in result.output - assert "community" in result.output - assert "built-in default stack" in result.output - - -def test_catalog_add_and_remove(project: Path): - catalog = project / "local-catalog.json" - write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) - - added = runner.invoke( - app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] - ) - assert added.exit_code == 0, added.output - - listed = runner.invoke(app, ["bundle", "catalog", "list"]) - assert "local" in listed.output - - removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"]) - assert removed.exit_code == 0 - - -def test_catalog_remove_builtin_is_refused(project: Path): - result = runner.invoke(app, ["bundle", "catalog", "remove", "default"]) - assert result.exit_code == 1 - assert "built-in" in result.output - - -# Every ``bundle`` error path funnels through ``_fail(str(exc))``, and the -# BundlerError messages interpolate untrusted data -- including the command's -# own argument. An unbalanced closer used to raise MarkupError instead of the -# error, leaving the user with a traceback and no message at all. -@pytest.mark.parametrize( - "argv, expected", - [ - ( - ["bundle", "catalog", "add", "ssh://ex[/red]ample.com/c.json"], - "ssh://ex[/red]ample.com/c.json", - ), - (["bundle", "catalog", "remove", "no[/red]such"], "no[/red]such"), - (["bundle", "update", "no[/red]such"], "no[/red]such"), - (["bundle", "remove", "no[/red]such"], "no[/red]such"), - ], -) -def test_error_paths_escape_rich_markup(project: Path, argv: list, expected: str): - result = runner.invoke(app, argv) - - assert result.exit_code == 1 - # A MarkupError would surface here as an exception rather than a clean exit. - assert isinstance(result.exception, SystemExit) - assert expected in strip_ansi(result.output) - - -def test_validate_reports_invalid_manifest(project: Path): - data = valid_manifest_dict() - del data["bundle"]["license"] - (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - result = runner.invoke(app, ["bundle", "validate"]) - assert result.exit_code == 1 - assert "license" in result.output - - -def test_validate_accepts_valid_manifest(project: Path): - (project / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - # Offline mode does not fail on references it cannot verify (synthetic ids - # here); they surface as warnings while structure is confirmed valid. - result = runner.invoke(app, ["bundle", "validate", "--offline"]) - assert result.exit_code == 0, result.output - assert "valid" in result.output - - -def test_validate_escapes_manifest_markup_in_errors(project: Path): - data = valid_manifest_dict() - # An invalid constraint is echoed back inside the validation error. - data["requires"] = {"speckit_version": ">=1.0[/bold]"} - (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - - result = runner.invoke(app, ["bundle", "validate", "--offline"]) - - assert result.exit_code == 1 - assert isinstance(result.exception, SystemExit) - assert ">=1.0[/bold]" in strip_ansi(result.output) - - -def test_validate_escapes_manifest_markup_in_warnings(project: Path): - data = valid_manifest_dict() - # Step ids are not charset-validated, and the unresolved-reference warning - # echoes them -- so an otherwise *valid* manifest crashed just as readily as - # an invalid one, on the success path. - data["provides"]["steps"] = [{"id": "step[/bold]a"}] - (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - - result = runner.invoke(app, ["bundle", "validate", "--offline"]) - - assert result.exit_code == 0, repr(result.exception) - assert "step[/bold]a" in strip_ansi(result.output) - - -def test_validate_rejects_broken_reference(project: Path): - # Synthetic component ids resolve to nothing in any catalog → hard failure. - (project / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - result = runner.invoke(app, ["bundle", "validate"]) - assert result.exit_code == 1 - assert "preset-a" in result.output or "ext-a" in result.output - - -def test_validate_accepts_bundled_reference(project: Path): - data = valid_manifest_dict() - data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]} - (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - result = runner.invoke(app, ["bundle", "validate"]) - assert result.exit_code == 0, result.output - assert "valid" in result.output - - -def test_build_produces_artifact(project: Path): - (project / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - (project / "README.md").write_text("# Demo", encoding="utf-8") - result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")]) - assert result.exit_code == 0, result.output - artifacts = list((project / "dist").glob("*.zip")) - assert len(artifacts) == 1 - - -def test_build_escapes_markup_in_output_path(project: Path): - """The build success line echoes a caller-supplied ``--output`` path. - - Brackets are legal in a directory name on both POSIX and Windows, so the - artifact is built and *then* misreported: ``[bold]`` is consumed as a style - tag, and the success line names a path that does not exist on disk. - - A closing tag (``[/red]``) would raise MarkupError outright, but ``/`` is a - path separator on Windows, so this uses the silent-swallow form to keep the - fixture portable. - """ - (project / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - (project / "README.md").write_text("# Demo", encoding="utf-8") - out_dir = project / "dist[bold]out" - - result = runner.invoke(app, ["bundle", "build", "--output", str(out_dir)]) - - assert result.exit_code == 0, repr(result.exception) - assert list(out_dir.glob("*.zip")), "the artifact should still be built" - # Join across Rich's wrap points: the success line prints an absolute path, - # so the console folds it mid-token whenever the temp directory is long - # enough, which is a property of the runner's path, not of the escaping. - assert "dist[bold]out" in "".join(strip_ansi(result.output).split()), ( - "the reported path must match the directory actually written" - ) - - -def test_list_escapes_markup_in_records(project: Path): - """``bundle list`` renders record fields that are never charset-validated. - - ``InstalledBundleRecord.from_dict`` accepts any non-empty string for - ``bundle_id``/``version`` and any string for ``installed_at``, so a records - file that *loads cleanly* could still crash the command that displays it. - """ - (project / ".specify" / "bundle-records.json").write_text( - json.dumps( - { - "schema_version": "1.0", - "bundles": [ - { - "bundle_id": "demo[/red]id", - "version": "1.0.0[/bold]", - "installed_at": "2026-01-01T00:00:00Z[/dim]", - "contributed_components": [], - } - ], - } - ), - encoding="utf-8", - ) - - result = runner.invoke(app, ["bundle", "list"]) - - assert result.exit_code == 0, repr(result.exception) - output = strip_ansi(result.output) - assert "demo[/red]id" in output - assert "1.0.0[/bold]" in output - assert "2026-01-01T00:00:00Z[/dim]" in output - - -def _mock_manifest_download(monkeypatch, source_path: Path) -> None: - """Mock the HTTPS manifest fetch to return a locally-authored manifest. - - Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer - point one at a local file. Patch ``_download_manifest`` to return the - manifest parsed from *source_path* (a bundle.yml or a .zip artifact), - exercising ``info``'s expansion without a network call. - """ - from specify_cli.commands.bundle import _local_manifest_source - - monkeypatch.setattr( - "specify_cli.commands.bundle._download_manifest", - lambda resolved, *, offline: _local_manifest_source(str(source_path)), - ) - - -def _bundled_workflow_manifest(workflow_id: str, version: str = "1.0.0") -> dict: - return valid_manifest_dict( - provides={"workflows": [{"id": workflow_id, "version": version}]} - ) - - -@pytest.mark.parametrize( - ("command", "bundle_id", "extension_id"), - [("install", "bugfix", "bug"), ("add", "assess", "assess")], -) -def test_local_firstparty_bundle_installs_bundled_components_offline( - project: Path, command: str, bundle_id: str, extension_id: str -): - bundle_dir = REPO_ROOT / "bundles" / bundle_id - - result = runner.invoke( - app, ["bundle", command, str(bundle_dir), "--offline"] - ) - - assert result.exit_code == 0, result.output - assert ( - project / ".specify" / "extensions" / extension_id / "extension.yml" - ).is_file() - assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() - registry = json.loads( - (project / ".specify" / "workflows" / "workflow-registry.json").read_text( - encoding="utf-8" - ) - ) - assert registry["workflows"][bundle_id]["version"] == "1.0.0" - - -@pytest.mark.parametrize( - ("bundle_id", "extension_id"), - [("bugfix", "bug"), ("assess", "assess")], -) -def test_bundle_add_by_id_initializes_empty_project_from_firstparty_catalog( - tmp_path: Path, monkeypatch, bundle_id: str, extension_id: str -): - """``bundle add `` from an empty directory resolves ``builtin://default``. - - The command fetches the first-party catalog and bundle manifest over the - network (both mocked here), initializes a new Spec Kit project, and installs - the bundled extension and workflow without further network access. - """ - project = tmp_path / "fresh" - project.mkdir() - monkeypatch.chdir(project) - - catalog_bytes = (REPO_ROOT / "bundles" / "catalog.json").read_bytes() - manifest_bytes = (REPO_ROOT / "bundles" / bundle_id / "bundle.yml").read_bytes() - expected_manifest_url = ( - "https://raw.githubusercontent.com/github/spec-kit/main/" - f"bundles/{bundle_id}/bundle.yml" - ) - captured_urls: list[str] = [] - - def fake_open_url( - url: str, - timeout: int | None = None, - extra_headers: dict[str, str] | None = None, - redirect_validator=None, - ): - captured_urls.append(url) - if url == FIRSTPARTY_CATALOG_URL: - return FakeBundleResponse(catalog_bytes, url=url) - if url == expected_manifest_url: - return FakeBundleResponse(manifest_bytes, url=url) - raise AssertionError( - f"Unexpected network request in by-ID bundle test: {url}" - ) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke( - app, ["bundle", "add", bundle_id, "--integration", "copilot"] - ) - - assert result.exit_code == 0, result.output - assert "No Spec Kit project here" in result.output - assert (project / ".specify").is_dir() - assert ( - project / ".specify" / "extensions" / extension_id / "extension.yml" - ).is_file() - assert ( - project / ".specify" / "workflows" / bundle_id / "workflow.yml" - ).is_file() - registry = json.loads( - (project / ".specify" / "workflows" / "workflow-registry.json").read_text( - encoding="utf-8" - ) - ) - assert registry["workflows"][bundle_id]["version"] == "1.0.0" - assert FIRSTPARTY_CATALOG_URL in captured_urls - assert expected_manifest_url in captured_urls - - -def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): - bundle_dir = project / "mismatched-workflow-pin" - (bundle_dir / "bundle.yml").parent.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(_bundled_workflow_manifest("bugfix", "9.9.9")), encoding="utf-8" - ) - - result = runner.invoke( - app, ["bundle", "install", str(bundle_dir), "--offline"] - ) - - assert result.exit_code == 1 - assert "pinned to version 9.9.9" in result.output - assert not (project / ".specify" / "workflows" / "bugfix").exists() - - -def test_local_bundle_refuses_unbundled_workflow_offline(project: Path): - bundle_dir = project / "unbundled-workflow" - (bundle_dir / "bundle.yml").parent.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(_bundled_workflow_manifest("not-bundled")), encoding="utf-8" - ) - - result = runner.invoke( - app, ["bundle", "install", str(bundle_dir), "--offline"] - ) - - assert result.exit_code == 1 - assert "network access is disabled" in " ".join(result.output.lower().split()) - - -def test_info_expands_full_component_set(project: Path, monkeypatch): - bundle_dir = project / "src-bundle" - bundle_dir.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - catalog = project / "local-catalog.json" - entry = catalog_entry_dict( - "demo-bundle", download_url="https://example.com/demo-bundle.zip" - ) - write_catalog_file(catalog, {"demo-bundle": entry}) - added = runner.invoke( - app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] - ) - assert added.exit_code == 0, added.output - _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") - - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - components = {(c["kind"], c["id"]): c for c in payload["components"]} - assert ("extensions", "ext-a") in components - preset = components[("presets", "preset-a")] - assert preset["version"] == "2.0.0" - assert preset["priority"] == 10 - assert preset["strategy"] == "append" - assert payload["trust"] == "verified" - - text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) - assert "preset-a v2.0.0" in text.output - assert "Trust" in text.output - - -def test_info_escapes_catalog_markup(project: Path, monkeypatch): - entry = _configure_markup_catalog(project) - bundle_dir = project / "markup-bundle" - bundle_dir.mkdir() - manifest_data = valid_manifest_dict() - manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID - manifest_data["integration"] = { - "id": "[conceal]markup-integration[/conceal]" - } - manifest_path = bundle_dir / "bundle.yml" - manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8") - _mock_manifest_download(monkeypatch, manifest_path) - monkeypatch.setattr( - "specify_cli.commands.bundle._manifest_component_view", - lambda manifest: [ - { - "kind": "extensions", - "id": "[reverse]markup-component[/reverse]", - "version": "[strike]2.0.0[/strike]", - } - ], - ) - monkeypatch.setattr( - "specify_cli.commands.bundle._bundle_overlaps", - lambda project_root, manifest, *, offline: [ - "[blink]markup-overlap[/blink]" - ], - ) - - result = runner.invoke( - app, - ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"], - ) - - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for value in ( - entry["id"], - entry["name"], - entry["version"], - entry["role"], - entry["description"], - entry["author"], - entry["license"], - entry["requires"]["speckit_version"], - MARKUP_SOURCE_ID, - "[conceal]markup-integration[/conceal]", - "[reverse]markup-component[/reverse]", - "[strike]2.0.0[/strike]", - "[blink]markup-overlap[/blink]", - ): - assert value in output - - -def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch): - markup_count = "[bold]markup-count[/bold]" - _configure_markup_catalog( - project, - provides={"extensions": markup_count}, - ) - bundle_dir = project / "markup-bundle" - bundle_dir.mkdir() - manifest_data = valid_manifest_dict(provides={}) - manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID - manifest_path = bundle_dir / "bundle.yml" - manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8") - _mock_manifest_download(monkeypatch, manifest_path) - - result = runner.invoke( - app, - ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"], - ) - - assert result.exit_code == 0, result.output - assert markup_count in strip_ansi(result.output) - - -def test_info_expands_discovery_only_bundle(project: Path, monkeypatch): - # Discovery-only bundles must still be fully inspectable via `info`; - # only `install` is refused for them. - bundle_dir = project / "disc-bundle" - bundle_dir.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - catalog = project / "disc-catalog.json" - entry = catalog_entry_dict( - "demo-bundle", download_url="https://example.com/demo-bundle.zip" - ) - write_catalog_file(catalog, {"demo-bundle": entry}) - config = { - "schema_version": "1.0", - "catalogs": [ - {"id": "disc", "url": str(catalog), "priority": 1, - "install_policy": "discovery-only"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - components = {(c["kind"], c["id"]) for c in payload["components"]} - assert ("extensions", "ext-a") in components - - -def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch): - # A .zip artifact is extracted to read bundle.yml; info expands it. (The - # download itself is HTTPS-only now and mocked here — see contract note.) - bundle_dir = project / "zip-src" - bundle_dir.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - (bundle_dir / "README.md").write_text("# Demo", encoding="utf-8") - artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path - catalog = project / "zip-catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict( - "demo-bundle", download_url="https://example.com/demo-bundle.zip" - )}, - ) - added = runner.invoke( - app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] - ) - assert added.exit_code == 0, added.output - _mock_manifest_download(monkeypatch, artifact) - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - components = {(c["kind"], c["id"]) for c in payload["components"]} - assert ("extensions", "ext-a") in components - - -def test_install_refuses_discovery_only_source(project: Path, monkeypatch): - # Point a discovery-only catalog at a local payload containing the bundle. - catalog = project / "disc.json" - write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) - config = { - "schema_version": "1.0", - "catalogs": [ - {"id": "disc", "url": str(catalog), "priority": 1, - "install_policy": "discovery-only"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - result = runner.invoke(app, ["bundle", "install", "demo", "--offline"]) - assert result.exit_code == 1 - assert "discovery-only" in result.output - - -def test_update_refuses_discovery_only_source(project: Path): - # An installed bundle whose only resolvable source is discovery-only must - # not be updatable from there (FR-025), mirroring the install policy gate. - from specify_cli.bundler.models.manifest import ComponentRef - from specify_cli.bundler.models.records import ( - InstalledBundleRecord, - save_records, - ) - - save_records( - project, - [ - InstalledBundleRecord.create( - "demo", - "1.0.0", - [ComponentRef(kind="extensions", id="ext-a", version=None)], - ) - ], - ) - - catalog = project / "disc.json" - write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) - config = { - "schema_version": "1.0", - "catalogs": [ - {"id": "disc", "url": str(catalog), "priority": 1, - "install_policy": "discovery-only"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - - result = runner.invoke(app, ["bundle", "update", "demo", "--offline"]) - assert result.exit_code == 1 - assert "discovery-only" in result.output - - -def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path): - # `info` must expand the real component set; if the manifest can't be - # resolved (here: --offline against an https download_url), it should error - # and exit non-zero rather than silently degrading to `provides` counts. - catalog = project / "remote-catalog.json" - entry = catalog_entry_dict( - "demo-bundle", download_url="https://example.com/demo-bundle.zip" - ) - write_catalog_file(catalog, {"demo-bundle": entry}) - added = runner.invoke( - app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"] - ) - assert added.exit_code == 0, added.output - - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) - assert result.exit_code == 1 - assert "Network access disabled" in result.output - - -def test_search_json_offline(project: Path): - catalog = project / "c.json" - write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) - config = { - "schema_version": "1.0", - "catalogs": [ - # Priority 0 wins over the built-in first-party catalog so the demo - # entry is resolved from this project catalog, while the offline - # packaged first-party catalog (bugfix / assess) still appears in - # search results alongside it. - {"id": "c", "url": str(catalog), "priority": 0, - "install_policy": "install-allowed"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) - assert result.exit_code == 0 - payload = json.loads(result.output) - by_id = {entry["id"]: entry for entry in payload} - assert "demo" in by_id - # Trust indicator is exposed on the discovery surface (FR-010 / FR-027). - assert by_id["demo"]["verified"] is True - assert by_id["demo"]["trust"] == "verified" - - -def test_search_text_shows_trust(project: Path): - catalog = project / "c.json" - write_catalog_file( - catalog, - { - "verified-one": catalog_entry_dict("verified-one", verified=True), - "community-one": catalog_entry_dict("community-one", verified=False), - }, - ) - config = { - "schema_version": "1.0", - "catalogs": [ - {"id": "c", "url": str(catalog), "priority": 1, - "install_policy": "install-allowed"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - result = runner.invoke(app, ["bundle", "search", "--offline"]) - assert result.exit_code == 0, result.output - assert "verified" in result.output - assert "community" in result.output - - -def test_install_integration_override_cannot_bypass_clash_guard(project: Path): - # An initialized project's recorded active integration is authoritative: - # passing --integration must not let a differently-pinned bundle install. - import json - - (project / ".specify" / "integration.json").write_text( - json.dumps({"integration": "copilot"}), encoding="utf-8" - ) - bundle_dir = project / "claude-bundle" - bundle_dir.mkdir() - data = valid_manifest_dict(integration={"id": "claude"}) - (bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - (bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8") - - result = runner.invoke( - app, - ["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"], - ) - assert result.exit_code == 1 - assert "claude" in result.output and "copilot" in result.output - - -# ===== Private GitHub release asset URL resolution ===== - - -class FakeBundleResponse(io.BytesIO): - """Minimal context-manager response stub for open_url fakes.""" - - def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"): - super().__init__(data) - self._url = url - - def geturl(self) -> str: - return self._url - - -def _make_catalog_config(catalog_path: Path, project: Path) -> None: - """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*. - - Uses priority 0 so the test catalog wins over the built-in first-party - ``builtin://default`` catalog and the command under test does not need to - fetch the repository catalog from the network. - """ - config = { - "schema_version": "1.0", - "catalogs": [ - { - "id": "test", - "url": str(catalog_path), - "priority": 0, - "install_policy": "install-allowed", - } - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - - -def test_bundle_info_resolves_github_browser_release_url(project: Path): - """bundle info resolves a private-repo browser release URL via the GitHub API.""" - browser_url = "https://github.com/org/repo/releases/download/v1.0/bundle.yml" - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" - - captured = [] - manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - if "releases/tags/" in url: - # GitHub API release-tags lookup — return asset list - return FakeBundleResponse( - json.dumps({ - "assets": [{"name": "bundle.yml", "url": api_asset_url}] - }).encode(), - url=url, - ) - # Actual asset download - return FakeBundleResponse(manifest_yaml, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # The browser release URL must have been resolved via the GitHub tags API - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 1, f"Expected exactly one tags API call; got {captured}" - assert "releases/tags/v1.0" in tag_calls[0] - - # The actual download must use the resolved API asset URL with octet-stream - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][0] == api_asset_url - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - -def test_bundle_info_rejects_utf16_remote_manifest_like_local_sources(project: Path): - """A downloaded (non-zip) bundle.yml must be decoded strictly as UTF-8. - - ``yamlio.load_yaml`` decodes local ``bundle.yml`` sources strictly as - UTF-8, so a well-formed UTF-16 manifest (a realistic PowerShell - ``Out-File`` output) is rejected. Feeding the downloaded bytes straight - to ``yaml.safe_load(io.BytesIO(raw))`` let PyYAML's Reader honour the - UTF-16 BOM and silently *accept* the same manifest instead, diverging - from local/zip sources (the zip branch of this same download path was - already fixed for the identical bug). - """ - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" - manifest_yaml_utf16 = yaml.safe_dump(valid_manifest_dict()).encode("utf-16") - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - return FakeBundleResponse(manifest_yaml_utf16, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 1 - output_flat = " ".join(result.output.split()) - assert "could not be read" in output_flat.lower() - - -def test_bundle_info_passes_through_api_asset_url(project: Path): - """bundle info passes a direct GitHub API asset URL through with octet-stream.""" - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77" - - captured = [] - manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - return FakeBundleResponse(manifest_yaml, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # No tags API call — URL was already a REST asset URL - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 0 - - # Exactly one download call to the asset URL with octet-stream - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][0] == api_asset_url - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - -def test_bundle_info_resolves_github_browser_release_url_zip(project: Path): - """bundle info resolves a browser release URL for a .zip artifact and extracts bundle.yml.""" - import io - import zipfile - - browser_url = "https://github.com/org/repo/releases/download/v2.0/bundle.zip" - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/88" - - # Build a minimal in-memory ZIP containing bundle.yml - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) - zip_bytes = buf.getvalue() - - captured = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - if "releases/tags/" in url: - return FakeBundleResponse( - json.dumps({ - "assets": [{"name": "bundle.zip", "url": api_asset_url}] - }).encode(), - url=url, - ) - return FakeBundleResponse(zip_bytes, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # tags API lookup must have fired - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 1 - assert "releases/tags/v2.0" in tag_calls[0] - - # Asset download uses the resolved API URL with octet-stream - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][0] == api_asset_url - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - # Manifest was successfully parsed from the ZIP - payload = json.loads(result.output) - assert payload["id"] == "demo-bundle" - - -def test_bundle_info_api_asset_url_zip_detected_by_magic_bytes(project: Path): - """bundle info correctly handles a direct API asset URL that serves ZIP bytes.""" - import io - import zipfile - - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/55" - - # Build a minimal in-memory ZIP containing bundle.yml - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) - zip_bytes = buf.getvalue() - - captured = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - return FakeBundleResponse(zip_bytes, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # No tags API call — URL was already a REST asset URL - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 0 - - # Download used octet-stream header - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - # ZIP bytes were detected by magic and bundle.yml extracted correctly - payload = json.loads(result.output) - assert payload["id"] == "demo-bundle" - - -def test_bundle_info_github_release_url_resolution_failure_falls_back_and_errors(project: Path): - """When the GitHub tags API lookup finds no matching asset, fall back to the - original browser URL and surface a meaningful error (not a raw traceback).""" - browser_url = "https://github.com/org/repo/releases/download/v3.0/bundle.yml" - - captured = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - if "releases/tags/" in url: - # Tags API responds but the asset list doesn't include our file - return FakeBundleResponse( - json.dumps({"assets": []}).encode(), - url=url, - ) - # Fallback download: GitHub serves HTML (SSO redirect) instead of YAML - return FakeBundleResponse(b"SSO login required", url=url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - # Must exit non-zero — the HTML body is not a valid bundle manifest - assert result.exit_code == 1 - - # The tags API lookup must have fired - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 1 - - # The fallback download should use the original browser URL (no octet-stream) - fallback_calls = [(url, h) for url, h in captured if url == browser_url] - assert len(fallback_calls) == 1 - assert fallback_calls[0][1] is None # no Accept header on the original URL - - # Error output must be actionable (not a raw traceback) - assert "Error:" in result.output - - -def test_bundle_info_resolves_ghes_browser_release_url(project: Path): - """bundle info resolves a GHES private-repo browser release URL via /api/v3.""" - ghes_host = "ghes.example" - browser_url = f"https://{ghes_host}/org/repo/releases/download/v1.0/bundle.yml" - api_asset_url = f"https://{ghes_host}/api/v3/repos/org/repo/releases/assets/42" - - captured = [] - manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - if "/api/v3/repos/" in url and "releases/tags/" in url: - return FakeBundleResponse( - json.dumps({ - "assets": [{"name": "bundle.yml", "url": api_asset_url}] - }).encode(), - url=url, - ) - return FakeBundleResponse(manifest_yaml, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ - patch("specify_cli.authentication.http.github_provider_hosts", return_value=(ghes_host,)): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # The GHES /api/v3 tags lookup must have fired - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 1 - assert f"{ghes_host}/api/v3/repos/org/repo/releases/tags/v1.0" in tag_calls[0] - - # Asset download must use the resolved GHES API URL with octet-stream - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][0] == api_asset_url - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - payload = json.loads(result.output) - assert payload["id"] == "demo-bundle" - - -def test_bundle_download_rejects_oversized_response(project: Path, monkeypatch): - """Bundle download rejects responses exceeding MAX_DOWNLOAD_BYTES.""" - # Monkeypatch to a small limit so the test is fast and low-memory. - monkeypatch.setattr( - "specify_cli.commands.bundle.MAX_DOWNLOAD_BYTES", 100 - ) - - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - # Return a response that exceeds 100 bytes. - return FakeBundleResponse(b"x" * 200, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - # Must fail with a size-limit error, not an unhandled traceback. - assert result.exit_code == 1 - # Rich may wrap the message across lines; normalise whitespace before checking. - output_flat = " ".join(result.output.split()) - assert "exceeds maximum size of 100 bytes" in output_flat diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index c32b059d53..dbc053ff48 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -11,7 +11,7 @@ import yaml -from specify_cli.bundler.models.catalog import ( +from specify_cli.bundles.catalogs import ( BUILTIN_DEFAULT_STACK, CatalogSource, InstallPolicy, @@ -21,7 +21,7 @@ ) from specify_cli.bundler import BundlerError import pytest -from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project +from tests.specify_cli.bundles.helpers import catalog_entry_dict, catalog_payload, make_project def test_non_integer_source_priority_raises_actionable_error(): @@ -243,7 +243,7 @@ def test_wheel_packages_firstparty_bundle_catalog(): def test_catalog_entry_rejects_string_tags(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["tags"] = "not-a-list" @@ -252,7 +252,7 @@ def test_catalog_entry_rejects_string_tags(): def test_catalog_entry_rejects_non_string_tag_members(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["tags"] = ["valid", 1] @@ -261,7 +261,7 @@ def test_catalog_entry_rejects_non_string_tag_members(): def test_catalog_entry_rejects_non_boolean_verified(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["verified"] = "false" # truthy string must not mark the entry verified @@ -305,7 +305,7 @@ def test_load_payload_rejects_missing_entry_id(): def test_catalog_entry_rejects_non_mapping_requires(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["requires"] = "speckit>=0.1" @@ -314,7 +314,7 @@ def test_catalog_entry_rejects_non_mapping_requires(): def test_catalog_entry_rejects_non_mapping_provides(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["provides"] = "extensions" @@ -345,7 +345,7 @@ def test_catalog_entry_rejects_falsy_non_mapping(field, bad): # `or {}` coerced a FALSY non-mapping ([], '', 0, False) to {} before the # isinstance guard, silently accepting a corrupt entry; only absent/None # means "not present". Mirrors the manifest requires/provides guard. - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data[field] = bad diff --git a/tests/contract/test_manifest_schema.py b/tests/contract/test_manifest_schema.py index 4784bdf462..edad666ebb 100644 --- a/tests/contract/test_manifest_schema.py +++ b/tests/contract/test_manifest_schema.py @@ -8,8 +8,8 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import BundleManifest -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest +from tests.specify_cli.bundles.helpers import valid_manifest_dict def test_valid_manifest_has_no_structural_errors(): diff --git a/tests/integration/test_bundler_init_install.py b/tests/integration/test_bundler_init_install.py deleted file mode 100644 index a13def5ff8..0000000000 --- a/tests/integration/test_bundler_init_install.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Install-time initialization and integration precedence (T049, T050). - -``specify bundle install`` into an uninitialized directory must scaffold a Spec -Kit project first (FR-012), choosing the integration by precedence (FR-013): -explicit ``--integration`` override → bundle-declared integration → default. -The end-to-end test runs fully offline against bundled assets. -""" -from __future__ import annotations - -import json -import os -from pathlib import Path - -import yaml -from typer.testing import CliRunner - -from specify_cli import app -from specify_cli.bundler.models.manifest import BundleManifest -from specify_cli.commands.bundle import _resolve_init_integration -from specify_cli.bundler.services.packager import build_bundle -from tests.bundler_helpers import valid_manifest_dict - -runner = CliRunner() - - -def _manifest(**overrides): - data = valid_manifest_dict(**overrides) - return BundleManifest.from_dict(data) - - -def test_precedence_override_wins(): - manifest = _manifest(integration={"id": "claude"}) - assert _resolve_init_integration("gemini", manifest) == "gemini" - - -def test_precedence_bundle_declared_when_no_override(): - manifest = _manifest(integration={"id": "claude"}) - assert _resolve_init_integration(None, manifest) == "claude" - - -def test_precedence_default_when_unspecified(): - manifest = _manifest() - assert _resolve_init_integration(None, manifest) == "copilot" - assert _resolve_init_integration(None, None) == "copilot" - - -def test_precedence_default_honors_env_var(monkeypatch): - monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini") - # With no override and no bundle-declared integration, the env-var default - # applies instead of the hardcoded "copilot". - assert _resolve_init_integration(None, None) == "gemini" - assert _resolve_init_integration(None, _manifest()) == "gemini" - # Explicit override and bundle-declared integration still take precedence. - assert _resolve_init_integration("claude", None) == "claude" - assert ( - _resolve_init_integration(None, _manifest(integration={"id": "claude"})) - == "claude" - ) - - -def _build_mini(tmp_path: Path) -> Path: - bundle = tmp_path / "mini" - bundle.mkdir() - (bundle / "bundle.yml").write_text( - yaml.safe_dump( - { - "schema_version": "1.0", - "bundle": { - "id": "mini", - "name": "Mini", - "version": "1.0.0", - "role": "developer", - "description": "minimal", - "author": "tests", - "license": "MIT", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}, - } - ), - encoding="utf-8", - ) - (bundle / "README.md").write_text("# Mini\n", encoding="utf-8") - return build_bundle(bundle).artifact_path - - -def test_install_initializes_uninitialized_project(tmp_path: Path): - project = tmp_path / "proj" - project.mkdir() - artifact = _build_mini(tmp_path) - - previous = Path.cwd() - os.chdir(project) - try: - result = runner.invoke( - app, ["bundle", "install", str(artifact), "--offline"] - ) - assert result.exit_code == 0, result.output - finally: - os.chdir(previous) - - assert (project / ".specify").is_dir() - marker = project / ".specify" / "integration.json" - assert marker.exists() - data = json.loads(marker.read_text(encoding="utf-8")) - assert "copilot" in json.dumps(data) diff --git a/tests/specify_cli/bundles/__init__.py b/tests/specify_cli/bundles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/specify_cli/bundles/_command_helpers.py b/tests/specify_cli/bundles/_command_helpers.py new file mode 100644 index 0000000000..81149d81d1 --- /dev/null +++ b/tests/specify_cli/bundles/_command_helpers.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specify_cli.bundles.sources import _local_manifest_source +from tests.specify_cli.bundles.helpers import catalog_entry_dict, write_catalog_file + +MARKUP_BUNDLE_ID = "[red]markup-id[/red]" +MARKUP_SOURCE_ID = "[underline]markup-source[/underline]" + + +def configure_markup_catalog(project: Path, **overrides: object) -> dict: + entry = catalog_entry_dict( + MARKUP_BUNDLE_ID, + name="[green]Markup Name[/green]", + version="[blue]1.0.0[/blue]", + role="[magenta]Markup Role[/magenta]", + description="[yellow]Markup Description[/yellow]", + author="[cyan]Markup Author[/cyan]", + license="[bold]Markup License[/bold]", + download_url="https://example.com/markup-bundle.zip", + requires={"speckit_version": "[italic]>=0.1.0[/italic]"}, + **overrides, + ) + catalog = project / "markup-catalog.json" + write_catalog_file(catalog, {MARKUP_BUNDLE_ID: entry}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": MARKUP_SOURCE_ID, + "url": str(catalog), + "priority": 1, + "install_policy": "install-allowed", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + return entry + + +def mock_manifest_download(monkeypatch, source_path: Path) -> None: + monkeypatch.setattr( + "specify_cli.bundles.command_info._download_manifest", + lambda resolved, *, offline: _local_manifest_source(str(source_path)), + ) diff --git a/tests/specify_cli/bundles/catalog/__init__.py b/tests/specify_cli/bundles/catalog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/specify_cli/bundles/catalog/test_command_add.py b/tests/specify_cli/bundles/catalog/test_command_add.py new file mode 100644 index 0000000000..5a6cc9c0e4 --- /dev/null +++ b/tests/specify_cli/bundles/catalog/test_command_add.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + write_catalog_file, +) + +runner = CliRunner() + + +def test_catalog_add_and_remove(project: Path): + catalog = project / "local-catalog.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + + listed = runner.invoke(app, ["bundle", "catalog", "list"]) + assert "local" in listed.output + + removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"]) + assert removed.exit_code == 0 diff --git a/tests/specify_cli/bundles/catalog/test_command_list.py b/tests/specify_cli/bundles/catalog/test_command_list.py new file mode 100644 index 0000000000..f3445c1e13 --- /dev/null +++ b/tests/specify_cli/bundles/catalog/test_command_list.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 + +runner = CliRunner() + + +def test_catalog_list_shows_builtin_defaults(project: Path): + result = runner.invoke(app, ["bundle", "catalog", "list"]) + assert result.exit_code == 0 + assert "default" in result.output + assert "community" in result.output + assert "built-in default stack" in result.output diff --git a/tests/specify_cli/bundles/catalog/test_command_remove.py b/tests/specify_cli/bundles/catalog/test_command_remove.py new file mode 100644 index 0000000000..335b2d28be --- /dev/null +++ b/tests/specify_cli/bundles/catalog/test_command_remove.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 + +runner = CliRunner() + + +def test_catalog_remove_builtin_is_refused(project: Path): + result = runner.invoke(app, ["bundle", "catalog", "remove", "default"]) + assert result.exit_code == 1 + assert "built-in" in result.output diff --git a/tests/specify_cli/bundles/conftest.py b/tests/specify_cli/bundles/conftest.py new file mode 100644 index 0000000000..f7fa8213cd --- /dev/null +++ b/tests/specify_cli/bundles/conftest.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture() +def project(tmp_path: Path, monkeypatch) -> Path: + (tmp_path / ".specify").mkdir() + monkeypatch.chdir(tmp_path) + return tmp_path diff --git a/tests/bundler_helpers.py b/tests/specify_cli/bundles/helpers.py similarity index 96% rename from tests/bundler_helpers.py rename to tests/specify_cli/bundles/helpers.py index 0ebaf2f1c7..eb46fc3baf 100644 --- a/tests/bundler_helpers.py +++ b/tests/specify_cli/bundles/helpers.py @@ -3,7 +3,7 @@ Kept out of ``tests/conftest.py`` so the existing root fixtures are untouched. Import what you need explicitly, e.g.:: - from tests.bundler_helpers import FakeInstaller, write_manifest + from tests.specify_cli.bundles.helpers import FakeInstaller, write_manifest """ from __future__ import annotations @@ -12,7 +12,7 @@ import yaml -from specify_cli.bundler.models.manifest import ComponentRef +from specify_cli.bundles.manifest import ComponentRef def valid_manifest_dict(**overrides) -> dict: diff --git a/tests/unit/test_bundler_adapters.py b/tests/specify_cli/bundles/test_adapters.py similarity index 99% rename from tests/unit/test_bundler_adapters.py rename to tests/specify_cli/bundles/test_adapters.py index d435d29c4d..6ce9a3232f 100644 --- a/tests/unit/test_bundler_adapters.py +++ b/tests/specify_cli/bundles/test_adapters.py @@ -13,8 +13,8 @@ from specify_cli.authentication.http import _StripAuthOnRedirect from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy -from specify_cli.bundler.services import adapters +from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy +from specify_cli.bundles import adapters def _source(url: str) -> CatalogSource: diff --git a/tests/specify_cli/bundles/test_bundles.py b/tests/specify_cli/bundles/test_bundles.py new file mode 100644 index 0000000000..098cca20f8 --- /dev/null +++ b/tests/specify_cli/bundles/test_bundles.py @@ -0,0 +1,8 @@ +"""Tests for the public bundle domain package.""" + + +def test_legacy_bundler_error_import_remains_compatible(): + from specify_cli.bundler import BundlerError as LegacyBundlerError + from specify_cli.bundles import BundlerError + + assert LegacyBundlerError is BundlerError diff --git a/tests/unit/test_bundler_catalog_config.py b/tests/specify_cli/bundles/test_catalog_config.py similarity index 99% rename from tests/unit/test_bundler_catalog_config.py rename to tests/specify_cli/bundles/test_catalog_config.py index 46c333700a..5a8f62a2e5 100644 --- a/tests/unit/test_bundler_catalog_config.py +++ b/tests/specify_cli/bundles/test_catalog_config.py @@ -6,7 +6,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.commands_impl import catalog_config as cc +from specify_cli.bundles import catalog_config as cc def test_derive_id_incorporates_path_stem_for_same_host(): diff --git a/tests/integration/test_bundler_catalog_stack.py b/tests/specify_cli/bundles/test_catalog_stack.py similarity index 95% rename from tests/integration/test_bundler_catalog_stack.py rename to tests/specify_cli/bundles/test_catalog_stack.py index 87d31581b1..0dd3d0c9f4 100644 --- a/tests/integration/test_bundler_catalog_stack.py +++ b/tests/specify_cli/bundles/test_catalog_stack.py @@ -5,9 +5,9 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope -from specify_cli.bundler.services.catalog_stack import CatalogStack -from tests.bundler_helpers import catalog_entry_dict, catalog_payload +from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy, Scope +from specify_cli.bundles.catalog_stack import CatalogStack +from tests.specify_cli.bundles.helpers import catalog_entry_dict, catalog_payload def _source(source_id, priority, policy, url="builtin://x"): diff --git a/tests/specify_cli/bundles/test_command_add.py b/tests/specify_cli/bundles/test_command_add.py new file mode 100644 index 0000000000..203cc9bed6 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_add.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.adapters import FIRSTPARTY_CATALOG_URL + +runner = CliRunner() +REPO_ROOT = Path(__file__).parents[3] + + +class FakeBundleResponse(io.BytesIO): + def __init__(self, data: bytes, url: str): + super().__init__(data) + self._url = url + + def geturl(self) -> str: + return self._url + + +def test_add_forwards_refresh_default_without_refreshing(project: Path): + with patch("specify_cli.bundles.command_add.bundle_install") as install: + result = runner.invoke(app, ["bundle", "add", "demo"]) + + assert result.exit_code == 0, result.output + install.assert_called_once_with( + bundle_id="demo", + integration=None, + offline=False, + refresh=False, + ) + + +@pytest.mark.parametrize( + ("bundle_id", "extension_id"), + [("bugfix", "bug"), ("assess", "assess")], +) +def test_bundle_add_by_id_initializes_empty_project_from_firstparty_catalog( + tmp_path: Path, monkeypatch, bundle_id: str, extension_id: str +): + project = tmp_path / "fresh" + project.mkdir() + monkeypatch.chdir(project) + + catalog_bytes = (REPO_ROOT / "bundles" / "catalog.json").read_bytes() + manifest_bytes = (REPO_ROOT / "bundles" / bundle_id / "bundle.yml").read_bytes() + expected_manifest_url = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + f"bundles/{bundle_id}/bundle.yml" + ) + captured_urls: list[str] = [] + + def fake_open_url( + url: str, + timeout: int | None = None, + extra_headers: dict[str, str] | None = None, + redirect_validator=None, + ): + captured_urls.append(url) + if url == FIRSTPARTY_CATALOG_URL: + return FakeBundleResponse(catalog_bytes, url=url) + if url == expected_manifest_url: + return FakeBundleResponse(manifest_bytes, url=url) + raise AssertionError(f"Unexpected network request in by-ID bundle test: {url}") + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke( + app, ["bundle", "add", bundle_id, "--integration", "copilot"] + ) + + assert result.exit_code == 0, result.output + assert "No Spec Kit project here" in result.output + assert (project / ".specify").is_dir() + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" + assert FIRSTPARTY_CATALOG_URL in captured_urls + assert expected_manifest_url in captured_urls diff --git a/tests/specify_cli/bundles/test_command_build.py b/tests/specify_cli/bundles/test_command_build.py new file mode 100644 index 0000000000..0b721d9593 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_build.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + valid_manifest_dict, +) + +runner = CliRunner() + + +def test_build_produces_artifact(project: Path): + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (project / "README.md").write_text("# Demo", encoding="utf-8") + result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")]) + assert result.exit_code == 0, result.output + artifacts = list((project / "dist").glob("*.zip")) + assert len(artifacts) == 1 + + +def test_build_escapes_markup_in_output_path(project: Path): + """The build success line echoes a caller-supplied ``--output`` path. + + Brackets are legal in a directory name on both POSIX and Windows, so the + artifact is built and *then* misreported: ``[bold]`` is consumed as a style + tag, and the success line names a path that does not exist on disk. + + A closing tag (``[/red]``) would raise MarkupError outright, but ``/`` is a + path separator on Windows, so this uses the silent-swallow form to keep the + fixture portable. + """ + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (project / "README.md").write_text("# Demo", encoding="utf-8") + out_dir = project / "dist[bold]out" + + result = runner.invoke(app, ["bundle", "build", "--output", str(out_dir)]) + + assert result.exit_code == 0, repr(result.exception) + assert list(out_dir.glob("*.zip")), "the artifact should still be built" + # Join across Rich's wrap points: the success line prints an absolute path, + # so the console folds it mid-token whenever the temp directory is long + # enough, which is a property of the runner's path, not of the escaping. + assert "dist[bold]out" in "".join(strip_ansi(result.output).split()), ( + "the reported path must match the directory actually written" + ) diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py new file mode 100644 index 0000000000..6d9f6905a9 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_info.py @@ -0,0 +1,606 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles._command_helpers import ( + MARKUP_BUNDLE_ID, + MARKUP_SOURCE_ID, + configure_markup_catalog as _configure_markup_catalog, + mock_manifest_download as _mock_manifest_download, +) +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + valid_manifest_dict, + write_catalog_file, +) + +runner = CliRunner() + + +class FakeBundleResponse(io.BytesIO): + def __init__( + self, + data: bytes, + url: str = "https://api.github.com/repos/org/repo/releases/assets/99", + ): + super().__init__(data) + self._url = url + + def geturl(self) -> str: + return self._url + + +def _make_catalog_config(catalog_path: Path, project: Path) -> None: + """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*. + + Priority 0 keeps this test catalog ahead of the packaged first-party + catalog, avoiding unrelated network resolution in info tests. + """ + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "test", + "url": str(catalog_path), + "priority": 0, + "install_policy": "install-allowed", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + + +def test_info_unknown_bundle_without_project_reports_not_found( + tmp_path: Path, monkeypatch +): + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"]) + # Reaches catalog resolution (not the project gate) and reports a clean miss. + assert result.exit_code == 1 + assert "Spec Kit project" not in result.output + + +def test_info_expands_full_component_set(project: Path, monkeypatch): + bundle_dir = project / "src-bundle" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + catalog = project / "local-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") + + result = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--json", "--offline"] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]): c for c in payload["components"]} + assert ("extensions", "ext-a") in components + preset = components[("presets", "preset-a")] + assert preset["version"] == "2.0.0" + assert preset["priority"] == 10 + assert preset["strategy"] == "append" + assert payload["trust"] == "verified" + + text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert "preset-a v2.0.0" in text.output + assert "Trust" in text.output + + +def test_info_escapes_catalog_markup(project: Path, monkeypatch): + entry = _configure_markup_catalog(project) + bundle_dir = project / "markup-bundle" + bundle_dir.mkdir() + manifest_data = valid_manifest_dict() + manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID + manifest_data["integration"] = {"id": "[conceal]markup-integration[/conceal]"} + manifest_path = bundle_dir / "bundle.yml" + manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8") + _mock_manifest_download(monkeypatch, manifest_path) + monkeypatch.setattr( + "specify_cli.bundles.command_info._manifest_component_view", + lambda manifest: [ + { + "kind": "extensions", + "id": "[reverse]markup-component[/reverse]", + "version": "[strike]2.0.0[/strike]", + } + ], + ) + monkeypatch.setattr( + "specify_cli.bundles.command_info._bundle_overlaps", + lambda project_root, manifest, *, offline: ["[blink]markup-overlap[/blink]"], + ) + + result = runner.invoke( + app, + ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"], + ) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for value in ( + entry["id"], + entry["name"], + entry["version"], + entry["role"], + entry["description"], + entry["author"], + entry["license"], + entry["requires"]["speckit_version"], + MARKUP_SOURCE_ID, + "[conceal]markup-integration[/conceal]", + "[reverse]markup-component[/reverse]", + "[strike]2.0.0[/strike]", + "[blink]markup-overlap[/blink]", + ): + assert value in output + + +def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch): + markup_count = "[bold]markup-count[/bold]" + _configure_markup_catalog( + project, + provides={"extensions": markup_count}, + ) + bundle_dir = project / "markup-bundle" + bundle_dir.mkdir() + manifest_data = valid_manifest_dict(provides={}) + manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID + manifest_path = bundle_dir / "bundle.yml" + manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8") + _mock_manifest_download(monkeypatch, manifest_path) + + result = runner.invoke( + app, + ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"], + ) + + assert result.exit_code == 0, result.output + assert markup_count in strip_ansi(result.output) + + +def test_info_expands_discovery_only_bundle(project: Path, monkeypatch): + # Discovery-only bundles must still be fully inspectable via `info`; + # only `install` is refused for them. + bundle_dir = project / "disc-bundle" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + catalog = project / "disc-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "disc", + "url": str(catalog), + "priority": 1, + "install_policy": "discovery-only", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") + result = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--json", "--offline"] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]) for c in payload["components"]} + assert ("extensions", "ext-a") in components + + +def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch): + # A .zip artifact is extracted to read bundle.yml; info expands it. (The + # download itself is HTTPS-only now and mocked here — see contract note.) + bundle_dir = project / "zip-src" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (bundle_dir / "README.md").write_text("# Demo", encoding="utf-8") + artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path + catalog = project / "zip-catalog.json" + write_catalog_file( + catalog, + { + "demo-bundle": catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + }, + ) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + _mock_manifest_download(monkeypatch, artifact) + result = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--json", "--offline"] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]) for c in payload["components"]} + assert ("extensions", "ext-a") in components + + +def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path): + # `info` must expand the real component set; if the manifest can't be + # resolved (here: --offline against an https download_url), it should error + # and exit non-zero rather than silently degrading to `provides` counts. + catalog = project / "remote-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"] + ) + assert added.exit_code == 0, added.output + + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert result.exit_code == 1 + assert "Network access disabled" in result.output + + +def test_bundle_info_resolves_github_browser_release_url(project: Path): + """bundle info resolves a private-repo browser release URL via the GitHub API.""" + browser_url = "https://github.com/org/repo/releases/download/v1.0/bundle.yml" + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + + captured = [] + manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + if "releases/tags/" in url: + # GitHub API release-tags lookup — return asset list + return FakeBundleResponse( + json.dumps( + {"assets": [{"name": "bundle.yml", "url": api_asset_url}]} + ).encode(), + url=url, + ) + # Actual asset download + return FakeBundleResponse(manifest_yaml, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # The browser release URL must have been resolved via the GitHub tags API + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 1, f"Expected exactly one tags API call; got {captured}" + assert "releases/tags/v1.0" in tag_calls[0] + + # The actual download must use the resolved API asset URL with octet-stream + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][0] == api_asset_url + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + +def test_bundle_info_rejects_utf16_remote_manifest_like_local_sources(project: Path): + """A downloaded (non-zip) bundle.yml must be decoded strictly as UTF-8. + + ``yamlio.load_yaml`` decodes local ``bundle.yml`` sources strictly as + UTF-8, so a well-formed UTF-16 manifest (a realistic PowerShell + ``Out-File`` output) is rejected. Feeding the downloaded bytes straight + to ``yaml.safe_load(io.BytesIO(raw))`` let PyYAML's Reader honour the + UTF-16 BOM and silently *accept* the same manifest instead, diverging + from local/zip sources (the zip branch of this same download path was + already fixed for the identical bug). + """ + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + manifest_yaml_utf16 = yaml.safe_dump(valid_manifest_dict()).encode("utf-16") + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + return FakeBundleResponse(manifest_yaml_utf16, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 1 + output_flat = " ".join(result.output.split()) + assert "could not be read" in output_flat.lower() + + +def test_bundle_info_passes_through_api_asset_url(project: Path): + """bundle info passes a direct GitHub API asset URL through with octet-stream.""" + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77" + + captured = [] + manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + return FakeBundleResponse(manifest_yaml, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # No tags API call — URL was already a REST asset URL + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 0 + + # Exactly one download call to the asset URL with octet-stream + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][0] == api_asset_url + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + +def test_bundle_info_resolves_github_browser_release_url_zip(project: Path): + """bundle info resolves a browser release URL for a .zip artifact and extracts bundle.yml.""" + import io + import zipfile + + browser_url = "https://github.com/org/repo/releases/download/v2.0/bundle.zip" + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/88" + + # Build a minimal in-memory ZIP containing bundle.yml + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) + zip_bytes = buf.getvalue() + + captured = [] + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeBundleResponse( + json.dumps( + {"assets": [{"name": "bundle.zip", "url": api_asset_url}]} + ).encode(), + url=url, + ) + return FakeBundleResponse(zip_bytes, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # tags API lookup must have fired + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 1 + assert "releases/tags/v2.0" in tag_calls[0] + + # Asset download uses the resolved API URL with octet-stream + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][0] == api_asset_url + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + # Manifest was successfully parsed from the ZIP + payload = json.loads(result.output) + assert payload["id"] == "demo-bundle" + + +def test_bundle_info_api_asset_url_zip_detected_by_magic_bytes(project: Path): + """bundle info correctly handles a direct API asset URL that serves ZIP bytes.""" + import io + import zipfile + + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/55" + + # Build a minimal in-memory ZIP containing bundle.yml + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) + zip_bytes = buf.getvalue() + + captured = [] + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + return FakeBundleResponse(zip_bytes, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # No tags API call — URL was already a REST asset URL + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 0 + + # Download used octet-stream header + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + # ZIP bytes were detected by magic and bundle.yml extracted correctly + payload = json.loads(result.output) + assert payload["id"] == "demo-bundle" + + +def test_bundle_info_github_release_url_resolution_failure_falls_back_and_errors( + project: Path, +): + """When the GitHub tags API lookup finds no matching asset, fall back to the + original browser URL and surface a meaningful error (not a raw traceback).""" + browser_url = "https://github.com/org/repo/releases/download/v3.0/bundle.yml" + + captured = [] + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + if "releases/tags/" in url: + # Tags API responds but the asset list doesn't include our file + return FakeBundleResponse( + json.dumps({"assets": []}).encode(), + url=url, + ) + # Fallback download: GitHub serves HTML (SSO redirect) instead of YAML + return FakeBundleResponse(b"SSO login required", url=url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + # Must exit non-zero — the HTML body is not a valid bundle manifest + assert result.exit_code == 1 + + # The tags API lookup must have fired + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 1 + + # The fallback download should use the original browser URL (no octet-stream) + fallback_calls = [(url, h) for url, h in captured if url == browser_url] + assert len(fallback_calls) == 1 + assert fallback_calls[0][1] is None # no Accept header on the original URL + + # Error output must be actionable (not a raw traceback) + assert "Error:" in result.output + + +def test_bundle_info_resolves_ghes_browser_release_url(project: Path): + """bundle info resolves a GHES private-repo browser release URL via /api/v3.""" + ghes_host = "ghes.example" + browser_url = f"https://{ghes_host}/org/repo/releases/download/v1.0/bundle.yml" + api_asset_url = f"https://{ghes_host}/api/v3/repos/org/repo/releases/assets/42" + + captured = [] + manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + if "/api/v3/repos/" in url and "releases/tags/" in url: + return FakeBundleResponse( + json.dumps( + {"assets": [{"name": "bundle.yml", "url": api_asset_url}]} + ).encode(), + url=url, + ) + return FakeBundleResponse(manifest_yaml, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, + ) + _make_catalog_config(catalog, project) + + with ( + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), + patch( + "specify_cli.authentication.http.github_provider_hosts", + return_value=(ghes_host,), + ), + ): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # The GHES /api/v3 tags lookup must have fired + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 1 + assert f"{ghes_host}/api/v3/repos/org/repo/releases/tags/v1.0" in tag_calls[0] + + # Asset download must use the resolved GHES API URL with octet-stream + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][0] == api_asset_url + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + payload = json.loads(result.output) + assert payload["id"] == "demo-bundle" + + +def test_bundle_download_rejects_oversized_response(project: Path, monkeypatch): + """Bundle download rejects responses exceeding MAX_DOWNLOAD_BYTES.""" + # Monkeypatch to a small limit so the test is fast and low-memory. + monkeypatch.setattr("specify_cli.bundles.sources.MAX_DOWNLOAD_BYTES", 100) + + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + # Return a response that exceeds 100 bytes. + return FakeBundleResponse(b"x" * 200, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + # Must fail with a size-limit error, not an unhandled traceback. + assert result.exit_code == 1 + # Rich may wrap the message across lines; normalise whitespace before checking. + output_flat = " ".join(result.output.split()) + assert "exceeds maximum size of 100 bytes" in output_flat diff --git a/tests/specify_cli/bundles/test_command_init.py b/tests/specify_cli/bundles/test_command_init.py new file mode 100644 index 0000000000..adf60e0035 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_init.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from specify_cli import app + +runner = CliRunner() + + +def _make_project(tmp_path: Path, name: str) -> Path: + project = tmp_path / name + (project / ".specify").mkdir(parents=True) + return project + + +def test_override_symlinked_specify_errors_bundle_init_no_fallback( + tmp_path, monkeypatch +): + """A symlinked override .specify must not make bundle init fall back to cwd.""" + web = tmp_path / "web" + web.mkdir() + real = tmp_path / "real-specify" + real.mkdir() + try: + (web / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are not available in this environment") + + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["bundle", "init", "--offline"]) + assert result.exit_code != 0 + assert "symlinked .specify" in result.output + assert not (elsewhere / ".specify").exists() diff --git a/tests/integration/test_bundler_local_install.py b/tests/specify_cli/bundles/test_command_install.py similarity index 54% rename from tests/integration/test_bundler_local_install.py rename to tests/specify_cli/bundles/test_command_install.py index 7655543ebb..399162efa1 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -1,11 +1,6 @@ -"""Tests for installing a bundle from a local artifact/path (T045). - -The resolution-level tests are pure; the end-to-end test installs the bundled -``agent-context`` extension fully offline from a built ``.zip`` artifact, -proving the real in-process primitive dispatch (T044) works without a network. -""" from __future__ import annotations +import json import os import zipfile from pathlib import Path @@ -16,83 +11,123 @@ from typer.testing import CliRunner from specify_cli import app -from specify_cli.bundler import BundlerError -from specify_cli.commands.bundle import _local_manifest_source -from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict, write_manifest - - -def test_local_source_none_for_non_path(): - assert _local_manifest_source("some-catalog-bundle-id") is None +from specify_cli.bundles._commands import _resolve_init_integration +from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles.packager import build_bundle +from tests.specify_cli.bundles.helpers import ( + FakeInstaller, + catalog_entry_dict, + make_project, + valid_manifest_dict, + write_catalog_file, + write_manifest, +) + +runner = CliRunner() +REPO_ROOT = Path(__file__).parents[3] + + +def _bundled_workflow_manifest(workflow_id: str, version: str = "1.0.0") -> dict: + return valid_manifest_dict( + provides={"workflows": [{"id": workflow_id, "version": version}]} + ) -def test_local_source_from_directory(tmp_path: Path): - write_manifest(tmp_path, valid_manifest_dict()) - manifest = _local_manifest_source(str(tmp_path)) - assert manifest is not None - assert manifest.bundle.id == "demo-bundle" +@pytest.mark.parametrize( + ("command", "bundle_id", "extension_id"), + [("install", "bugfix", "bug"), ("add", "assess", "assess")], +) +def test_local_firstparty_bundle_installs_bundled_components_offline( + project: Path, command: str, bundle_id: str, extension_id: str +): + bundle_dir = REPO_ROOT / "bundles" / bundle_id + result = runner.invoke(app, ["bundle", command, str(bundle_dir), "--offline"]) -def test_local_source_from_bundle_yml(tmp_path: Path): - path = write_manifest(tmp_path, valid_manifest_dict()) - manifest = _local_manifest_source(str(path)) - assert manifest is not None - assert manifest.bundle.id == "demo-bundle" + assert result.exit_code == 0, result.output + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" -def test_local_source_from_zip_artifact(tmp_path: Path): - bundle_dir = tmp_path / "bundle" - bundle_dir.mkdir() - write_manifest(bundle_dir, valid_manifest_dict()) - (bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8") +def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): + bundle_dir = project / "mismatched-workflow-pin" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("bugfix", "9.9.9")), + encoding="utf-8", + ) - runner = CliRunner() - result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)]) - assert result.exit_code == 0, result.output - artifact = next(bundle_dir.glob("*.zip")) + result = runner.invoke(app, ["bundle", "install", str(bundle_dir), "--offline"]) - manifest = _local_manifest_source(str(artifact)) - assert manifest is not None - assert manifest.bundle.id == "demo-bundle" + assert result.exit_code == 1 + assert "pinned to version 9.9.9" in result.output + assert not (project / ".specify" / "workflows" / "bugfix").exists() -def test_local_source_rejects_unknown_file(tmp_path: Path): - weird = tmp_path / "thing.txt" - weird.write_text("nope", encoding="utf-8") - with pytest.raises(BundlerError, match="not a recognised bundle source"): - _local_manifest_source(str(weird)) +def test_local_bundle_refuses_unbundled_workflow_offline(project: Path): + bundle_dir = project / "unbundled-workflow" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("not-bundled")), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "install", str(bundle_dir), "--offline"]) -def test_local_source_zip_non_utf8_manifest_raises_bundler_error(tmp_path: Path): - """Undecodable bundle.yml bytes inside a .zip must raise BundlerError. + assert result.exit_code == 1 + assert "network access is disabled" in " ".join(result.output.lower().split()) - The manifest bytes are decoded as UTF-8 explicitly, matching - ``yamlio.load_yaml``'s "Could not read ..." contract, instead of - escaping as a raw ``UnicodeDecodeError``/``ReaderError`` traceback. - """ - artifact = tmp_path / "demo.zip" - with zipfile.ZipFile(artifact, "w") as archive: - archive.writestr("bundle.yml", b"\xff\xfe bundle \xc3\x28\n") - with pytest.raises(BundlerError, match="Could not read"): - _local_manifest_source(str(artifact)) +def test_install_refuses_discovery_only_source(project: Path, monkeypatch): + # Point a discovery-only catalog at a local payload containing the bundle. + catalog = project / "disc.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "disc", + "url": str(catalog), + "priority": 1, + "install_policy": "discovery-only", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "install", "demo", "--offline"]) + assert result.exit_code == 1 + assert "discovery-only" in result.output -def test_local_source_zip_utf16_manifest_rejected_like_directory(tmp_path: Path): - """A well-formed UTF-16 manifest must fail the same way in a .zip. +def test_install_integration_override_cannot_bypass_clash_guard(project: Path): + # An initialized project's recorded active integration is authoritative: + # passing --integration must not let a differently-pinned bundle install. + import json - ``yamlio.load_yaml`` decodes strictly as UTF-8, so a UTF-16 bundle.yml - (the realistic PowerShell ``Out-File`` output) is rejected when read - from a directory. Feeding the zip bytes straight to PyYAML would let - its Reader honour the UTF-16 BOM and *accept* the same manifest, - making zip and directory sources diverge. - """ - artifact = tmp_path / "demo.zip" - manifest_text = "bundle:\n id: demo-bundle\n version: 1.0.0\n" - with zipfile.ZipFile(artifact, "w") as archive: - archive.writestr("bundle.yml", manifest_text.encode("utf-16")) + (project / ".specify" / "integration.json").write_text( + json.dumps({"integration": "copilot"}), encoding="utf-8" + ) + bundle_dir = project / "claude-bundle" + bundle_dir.mkdir() + data = valid_manifest_dict(integration={"id": "claude"}) + (bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8") - with pytest.raises(BundlerError, match="Could not read"): - _local_manifest_source(str(artifact)) + result = runner.invoke( + app, + ["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"], + ) + assert result.exit_code == 1 + assert "claude" in result.output and "copilot" in result.output def test_install_bundled_extension_from_zip_offline(tmp_path: Path): @@ -149,91 +184,6 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path): os.chdir(previous) -def test_download_manifest_rejects_file_url(tmp_path: Path): - """A catalog ``file://`` download_url is rejected — catalog URLs are - HTTPS-only, matching extensions/presets/workflows. Disk installs go through - the positional path (see the local-source tests above), not download_url. - """ - from types import SimpleNamespace - - from specify_cli.commands.bundle import _download_manifest - - manifest_path = write_manifest(tmp_path / "my bundles") - resolved = SimpleNamespace( - entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri()) - ) - - with pytest.raises(BundlerError, match="bundle install"): - _download_manifest(resolved, offline=True) - - -def test_download_manifest_rejects_bare_path(tmp_path: Path): - """A bare filesystem path download_url is likewise rejected.""" - from types import SimpleNamespace - - from specify_cli.commands.bundle import _download_manifest - - manifest_path = write_manifest(tmp_path / "plain") - resolved = SimpleNamespace( - entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path)) - ) - - with pytest.raises(BundlerError, match="bundle install"): - _download_manifest(resolved, offline=True) - - -def test_local_install_still_resolves_via_positional_path(tmp_path: Path): - """The supported local route — a positional path, not a download_url — - still resolves the manifest via _local_manifest_source.""" - manifest_path = write_manifest(tmp_path / "my bundles") - manifest = _local_manifest_source(str(manifest_path)) - assert manifest is not None - assert manifest.bundle.id == "demo-bundle" - - -def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path): - """A non-HTTPS download_url must report the HTTPS problem, not a misleading - 'Network access disabled', even under --offline (scheme is validated before - the offline gate).""" - from types import SimpleNamespace - - from specify_cli.commands.bundle import _download_manifest - - resolved = SimpleNamespace( - entry=SimpleNamespace( - id="demo-bundle", download_url="http://example.com/bundle.zip" - ) - ) - with pytest.raises(BundlerError, match="HTTPS"): - _download_manifest(resolved, offline=True) - - -def test_local_zip_uses_bounded_archive_open(tmp_path: Path): - artifact = tmp_path / "too-many-entries.zip" - with zipfile.ZipFile(artifact, "w") as archive: - archive.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) - for index in range(512): - archive.writestr(f"assets/{index}.txt", "") - - with pytest.raises(BundlerError, match="too many entries"): - _local_manifest_source(str(artifact)) - - -def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path): - """A malformed bundle.yml inside a .zip must raise BundlerError. - - The zip branch parses YAML inline rather than through load_yaml(), so the - raw yaml.YAMLError used to escape. It is neither a ValueError nor an - OSError, so nothing upstream caught it. - """ - artifact = tmp_path / "bad-manifest.zip" - with zipfile.ZipFile(artifact, "w") as archive: - archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n") - - with pytest.raises(BundlerError, match="Invalid YAML"): - _local_manifest_source(str(artifact)) - - def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path): """`bundle install` reports malformed YAML the same way for all 3 sources. @@ -257,9 +207,9 @@ def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Pa for source in (directory, manifest_file, artifact): result = runner.invoke(app, ["bundle", "install", str(source)]) assert result.exit_code == 1, f"{source.name}: {result.output}" - assert result.exception is None or isinstance( - result.exception, SystemExit - ), f"{source.name} leaked {type(result.exception).__name__}" + assert result.exception is None or isinstance(result.exception, SystemExit), ( + f"{source.name} leaked {type(result.exception).__name__}" + ) assert "Invalid YAML" in result.output, f"{source.name}: {result.output}" @@ -276,7 +226,7 @@ def test_invalid_local_manifest_is_rejected_before_project_init( monkeypatch.chdir(empty_cwd) runner = CliRunner() - with patch("specify_cli.commands.bundle._run_init") as run_init: + with patch("specify_cli.bundles.command_install._run_init") as run_init: result = runner.invoke( app, ["bundle", "install", str(bundle_dir), "--offline"], @@ -300,7 +250,7 @@ def test_incompatible_local_manifest_is_rejected_before_project_init( monkeypatch.chdir(empty_cwd) runner = CliRunner() - with patch("specify_cli.commands.bundle._run_init") as run_init: + with patch("specify_cli.bundles.command_install._run_init") as run_init: result = runner.invoke( app, ["bundle", "install", str(bundle_dir), "--offline"], @@ -314,10 +264,13 @@ def test_incompatible_local_manifest_is_rejected_before_project_init( @pytest.mark.parametrize("source_kind", ["manifest", "directory", "zip"]) @pytest.mark.parametrize("bundle_version", ["1.2.0", "2.0.0"]) def test_local_install_refresh_updates_owned_components( - tmp_path: Path, monkeypatch, source_kind: str, bundle_version: str, + tmp_path: Path, + monkeypatch, + source_kind: str, + bundle_version: str, ): """Local upgrades refresh owned pins before advancing the bundle record.""" - from specify_cli.bundler.models.records import load_records, records_path + from specify_cli.bundles.records import load_records, records_path project = make_project(tmp_path / "proj") monkeypatch.chdir(project) @@ -335,7 +288,7 @@ def refresh(self, root, component): installer = VersionedInstaller() monkeypatch.setattr( - "specify_cli.bundler.services.adapters.DefaultPrimitiveInstaller", + "specify_cli.bundles.adapters.DefaultPrimitiveInstaller", lambda **kwargs: installer, ) data = valid_manifest_dict() @@ -367,7 +320,8 @@ def refresh(self, root, component): assert installer.refresh_calls == [] refreshed = runner.invoke( - app, ["bundle", "install", str(source), "--offline", "--refresh"], + app, + ["bundle", "install", str(source), "--offline", "--refresh"], ) assert refreshed.exit_code == 0, refreshed.output assert "--refresh" in rejected.output @@ -382,22 +336,31 @@ def refresh(self, root, component): assert set(installer.refresh_calls) == set(expected) record = load_records(project)[0] assert record.version == bundle_version - assert {(c.kind, c.id): c.version for c in record.contributed_components} == expected + assert { + (c.kind, c.id): c.version for c in record.contributed_components + } == expected @pytest.mark.parametrize("source_kind", ["manifest", "directory", "zip"]) @pytest.mark.parametrize("bundle_version", ["1.2.0", "2.0.0"]) def test_local_refresh_catalog_extension_requires_network( - tmp_path: Path, monkeypatch, source_kind: str, bundle_version: str, + tmp_path: Path, + monkeypatch, + source_kind: str, + bundle_version: str, ): """Use the real installer; replace only catalog I/O with local artifacts.""" - from specify_cli.bundler.models.records import load_records, records_path + from specify_cli.bundles.records import load_records, records_path from specify_cli.extensions import ExtensionCatalog project = make_project(tmp_path / "project") monkeypatch.chdir(project) - monkeypatch.setattr("specify_cli.commands.bundle._bundle_overlaps", lambda *a, **kw: []) - monkeypatch.setattr("specify_cli._assets._locate_bundled_extension", lambda cid: None) + monkeypatch.setattr( + "specify_cli.bundles.command_install._bundle_overlaps", lambda *a, **kw: [] + ) + monkeypatch.setattr( + "specify_cli._assets._locate_bundled_extension", lambda cid: None + ) version = "1.0.0" downloads = [] @@ -407,25 +370,34 @@ def download_extension(self, extension_id): extension = { "schema_version": "1.0", "extension": { - "id": extension_id, "name": "Catalog extension", - "version": version, "description": "Refresh regression", + "id": extension_id, + "name": "Catalog extension", + "version": version, + "description": "Refresh regression", }, "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"commands": [ - {"name": "speckit.catalog-ext.hello", "file": "commands/hello.md"}, - ]}, + "provides": { + "commands": [ + {"name": "speckit.catalog-ext.hello", "file": "commands/hello.md"}, + ] + }, } with zipfile.ZipFile(artifact, "w") as archive: archive.writestr("extension.yml", yaml.safe_dump(extension)) - archive.writestr("commands/hello.md", f"---\ndescription: Test\n---\n{version}\n") + archive.writestr( + "commands/hello.md", f"---\ndescription: Test\n---\n{version}\n" + ) return artifact monkeypatch.setattr( - ExtensionCatalog, "get_extension_info", + ExtensionCatalog, + "get_extension_info", lambda self, cid: {"id": cid, "version": version, "_install_allowed": True}, ) monkeypatch.setattr(ExtensionCatalog, "download_extension", download_extension) - data = valid_manifest_dict(provides={"extensions": [{"id": "catalog-ext", "version": version}]}) + data = valid_manifest_dict( + provides={"extensions": [{"id": "catalog-ext", "version": version}]} + ) manifest_path = write_manifest(tmp_path / "local bundle", data) runner = CliRunner() first = runner.invoke(app, ["bundle", "install", str(manifest_path)]) @@ -457,7 +429,9 @@ def download_extension(self, extension_id): assert payload.read_bytes() == original_payload assert (installed_dir / "extension.yml").read_bytes() == original_manifest - offline = runner.invoke(app, ["bundle", "install", str(source), "--refresh", "--offline"]) + offline = runner.invoke( + app, ["bundle", "install", str(source), "--refresh", "--offline"] + ) assert offline.exit_code == 1, offline.output output = " ".join(offline.output.split()) assert "catalog-ext" in output @@ -474,7 +448,95 @@ def download_extension(self, extension_id): assert "1 refreshed" in refreshed.output assert downloads == [("catalog-ext", "1.0.0"), ("catalog-ext", "2.0.0")] assert payload.read_text(encoding="utf-8").endswith("2.0.0\n") - assert yaml.safe_load((installed_dir / "extension.yml").read_text(encoding="utf-8"))["extension"]["version"] == version + assert ( + yaml.safe_load((installed_dir / "extension.yml").read_text(encoding="utf-8"))[ + "extension" + ]["version"] + == version + ) record = load_records(project)[0] assert record.version == bundle_version assert record.contributed_components[0].version == version + + +def _manifest(**overrides): + data = valid_manifest_dict(**overrides) + return BundleManifest.from_dict(data) + + +def _build_mini(tmp_path: Path) -> Path: + bundle = tmp_path / "mini" + bundle.mkdir() + (bundle / "bundle.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "bundle": { + "id": "mini", + "name": "Mini", + "version": "1.0.0", + "role": "developer", + "description": "minimal", + "author": "tests", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "extensions": [{"id": "agent-context", "version": "1.0.0"}] + }, + } + ), + encoding="utf-8", + ) + (bundle / "README.md").write_text("# Mini\n", encoding="utf-8") + return build_bundle(bundle).artifact_path + + +def test_precedence_override_wins(): + manifest = _manifest(integration={"id": "claude"}) + assert _resolve_init_integration("gemini", manifest) == "gemini" + + +def test_precedence_bundle_declared_when_no_override(): + manifest = _manifest(integration={"id": "claude"}) + assert _resolve_init_integration(None, manifest) == "claude" + + +def test_precedence_default_when_unspecified(): + manifest = _manifest() + assert _resolve_init_integration(None, manifest) == "copilot" + assert _resolve_init_integration(None, None) == "copilot" + + +def test_precedence_default_honors_env_var(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini") + # With no override and no bundle-declared integration, the env-var default + # applies instead of the hardcoded "copilot". + assert _resolve_init_integration(None, None) == "gemini" + assert _resolve_init_integration(None, _manifest()) == "gemini" + # Explicit override and bundle-declared integration still take precedence. + assert _resolve_init_integration("claude", None) == "claude" + assert ( + _resolve_init_integration(None, _manifest(integration={"id": "claude"})) + == "claude" + ) + + +def test_install_initializes_uninitialized_project(tmp_path: Path): + project = tmp_path / "proj" + project.mkdir() + artifact = _build_mini(tmp_path) + + previous = Path.cwd() + os.chdir(project) + try: + result = runner.invoke(app, ["bundle", "install", str(artifact), "--offline"]) + assert result.exit_code == 0, result.output + finally: + os.chdir(previous) + + assert (project / ".specify").is_dir() + marker = project / ".specify" / "integration.json" + assert marker.exists() + data = json.loads(marker.read_text(encoding="utf-8")) + assert "copilot" in json.dumps(data) diff --git a/tests/specify_cli/bundles/test_command_list.py b/tests/specify_cli/bundles/test_command_list.py new file mode 100644 index 0000000000..8e2fe90a9f --- /dev/null +++ b/tests/specify_cli/bundles/test_command_list.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 + +runner = CliRunner() + + +def _make_project(tmp_path: Path, name: str) -> Path: + project = tmp_path / name + (project / ".specify").mkdir(parents=True) + return project + + +def test_list_empty_project(project: Path): + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 0 + assert "No bundles installed" in result.output + + +def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 1 + assert "Spec Kit project" in result.output + + +def test_list_escapes_markup_in_records(project: Path): + """``bundle list`` renders record fields that are never charset-validated. + + ``InstalledBundleRecord.from_dict`` accepts any non-empty string for + ``bundle_id``/``version`` and any string for ``installed_at``, so a records + file that *loads cleanly* could still crash the command that displays it. + """ + (project / ".specify" / "bundle-records.json").write_text( + json.dumps( + { + "schema_version": "1.0", + "bundles": [ + { + "bundle_id": "demo[/red]id", + "version": "1.0.0[/bold]", + "installed_at": "2026-01-01T00:00:00Z[/dim]", + "contributed_components": [], + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["bundle", "list"]) + + assert result.exit_code == 0, repr(result.exception) + output = strip_ansi(result.output) + assert "demo[/red]id" in output + assert "1.0.0[/bold]" in output + assert "2026-01-01T00:00:00Z[/dim]" in output + + +def test_override_redirects_bundle_commands(tmp_path, monkeypatch): + web = _make_project(tmp_path, "web") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 0, result.output + assert "No bundles installed" in result.output + + +def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch): + """Bundle commands also honor the strict override contract.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + assert "No bundles installed" not in result.output + + +def test_override_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch): + """Invalid override errors must not contaminate JSON stdout.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["bundle", "list", "--json"]) + assert result.exit_code != 0 + assert result.stdout == "" + assert "does not point to an existing directory" in result.stderr diff --git a/tests/specify_cli/bundles/test_command_remove.py b/tests/specify_cli/bundles/test_command_remove.py new file mode 100644 index 0000000000..262d57ade8 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_remove.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import pytest +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + valid_manifest_dict, +) + +runner = CliRunner() + + +def test_remove_reports_clean_error_when_primitive_raises_raw_exception( + project: Path, +): + """A raw exception from a primitive installer (e.g. an OSError from an + unreadable workflow registry surfacing through _WorkflowKindManager's + fail-closed construction) must not propagate uncaught through + `specify bundle remove` -- the command only catches BundlerError, so + without a conversion at the remove_bundle boundary this would exit + with an unhandled exception and empty/raw output instead of a clean, + actionable message, and no removal side effects should occur either.""" + from specify_cli.bundles.manifest import BundleManifest + from specify_cli.bundles.records import load_records + from specify_cli.bundles.adapters import DefaultPrimitiveInstaller + from specify_cli.bundles.installer import install_bundle + from specify_cli.bundles.resolver import resolve_install_plan + from tests.specify_cli.bundles.helpers import FakeInstaller + + manifest = BundleManifest.from_dict(valid_manifest_dict()) + plan = resolve_install_plan( + manifest, speckit_version="0.11.2", active_integration="copilot" + ) + install_bundle(project, plan, FakeInstaller(), manifest=manifest) + + def boom(self, project_root, component): + raise OSError("workflow registry unreadable") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom) + result = runner.invoke(app, ["bundle", "remove", "demo-bundle"]) + + assert result.exit_code != 0 + assert result.output.strip() != "" + assert result.exception is None or isinstance(result.exception, SystemExit) + assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"} diff --git a/tests/specify_cli/bundles/test_command_search.py b/tests/specify_cli/bundles/test_command_search.py new file mode 100644 index 0000000000..433d2158d6 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_search.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles._command_helpers import ( + MARKUP_SOURCE_ID, + configure_markup_catalog as _configure_markup_catalog, +) +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + write_catalog_file, +) + +runner = CliRunner() + + +def test_search_works_without_a_project(tmp_path: Path, monkeypatch): + # Discovery commands fall back to the built-in/user catalog stack and must + # not require a Spec Kit project (matches README/quickstart examples). + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) + assert result.exit_code == 0, result.output + assert result.output.strip().startswith("[") + + +def test_search_escapes_catalog_markup(project: Path): + entry = _configure_markup_catalog(project) + + result = runner.invoke(app, ["bundle", "search", "--offline"]) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for value in ( + entry["id"], + entry["name"], + entry["version"], + entry["role"], + entry["description"], + MARKUP_SOURCE_ID, + ): + assert value in output + + +def test_search_json_offline(project: Path): + catalog = project / "c.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "c", + "url": str(catalog), + "priority": 0, + "install_policy": "install-allowed", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + by_id = {entry["id"]: entry for entry in payload} + assert "demo" in by_id + # Trust indicator is exposed on the discovery surface (FR-010 / FR-027). + assert by_id["demo"]["verified"] is True + assert by_id["demo"]["trust"] == "verified" + + +def test_search_text_shows_trust(project: Path): + catalog = project / "c.json" + write_catalog_file( + catalog, + { + "verified-one": catalog_entry_dict("verified-one", verified=True), + "community-one": catalog_entry_dict("community-one", verified=False), + }, + ) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "c", + "url": str(catalog), + "priority": 1, + "install_policy": "install-allowed", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "search", "--offline"]) + assert result.exit_code == 0, result.output + assert "verified" in result.output + assert "community" in result.output diff --git a/tests/specify_cli/bundles/test_command_update.py b/tests/specify_cli/bundles/test_command_update.py new file mode 100644 index 0000000000..6452f5c73c --- /dev/null +++ b/tests/specify_cli/bundles/test_command_update.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + write_catalog_file, +) + +runner = CliRunner() + + +def test_update_accepts_integration_override(): + # Update must expose --integration so integration-pinned bundles can be + # updated in projects where the active integration can't be auto-detected. + # Rich may insert ANSI escapes between the two leading dashes, so match the + # un-split option word rather than the literal "--integration". + result = runner.invoke(app, ["bundle", "update", "--help"]) + assert result.exit_code == 0 + assert "integration" in result.output + + +def test_update_refuses_discovery_only_source(project: Path): + # An installed bundle whose only resolvable source is discovery-only must + # not be updatable from there (FR-025), mirroring the install policy gate. + from specify_cli.bundles.manifest import ComponentRef + from specify_cli.bundles.records import ( + InstalledBundleRecord, + save_records, + ) + + save_records( + project, + [ + InstalledBundleRecord.create( + "demo", + "1.0.0", + [ComponentRef(kind="extensions", id="ext-a", version=None)], + ) + ], + ) + + catalog = project / "disc.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "disc", + "url": str(catalog), + "priority": 1, + "install_policy": "discovery-only", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + + result = runner.invoke(app, ["bundle", "update", "demo", "--offline"]) + assert result.exit_code == 1 + assert "discovery-only" in result.output diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py new file mode 100644 index 0000000000..f0b22f7963 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + valid_manifest_dict, +) + +runner = CliRunner() + + +def test_validate_reports_invalid_manifest(project: Path): + data = valid_manifest_dict() + del data["bundle"]["license"] + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 1 + assert "license" in result.output + + +def test_validate_accepts_valid_manifest(project: Path): + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + # Offline mode does not fail on references it cannot verify (synthetic ids + # here); they surface as warnings while structure is confirmed valid. + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + assert result.exit_code == 0, result.output + assert "valid" in result.output + + +def test_validate_escapes_manifest_markup_in_errors(project: Path): + data = valid_manifest_dict() + # An invalid constraint is echoed back inside the validation error. + data["requires"] = {"speckit_version": ">=1.0[/bold]"} + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert ">=1.0[/bold]" in strip_ansi(result.output) + + +def test_validate_escapes_manifest_markup_in_warnings(project: Path): + data = valid_manifest_dict() + # Step ids are not charset-validated, and the unresolved-reference warning + # echoes them -- so an otherwise *valid* manifest crashed just as readily as + # an invalid one, on the success path. + data["provides"]["steps"] = [{"id": "step[/bold]a"}] + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + + assert result.exit_code == 0, repr(result.exception) + assert "step[/bold]a" in strip_ansi(result.output) + + +def test_validate_rejects_broken_reference(project: Path): + # Synthetic component ids resolve to nothing in any catalog → hard failure. + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 1 + assert "preset-a" in result.output or "ext-a" in result.output + + +def test_validate_accepts_bundled_reference(project: Path): + data = valid_manifest_dict() + data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]} + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 0, result.output + assert "valid" in result.output diff --git a/tests/specify_cli/bundles/test_commands.py b/tests/specify_cli/bundles/test_commands.py new file mode 100644 index 0000000000..e0d054d219 --- /dev/null +++ b/tests/specify_cli/bundles/test_commands.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import pytest +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 + +runner = CliRunner() + + +def test_bundle_help_lists_all_commands(): + result = runner.invoke(app, ["bundle", "--help"]) + assert result.exit_code == 0 + for cmd in ( + "search", + "info", + "list", + "install", + "add", + "update", + "remove", + "validate", + "build", + "init", + "catalog", + ): + assert cmd in result.output + + +def test_fail_writes_error_to_stderr_not_stdout(capsys): + """_fail must write to stderr, not stdout: every bundle command routes errors + through it, and under --json the error would otherwise corrupt the JSON payload + that consumers read from stdout.""" + import typer + + from specify_cli.bundles._commands import _fail + + with pytest.raises(typer.Exit): + _fail("something broke") + captured = capsys.readouterr() + assert "something broke" in captured.err + assert "something broke" not in captured.out + + +@pytest.mark.parametrize( + "argv, expected", + [ + ( + ["bundle", "catalog", "add", "ssh://ex[/red]ample.com/c.json"], + "ssh://ex[/red]ample.com/c.json", + ), + (["bundle", "catalog", "remove", "no[/red]such"], "no[/red]such"), + (["bundle", "update", "no[/red]such"], "no[/red]such"), + (["bundle", "remove", "no[/red]such"], "no[/red]such"), + ], +) +def test_error_paths_escape_rich_markup(project: Path, argv: list, expected: str): + result = runner.invoke(app, argv) + + assert result.exit_code == 1 + # A MarkupError would surface here as an exception rather than a clean exit. + assert isinstance(result.exception, SystemExit) + assert expected in strip_ansi(result.output) diff --git a/tests/unit/test_bundler_conflict.py b/tests/specify_cli/bundles/test_conflict.py similarity index 87% rename from tests/unit/test_bundler_conflict.py rename to tests/specify_cli/bundles/test_conflict.py index 5dbcb3dba1..ca92f0e825 100644 --- a/tests/unit/test_bundler_conflict.py +++ b/tests/specify_cli/bundles/test_conflict.py @@ -1,10 +1,10 @@ """Unit tests for conflict detection (T034): integration clash and overlap precedence.""" from __future__ import annotations -from specify_cli.bundler.models.manifest import BundleManifest, ComponentRef -from specify_cli.bundler.models.records import InstalledBundleRecord -from specify_cli.bundler.services.conflict import detect_conflicts -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest, ComponentRef +from specify_cli.bundles.records import InstalledBundleRecord +from specify_cli.bundles.conflict import detect_conflicts +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _manifest(**overrides) -> BundleManifest: diff --git a/tests/integration/test_bundler_install_flow.py b/tests/specify_cli/bundles/test_installer.py similarity index 97% rename from tests/integration/test_bundler_install_flow.py rename to tests/specify_cli/bundles/test_installer.py index 8b149c9f49..f004b2a0c2 100644 --- a/tests/integration/test_bundler_install_flow.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -10,11 +10,11 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import BundleManifest -from specify_cli.bundler.models.records import load_records, records_path -from specify_cli.bundler.services.installer import install_bundle, remove_bundle -from specify_cli.bundler.services.resolver import resolve_install_plan -from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles.records import load_records, records_path +from specify_cli.bundles.installer import install_bundle, remove_bundle +from specify_cli.bundles.resolver import resolve_install_plan +from tests.specify_cli.bundles.helpers import FakeInstaller, make_project, valid_manifest_dict def _plan(manifest): @@ -363,7 +363,7 @@ def fail_dump(_data, handle, *_args, **_kwargs): with pytest.MonkeyPatch.context() as mp: mp.setattr( - "specify_cli.bundler.lib.yamlio.json.dump", + "specify_cli.bundles.yamlio.json.dump", fail_dump, ) with pytest.raises(BundlerError) as exc_info: @@ -391,7 +391,7 @@ def fail_save(*_args, **_kwargs): with pytest.MonkeyPatch.context() as mp: mp.setattr( - "specify_cli.bundler.services.installer.save_records", + "specify_cli.bundles.installer.save_records", fail_save, ) with pytest.raises(BundlerError) as exc_info: @@ -597,8 +597,8 @@ def test_install_result_changed_reports_uninstalled(): # A `bundle update` that only DROPS components (new manifest reduces # provides) populates uninstalled with nothing installed/refreshed; that is # still a mutating change, so `changed` must be True — not a false no-op. - from specify_cli.bundler.services.installer import InstallResult - from specify_cli.bundler.models.manifest import ComponentRef + from specify_cli.bundles.installer import InstallResult + from specify_cli.bundles.manifest import ComponentRef result = InstallResult(bundle_id="x") assert result.changed is False # empty == no change diff --git a/tests/integration/test_bundler_offline.py b/tests/specify_cli/bundles/test_offline.py similarity index 91% rename from tests/integration/test_bundler_offline.py rename to tests/specify_cli/bundles/test_offline.py index f85e58e2cf..8acb513fc5 100644 --- a/tests/integration/test_bundler_offline.py +++ b/tests/specify_cli/bundles/test_offline.py @@ -11,10 +11,10 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope -from specify_cli.bundler.services.adapters import make_catalog_fetcher -from specify_cli.bundler.services.catalog_stack import CatalogStack -from tests.bundler_helpers import catalog_entry_dict, write_catalog_file +from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy, Scope +from specify_cli.bundles.adapters import make_catalog_fetcher +from specify_cli.bundles.catalog_stack import CatalogStack +from tests.specify_cli.bundles.helpers import catalog_entry_dict, write_catalog_file def _src(source_id, url, priority=1, policy="install-allowed"): @@ -51,16 +51,16 @@ def test_builtin_default_catalog_resolves_first_party_bundles_offline(): def test_builtin_catalog_failure_does_not_block_lower_priority_source( monkeypatch, source_id, builtin_id, builtin_priority, project_priority ): - from specify_cli.bundler.services import adapters + from specify_cli.bundles import adapters def fail_http_get_json(source_id, url): raise adapters._CatalogUnavailable("repository unavailable") monkeypatch.setattr( - "specify_cli.bundler.services.adapters._http_get_json", fail_http_get_json + "specify_cli.bundles.adapters._http_get_json", fail_http_get_json ) monkeypatch.setattr( - "specify_cli.bundler.services.adapters._load_packaged_catalog", + "specify_cli.bundles.adapters._load_packaged_catalog", lambda filename: {"schema_version": "1.0", "bundles": {}}, ) diff --git a/tests/unit/test_bundler_packager.py b/tests/specify_cli/bundles/test_packager.py similarity index 98% rename from tests/unit/test_bundler_packager.py rename to tests/specify_cli/bundles/test_packager.py index d203f7ffb0..0044e37830 100644 --- a/tests/unit/test_bundler_packager.py +++ b/tests/specify_cli/bundles/test_packager.py @@ -9,8 +9,8 @@ import yaml from specify_cli.bundler import BundlerError -from specify_cli.bundler.services.packager import build_bundle -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.packager import build_bundle +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _make_bundle(directory: Path, *, extra_files: dict | None = None) -> Path: diff --git a/tests/unit/test_bundler_primitives.py b/tests/specify_cli/bundles/test_primitives.py similarity index 97% rename from tests/unit/test_bundler_primitives.py rename to tests/specify_cli/bundles/test_primitives.py index 21666cf1a7..a3b4d83f45 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/specify_cli/bundles/test_primitives.py @@ -12,16 +12,16 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import ComponentRef -from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller -from specify_cli.bundler.services.primitives import ( +from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles.adapters import DefaultPrimitiveInstaller +from specify_cli.bundles.primitives import ( _ExtensionKindManager, _PresetKindManager, _StepKindManager, _WorkflowKindManager, primitive_manager, ) -from tests.bundler_helpers import valid_manifest_dict +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _component(kind: str, cid: str = "x") -> ComponentRef: @@ -109,7 +109,7 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): def test_assert_pinned_version_matches_passes(): - from specify_cli.bundler.services.primitives import _assert_pinned_version + from specify_cli.bundles.primitives import _assert_pinned_version # Equal (including v-prefix/normalization) is accepted; no version pins are no-ops. _assert_pinned_version("Preset", "p", "2.0.0", "2.0.0") @@ -119,7 +119,7 @@ def test_assert_pinned_version_matches_passes(): def test_assert_pinned_version_mismatch_raises(): - from specify_cli.bundler.services.primitives import _assert_pinned_version + from specify_cli.bundles.primitives import _assert_pinned_version with pytest.raises(BundlerError, match="pinned to version 2.0.0"): _assert_pinned_version("Preset", "preset-a", "2.0.0", "3.1.0") @@ -483,8 +483,8 @@ def _fake_install(self, *a, **k): def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch): """Regression: bundle update (refresh=True) of an already-installed extension must succeed and pass force=True to install_from_directory.""" - from specify_cli.bundler.services.installer import install_bundle - from specify_cli.bundler.models.manifest import BundleManifest + from specify_cli.bundles.installer import install_bundle + from specify_cli.bundles.manifest import BundleManifest import specify_cli._assets as assets from specify_cli.extensions import ExtensionManager @@ -529,8 +529,8 @@ def _fake_install_from_directory(self, *a, **k): def _plan(manifest): - from specify_cli.bundler.services.installer import InstallPlan - from specify_cli.bundler.models.manifest import ComponentRef as CR + from specify_cli.bundles.installer import InstallPlan + from specify_cli.bundles.manifest import ComponentRef as CR components = [CR(kind=c.kind, id=c.id) for c in manifest.components] return InstallPlan( diff --git a/tests/unit/test_bundler_records.py b/tests/specify_cli/bundles/test_records.py similarity index 98% rename from tests/unit/test_bundler_records.py rename to tests/specify_cli/bundles/test_records.py index dc1da118a1..458b0d01f0 100644 --- a/tests/unit/test_bundler_records.py +++ b/tests/specify_cli/bundles/test_records.py @@ -7,8 +7,8 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import ComponentRef -from specify_cli.bundler.models.records import ( +from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles.records import ( InstalledBundleRecord, components_still_needed, load_records, diff --git a/tests/unit/test_bundler_references.py b/tests/specify_cli/bundles/test_references.py similarity index 95% rename from tests/unit/test_bundler_references.py rename to tests/specify_cli/bundles/test_references.py index b910a93e99..a020d64a9d 100644 --- a/tests/unit/test_bundler_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -7,9 +7,9 @@ from pathlib import Path -from specify_cli.bundler.models.manifest import ComponentRef -from specify_cli.bundler.services.references import make_reference_checker -from tests.bundler_helpers import make_project +from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles.references import make_reference_checker +from tests.specify_cli.bundles.helpers import make_project def _ref(kind: str, id_: str) -> ComponentRef: diff --git a/tests/unit/test_bundler_resolver.py b/tests/specify_cli/bundles/test_resolver.py similarity index 95% rename from tests/unit/test_bundler_resolver.py rename to tests/specify_cli/bundles/test_resolver.py index 4045cc07a3..a8c88c4179 100644 --- a/tests/unit/test_bundler_resolver.py +++ b/tests/specify_cli/bundles/test_resolver.py @@ -4,9 +4,9 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import BundleManifest -from specify_cli.bundler.services.resolver import resolve_install_plan -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles.resolver import resolve_install_plan +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _manifest(**overrides) -> BundleManifest: diff --git a/tests/integration/test_bundler_security_paths.py b/tests/specify_cli/bundles/test_security_paths.py similarity index 90% rename from tests/integration/test_bundler_security_paths.py rename to tests/specify_cli/bundles/test_security_paths.py index e575dccb88..807650a5ce 100644 --- a/tests/integration/test_bundler_security_paths.py +++ b/tests/specify_cli/bundles/test_security_paths.py @@ -11,7 +11,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath +from specify_cli.bundles.yamlio import ensure_within, is_safe_relpath def test_ensure_within_allows_child(tmp_path: Path): @@ -67,8 +67,8 @@ def test_build_skips_symlinks(tmp_path: Path): """Packager must not follow symlinks out of the bundle dir.""" import yaml - from specify_cli.bundler.services.packager import build_bundle - from tests.bundler_helpers import valid_manifest_dict + from specify_cli.bundles.packager import build_bundle + from tests.specify_cli.bundles.helpers import valid_manifest_dict bundle = tmp_path / "bundle" bundle.mkdir() @@ -94,7 +94,7 @@ def test_build_skips_symlinks(tmp_path: Path): def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path): # Reading bundle-records.json must honour the same confinement as writes: # a symlinked .specify pointing outside project_root is refused. - from specify_cli.bundler.models.records import load_records + from specify_cli.bundles.records import load_records project = tmp_path / "proj" project.mkdir() @@ -112,7 +112,7 @@ def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path): def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path): # Reading the integration marker must not follow a .specify symlink that # resolves outside project_root; an escape is treated as "not determinable". - from specify_cli.bundler.lib.project import active_integration + from specify_cli.bundles.project import active_integration project = tmp_path / "proj" project.mkdir() @@ -145,7 +145,7 @@ def test_active_integration_reads_default_integration(tmp_path: Path): reader (``integration_state`` line 199: ``state.get("default_integration") or state.get("integration")``). """ - from specify_cli.bundler.lib.project import active_integration + from specify_cli.bundles.project import active_integration project = _write_marker(tmp_path, '{"default_integration": "copilot"}') assert active_integration(project) == "copilot" @@ -154,7 +154,7 @@ def test_active_integration_reads_default_integration(tmp_path: Path): def test_active_integration_prefers_default_over_legacy_alias(tmp_path: Path): """When both are present the authoritative field wins, matching ``integration_state``'s own ordering.""" - from specify_cli.bundler.lib.project import active_integration + from specify_cli.bundles.project import active_integration project = _write_marker( tmp_path, '{"integration": "stale", "default_integration": "copilot"}' @@ -164,14 +164,14 @@ def test_active_integration_prefers_default_over_legacy_alias(tmp_path: Path): def test_active_integration_still_reads_legacy_alias(tmp_path: Path): """Projects initialised by older versions carry only ``integration``.""" - from specify_cli.bundler.lib.project import active_integration + from specify_cli.bundles.project import active_integration project = _write_marker(tmp_path, '{"integration": "copilot"}') assert active_integration(project) == "copilot" def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path): - from specify_cli.bundler.commands_impl import catalog_config as cc + from specify_cli.bundles import catalog_config as cc project = tmp_path / "proj" project.mkdir() @@ -187,7 +187,7 @@ def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path): def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path): - from specify_cli.bundler.models.catalog import load_source_stack + from specify_cli.bundles.catalogs import load_source_stack project = tmp_path / "project" project.mkdir() @@ -203,7 +203,7 @@ def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path): def test_find_project_root_ignores_symlinked_specify(tmp_path: Path): - from specify_cli.bundler.lib.project import find_project_root + from specify_cli.bundles.project import find_project_root real = tmp_path / "real-specify" real.mkdir() @@ -221,7 +221,7 @@ def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, """The SPECIFY_INIT_DIR override path refuses a symlinked .specify too, matching the cwd loop path (regression: the override returned early and skipped the symlink guard).""" - from specify_cli.bundler.lib.project import find_project_root + from specify_cli.bundles.project import find_project_root real = tmp_path / "real-specify" real.mkdir() diff --git a/tests/specify_cli/bundles/test_sources.py b/tests/specify_cli/bundles/test_sources.py new file mode 100644 index 0000000000..10b10aa76c --- /dev/null +++ b/tests/specify_cli/bundles/test_sources.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import hashlib +import io +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles import BundlerError +from specify_cli.bundles import sources as bundle_sources +from specify_cli.bundles.catalogs import CatalogEntry +from specify_cli.bundles.sources import ( + _download_manifest, + _local_manifest_source, + _require_https, +) +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + valid_manifest_dict, + write_manifest, +) + +runner = CliRunner() + +_MALFORMED_URLS = [ + "https://[::1", # unclosed IPv6 bracket + "https://[not-an-ip]/bundle.yml", + "https://example.com:notaport/bundle.yml", + "https://example.com:70000/bundle.yml", +] + + +class _Response(io.BytesIO): + def __init__(self, body: bytes, url: str) -> None: + super().__init__(body) + self._url = url + + def geturl(self) -> str: + return self._url + + +def _resolved_entry(**overrides) -> SimpleNamespace: + entry = CatalogEntry.from_dict( + catalog_entry_dict( + "demo-bundle", + download_url="https://example.com/demo-bundle.yml", + **overrides, + ) + ) + return SimpleNamespace(entry=entry) + + +def _patch_download(monkeypatch, body: bytes) -> None: + def fake_open_url( + url, + timeout=10, + extra_headers=None, + redirect_validator=None, + ): + return _Response(body, url) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + +def test_local_source_none_for_non_path(): + assert _local_manifest_source("some-catalog-bundle-id") is None + + +def test_local_source_from_directory(tmp_path: Path): + write_manifest(tmp_path, valid_manifest_dict()) + manifest = _local_manifest_source(str(tmp_path)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_from_bundle_yml(tmp_path: Path): + path = write_manifest(tmp_path, valid_manifest_dict()) + manifest = _local_manifest_source(str(path)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_from_zip_artifact(tmp_path: Path): + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + write_manifest(bundle_dir, valid_manifest_dict()) + (bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)]) + assert result.exit_code == 0, result.output + artifact = next(bundle_dir.glob("*.zip")) + + manifest = _local_manifest_source(str(artifact)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_rejects_unknown_file(tmp_path: Path): + weird = tmp_path / "thing.txt" + weird.write_text("nope", encoding="utf-8") + with pytest.raises(BundlerError, match="not a recognised bundle source"): + _local_manifest_source(str(weird)) + + +def test_local_source_zip_non_utf8_manifest_raises_bundler_error(tmp_path: Path): + """Undecodable bundle.yml bytes inside a .zip must raise BundlerError. + + The manifest bytes are decoded as UTF-8 explicitly, matching + ``yamlio.load_yaml``'s "Could not read ..." contract, instead of + escaping as a raw ``UnicodeDecodeError``/``ReaderError`` traceback. + """ + artifact = tmp_path / "demo.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", b"\xff\xfe bundle \xc3\x28\n") + + with pytest.raises(BundlerError, match="Could not read"): + _local_manifest_source(str(artifact)) + + +def test_local_source_zip_utf16_manifest_rejected_like_directory(tmp_path: Path): + """A well-formed UTF-16 manifest must fail the same way in a .zip. + + ``yamlio.load_yaml`` decodes strictly as UTF-8, so a UTF-16 bundle.yml + (the realistic PowerShell ``Out-File`` output) is rejected when read + from a directory. Feeding the zip bytes straight to PyYAML would let + its Reader honour the UTF-16 BOM and *accept* the same manifest, + making zip and directory sources diverge. + """ + artifact = tmp_path / "demo.zip" + manifest_text = "bundle:\n id: demo-bundle\n version: 1.0.0\n" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", manifest_text.encode("utf-16")) + + with pytest.raises(BundlerError, match="Could not read"): + _local_manifest_source(str(artifact)) + + +def test_download_manifest_rejects_file_url(tmp_path: Path): + """A catalog ``file://`` download_url is rejected — catalog URLs are + HTTPS-only, matching extensions/presets/workflows. Disk installs go through + the positional path (see the local-source tests above), not download_url. + """ + from types import SimpleNamespace + + from specify_cli.bundles.sources import _download_manifest + + manifest_path = write_manifest(tmp_path / "my bundles") + resolved = SimpleNamespace( + entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri()) + ) + + with pytest.raises(BundlerError, match="bundle install"): + _download_manifest(resolved, offline=True) + + +def test_download_manifest_rejects_bare_path(tmp_path: Path): + """A bare filesystem path download_url is likewise rejected.""" + from types import SimpleNamespace + + from specify_cli.bundles.sources import _download_manifest + + manifest_path = write_manifest(tmp_path / "plain") + resolved = SimpleNamespace( + entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path)) + ) + + with pytest.raises(BundlerError, match="bundle install"): + _download_manifest(resolved, offline=True) + + +def test_local_install_still_resolves_via_positional_path(tmp_path: Path): + """The supported local route — a positional path, not a download_url — + still resolves the manifest via _local_manifest_source.""" + manifest_path = write_manifest(tmp_path / "my bundles") + manifest = _local_manifest_source(str(manifest_path)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path): + """A non-HTTPS download_url must report the HTTPS problem, not a misleading + 'Network access disabled', even under --offline (scheme is validated before + the offline gate).""" + from types import SimpleNamespace + + from specify_cli.bundles.sources import _download_manifest + + resolved = SimpleNamespace( + entry=SimpleNamespace( + id="demo-bundle", download_url="http://example.com/bundle.zip" + ) + ) + with pytest.raises(BundlerError, match="HTTPS"): + _download_manifest(resolved, offline=True) + + +def test_local_zip_uses_bounded_archive_open(tmp_path: Path): + artifact = tmp_path / "too-many-entries.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) + for index in range(512): + archive.writestr(f"assets/{index}.txt", "") + + with pytest.raises(BundlerError, match="too many entries"): + _local_manifest_source(str(artifact)) + + +def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path): + """A malformed bundle.yml inside a .zip must raise BundlerError. + + The zip branch parses YAML inline rather than through load_yaml(), so the + raw yaml.YAMLError used to escape. It is neither a ValueError nor an + OSError, so nothing upstream caught it. + """ + artifact = tmp_path / "bad-manifest.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n") + + with pytest.raises(BundlerError, match="Invalid YAML"): + _local_manifest_source(str(artifact)) + + +@pytest.mark.parametrize("url", _MALFORMED_URLS) +def test_download_manifest_rejects_malformed_url_cleanly(url): + """A malformed download_url must raise BundlerError, not a raw ValueError. + + ``urlparse`` raises ``ValueError`` on a malformed authority (e.g. an + unclosed IPv6 bracket). The bundle CLI commands only catch BundlerError, so + a raw ValueError would escape as an uncaught traceback. Sibling of the + guarded ``_validate_remote_url`` (adapters) and the merged #3576 fix. + """ + resolved = SimpleNamespace(entry=SimpleNamespace(id="mybundle", download_url=url)) + with pytest.raises(BundlerError): + _download_manifest(resolved, offline=True) + + +@pytest.mark.parametrize("url", _MALFORMED_URLS) +def test_require_https_rejects_malformed_url_cleanly(url): + """``_require_https`` must also surface BundlerError on a malformed authority. + + On older Python versions the ValueError is raised at ``.hostname`` access + rather than at ``urlparse``, so guarding both keeps the contract across the + CI Python matrix. + """ + with pytest.raises(BundlerError): + _require_https("bundle 'x'", url) + + +def test_download_manifest_bounds_remote_artifact(monkeypatch): + body = yaml.safe_dump(valid_manifest_dict()).encode() + _patch_download(monkeypatch, body) + monkeypatch.setattr(bundle_sources, "MAX_DOWNLOAD_BYTES", len(body) - 1) + + with pytest.raises(BundlerError, match="exceeds maximum size"): + _download_manifest(_resolved_entry(), offline=False) + + +def test_download_manifest_accepts_matching_sha256(monkeypatch): + body = yaml.safe_dump(valid_manifest_dict()).encode() + digest = hashlib.sha256(body).hexdigest() + _patch_download(monkeypatch, body) + + manifest = _download_manifest( + _resolved_entry(sha256=f"sha256:{digest}"), + offline=False, + ) + + assert manifest.bundle.id == "demo-bundle" + + +def test_download_manifest_accepts_legacy_entry_without_sha256(monkeypatch): + body = yaml.safe_dump(valid_manifest_dict()).encode() + _patch_download(monkeypatch, body) + resolved = SimpleNamespace( + entry=SimpleNamespace( + id="demo-bundle", + version="1.2.0", + download_url="https://example.com/demo-bundle.yml", + ) + ) + + manifest = _download_manifest(resolved, offline=False) + + assert manifest.bundle.version == "1.2.0" + + +@pytest.mark.parametrize("declared", ["0" * 64, "not-a-sha256"]) +def test_download_manifest_rejects_bad_sha256(monkeypatch, declared): + body = yaml.safe_dump(valid_manifest_dict()).encode() + _patch_download(monkeypatch, body) + + with pytest.raises(BundlerError, match="sha256|Integrity check"): + _download_manifest( + _resolved_entry(sha256=declared), + offline=False, + ) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("id", "other-bundle", "id mismatch"), + ("version", "9.9.9", "version mismatch"), + ], +) +def test_download_manifest_rejects_catalog_identity_mismatch( + monkeypatch, + field, + value, + message, +): + data = valid_manifest_dict() + data["bundle"][field] = value + _patch_download(monkeypatch, yaml.safe_dump(data).encode()) + + with pytest.raises(BundlerError, match=message): + _download_manifest(_resolved_entry(), offline=False) + + +def test_download_manifest_rejects_invalid_structure(monkeypatch): + data = valid_manifest_dict() + data["bundle"]["author"] = "" + _patch_download(monkeypatch, yaml.safe_dump(data).encode()) + + with pytest.raises(BundlerError, match="invalid bundle manifest"): + _download_manifest(_resolved_entry(), offline=False) diff --git a/tests/unit/test_bundler_validator.py b/tests/specify_cli/bundles/test_validator.py similarity index 79% rename from tests/unit/test_bundler_validator.py rename to tests/specify_cli/bundles/test_validator.py index d69c6535e5..855fb80cc1 100644 --- a/tests/unit/test_bundler_validator.py +++ b/tests/specify_cli/bundles/test_validator.py @@ -3,10 +3,10 @@ import pytest -from specify_cli.bundler.models.manifest import BundleManifest -from specify_cli.bundler.services import validator as validator_mod -from specify_cli.bundler.services.validator import validate_manifest -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles import validator as validator_mod +from specify_cli.bundles.validator import validate_manifest +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _manifest(**overrides) -> BundleManifest: diff --git a/tests/unit/test_bundler_versioning.py b/tests/specify_cli/bundles/test_versioning.py similarity index 93% rename from tests/unit/test_bundler_versioning.py rename to tests/specify_cli/bundles/test_versioning.py index 15c42ea673..aa6dfc1494 100644 --- a/tests/unit/test_bundler_versioning.py +++ b/tests/specify_cli/bundles/test_versioning.py @@ -4,7 +4,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.lib.versioning import is_semver, satisfies +from specify_cli.bundles.versioning import is_semver, satisfies @pytest.mark.parametrize("value,expected", [ @@ -63,6 +63,6 @@ def test_satisfies_prerelease_in_constraint(installed, constraint, ok): def test_parse_constraint_empty_is_permissive(): - from specify_cli.bundler.lib.versioning import parse_constraint + from specify_cli.bundles.versioning import parse_constraint assert str(parse_constraint("")) == "" diff --git a/tests/unit/test_bundler_yamlio.py b/tests/specify_cli/bundles/test_yamlio.py similarity index 96% rename from tests/unit/test_bundler_yamlio.py rename to tests/specify_cli/bundles/test_yamlio.py index b3e8e592e4..23f78546c9 100644 --- a/tests/unit/test_bundler_yamlio.py +++ b/tests/specify_cli/bundles/test_yamlio.py @@ -6,7 +6,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.lib.yamlio import dump_yaml, load_json, load_yaml +from specify_cli.bundles.yamlio import dump_yaml, load_json, load_yaml def test_dump_yaml_preserves_unicode(tmp_path: Path): diff --git a/tests/test_init_dir_cli.py b/tests/test_init_dir_cli.py index 6f3cd570c6..beb38587ec 100644 --- a/tests/test_init_dir_cli.py +++ b/tests/test_init_dir_cli.py @@ -91,16 +91,6 @@ def test_override_trailing_slash_tolerated(tmp_path, monkeypatch): assert "No workflows installed" in result.output -def test_override_redirects_bundle_commands(tmp_path, monkeypatch): - web = _make_project(tmp_path, "web") - elsewhere = tmp_path / "elsewhere" - elsewhere.mkdir() - monkeypatch.chdir(elsewhere) - monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) - - result = runner.invoke(app, ["bundle", "list"]) - assert result.exit_code == 0, result.output - assert "No bundles installed" in result.output def test_unset_override_uses_cwd(tmp_path, monkeypatch): @@ -138,50 +128,10 @@ def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch): assert "No workflows installed" not in result.output # no fallback to cwd -def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch): - """Bundle commands also honor the strict override contract.""" - cwd_proj = _make_project(tmp_path, "cwd") - monkeypatch.chdir(cwd_proj) - monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) - - result = runner.invoke(app, ["bundle", "list"]) - assert result.exit_code != 0 - assert "does not point to an existing directory" in result.output - assert "No bundles installed" not in result.output - - -def test_override_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch): - """Invalid override errors must not contaminate JSON stdout.""" - cwd_proj = _make_project(tmp_path, "cwd") - monkeypatch.chdir(cwd_proj) - monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) - - result = runner.invoke(app, ["bundle", "list", "--json"]) - assert result.exit_code != 0 - assert result.stdout == "" - assert "does not point to an existing directory" in result.stderr -def test_override_symlinked_specify_errors_bundle_init_no_fallback(tmp_path, monkeypatch): - """A symlinked override .specify must not make bundle init fall back to cwd.""" - web = tmp_path / "web" - web.mkdir() - real = tmp_path / "real-specify" - real.mkdir() - try: - (web / ".specify").symlink_to(real, target_is_directory=True) - except (OSError, NotImplementedError): - pytest.skip("Symlinks are not available in this environment") - elsewhere = tmp_path / "elsewhere" - elsewhere.mkdir() - monkeypatch.chdir(elsewhere) - monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) - result = runner.invoke(app, ["bundle", "init", "--offline"]) - assert result.exit_code != 0 - assert "symlinked .specify" in result.output - assert not (elsewhere / ".specify").exists() def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch): diff --git a/tests/unit/test_bundle_download_url.py b/tests/unit/test_bundle_download_url.py deleted file mode 100644 index 6a29423b9d..0000000000 --- a/tests/unit/test_bundle_download_url.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Unit tests for malformed download-URL handling in bundle manifest resolution.""" -from __future__ import annotations - -import hashlib -import io -from types import SimpleNamespace - -import pytest -import yaml - -from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.catalog import CatalogEntry -from specify_cli.commands import bundle as bundle_commands -from specify_cli.commands.bundle import _download_manifest, _require_https -from tests.bundler_helpers import catalog_entry_dict, valid_manifest_dict - -_MALFORMED_URLS = [ - "https://[::1", # unclosed IPv6 bracket - "https://[not-an-ip]/bundle.yml", - "https://example.com:notaport/bundle.yml", - "https://example.com:70000/bundle.yml", -] - - -class _Response(io.BytesIO): - def __init__(self, body: bytes, url: str) -> None: - super().__init__(body) - self._url = url - - def geturl(self) -> str: - return self._url - - -def _resolved_entry(**overrides) -> SimpleNamespace: - entry = CatalogEntry.from_dict( - catalog_entry_dict( - "demo-bundle", - download_url="https://example.com/demo-bundle.yml", - **overrides, - ) - ) - return SimpleNamespace(entry=entry) - - -def _patch_download(monkeypatch, body: bytes) -> None: - def fake_open_url( - url, - timeout=10, - extra_headers=None, - redirect_validator=None, - ): - return _Response(body, url) - - monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) - - -@pytest.mark.parametrize("url", _MALFORMED_URLS) -def test_download_manifest_rejects_malformed_url_cleanly(url): - """A malformed download_url must raise BundlerError, not a raw ValueError. - - ``urlparse`` raises ``ValueError`` on a malformed authority (e.g. an - unclosed IPv6 bracket). The bundle CLI commands only catch BundlerError, so - a raw ValueError would escape as an uncaught traceback. Sibling of the - guarded ``_validate_remote_url`` (adapters) and the merged #3576 fix. - """ - resolved = SimpleNamespace( - entry=SimpleNamespace(id="mybundle", download_url=url) - ) - with pytest.raises(BundlerError): - _download_manifest(resolved, offline=True) - - -@pytest.mark.parametrize("url", _MALFORMED_URLS) -def test_require_https_rejects_malformed_url_cleanly(url): - """``_require_https`` must also surface BundlerError on a malformed authority. - - On older Python versions the ValueError is raised at ``.hostname`` access - rather than at ``urlparse``, so guarding both keeps the contract across the - CI Python matrix. - """ - with pytest.raises(BundlerError): - _require_https("bundle 'x'", url) - - -def test_download_manifest_bounds_remote_artifact(monkeypatch): - body = yaml.safe_dump(valid_manifest_dict()).encode() - _patch_download(monkeypatch, body) - monkeypatch.setattr(bundle_commands, "MAX_DOWNLOAD_BYTES", len(body) - 1) - - with pytest.raises(BundlerError, match="exceeds maximum size"): - _download_manifest(_resolved_entry(), offline=False) - - -def test_download_manifest_accepts_matching_sha256(monkeypatch): - body = yaml.safe_dump(valid_manifest_dict()).encode() - digest = hashlib.sha256(body).hexdigest() - _patch_download(monkeypatch, body) - - manifest = _download_manifest( - _resolved_entry(sha256=f"sha256:{digest}"), - offline=False, - ) - - assert manifest.bundle.id == "demo-bundle" - - -def test_download_manifest_accepts_legacy_entry_without_sha256(monkeypatch): - body = yaml.safe_dump(valid_manifest_dict()).encode() - _patch_download(monkeypatch, body) - resolved = SimpleNamespace( - entry=SimpleNamespace( - id="demo-bundle", - version="1.2.0", - download_url="https://example.com/demo-bundle.yml", - ) - ) - - manifest = _download_manifest(resolved, offline=False) - - assert manifest.bundle.version == "1.2.0" - - -@pytest.mark.parametrize("declared", ["0" * 64, "not-a-sha256"]) -def test_download_manifest_rejects_bad_sha256(monkeypatch, declared): - body = yaml.safe_dump(valid_manifest_dict()).encode() - _patch_download(monkeypatch, body) - - with pytest.raises(BundlerError, match="sha256|Integrity check"): - _download_manifest( - _resolved_entry(sha256=declared), - offline=False, - ) - - -@pytest.mark.parametrize( - ("field", "value", "message"), - [ - ("id", "other-bundle", "id mismatch"), - ("version", "9.9.9", "version mismatch"), - ], -) -def test_download_manifest_rejects_catalog_identity_mismatch( - monkeypatch, - field, - value, - message, -): - data = valid_manifest_dict() - data["bundle"][field] = value - _patch_download(monkeypatch, yaml.safe_dump(data).encode()) - - with pytest.raises(BundlerError, match=message): - _download_manifest(_resolved_entry(), offline=False) - - -def test_download_manifest_rejects_invalid_structure(monkeypatch): - data = valid_manifest_dict() - data["bundle"]["author"] = "" - _patch_download(monkeypatch, yaml.safe_dump(data).encode()) - - with pytest.raises(BundlerError, match="invalid bundle manifest"): - _download_manifest(_resolved_entry(), offline=False)