diff --git a/src/apm_cli/commands/marketplace.py b/src/apm_cli/commands/marketplace/__init__.py similarity index 50% rename from src/apm_cli/commands/marketplace.py rename to src/apm_cli/commands/marketplace/__init__.py index dcedbce97..a9a7f5130 100644 --- a/src/apm_cli/commands/marketplace.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -1,14 +1,15 @@ -"""APM marketplace command group. +"""Marketplace CLI package. -Manages marketplace discovery and governance. Follows the same -Click group pattern as ``mcp.py``. +This package keeps click group wiring, shared helpers, and compatibility +exports for the marketplace command surface. """ +from __future__ import annotations + import builtins import json import os import re -import subprocess import sys import traceback from pathlib import Path @@ -16,9 +17,9 @@ import click import yaml -from ..core.command_logger import CommandLogger -from ..marketplace.builder import BuildOptions, BuildReport, MarketplaceBuilder, ResolvedPackage -from ..marketplace.errors import ( +from ...core.command_logger import CommandLogger +from ...marketplace.builder import BuildOptions, BuildReport, MarketplaceBuilder, ResolvedPackage +from ...marketplace.errors import ( BuildError, GitLsRemoteError, HeadNotAllowedError, @@ -28,27 +29,30 @@ OfflineMissError, RefNotFoundError, ) -from ..marketplace.git_stderr import translate_git_stderr -from ..marketplace.pr_integration import PrIntegrator, PrResult, PrState -from ..marketplace.publisher import ( +from ...marketplace.git_stderr import translate_git_stderr +from ...marketplace.migration import ( + ConfigSource, + detect_config_source, + load_marketplace_config, + migrate_marketplace_yml, +) +from ...marketplace.pr_integration import PrIntegrator, PrResult, PrState +from ...marketplace.publisher import ( ConsumerTarget, MarketplacePublisher, PublishOutcome, PublishPlan, TargetResult, ) -from ..marketplace.ref_resolver import RefResolver, RemoteRef -from ..marketplace.semver import SemVer, parse_semver, satisfies_range -from ..marketplace.migration import ( - ConfigSource, - detect_config_source, - load_marketplace_config, - migrate_marketplace_yml, -) -from ..marketplace.yml_schema import load_marketplace_yml -from ..utils.path_security import PathTraversalError, validate_path_segments -from ..utils.console import _rich_info, _rich_warning -from ._helpers import _get_console, _is_interactive +from ...marketplace.ref_resolver import RefResolver, RemoteRef +from ...marketplace.semver import SemVer, parse_semver, satisfies_range +from ...marketplace.yml_schema import load_marketplace_yml +from ...utils.console import _rich_info, _rich_warning +from ...utils.path_security import PathTraversalError, validate_path_segments +from .._helpers import _get_console, _is_interactive + +# Restore builtins shadowed by subcommand names +list = builtins.list # Marketplace alias must satisfy this pattern so it can appear on the right of @@ -69,8 +73,23 @@ def _is_valid_alias(value: str) -> bool: class MarketplaceGroup(click.Group): """Custom group that organises commands by audience.""" - _consumer_commands = ["add", "list", "browse", "update", "remove", "validate"] - _authoring_commands = ["init", "check", "outdated", "doctor", "publish", "package"] + _consumer_commands = [ + "add", + "list", + "browse", + "update", + "remove", + "validate", + ] + _authoring_commands = [ + "init", + "check", + "outdated", + "doctor", + "publish", + "package", + "migrate", + ] def get_command(self, ctx, cmd_name): # The 'build' subcommand was removed in favour of the unified @@ -102,15 +121,6 @@ def format_commands(self, ctx, formatter): with formatter.section(section_name): formatter.write_dl(commands) -# Restore builtins shadowed by subcommand names -list = builtins.list - - -# --------------------------------------------------------------------------- -# Module-private helpers -# --------------------------------------------------------------------------- - - def _load_yml_or_exit(logger): """Load ``./marketplace.yml`` from CWD or exit with an appropriate code. @@ -131,11 +141,10 @@ def _load_yml_or_exit(logger): logger.error(f"marketplace.yml schema error: {exc}", symbol="error") sys.exit(2) - def _load_config_or_exit(logger): - """Load the marketplace config from CWD (apm.yml or legacy marketplace.yml). + """Load the marketplace config from CWD (apm.yml or marketplace.yml). - Returns ``(project_root, config)``. Exits with code 1 when no config + Returns ``(project_root, config)``. Exits with code 1 when no config is found or both files coexist; exits with code 2 on validation errors. Emits a deprecation warning when the legacy file is in use. """ @@ -157,7 +166,6 @@ def _load_config_or_exit(logger): sys.exit(2) return project_root, config - def _warn_duplicate_names(logger, yml): """Emit a warning for each duplicate package name in *yml*.""" seen: dict[str, int] = {} @@ -173,7 +181,6 @@ def _warn_duplicate_names(logger, yml): else: seen[lower] = idx - def _find_duplicate_names(yml): """Return a diagnostic string if *yml* contains duplicate package names.""" seen: dict[str, int] = {} @@ -196,134 +203,10 @@ def marketplace(ctx): """Register, browse, and search marketplaces.""" -from .marketplace_plugin import package # noqa: E402 - -marketplace.add_command(package) +from .plugin import package # noqa: E402 -# --------------------------------------------------------------------------- -# marketplace init -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Add a 'marketplace:' block to apm.yml (scaffolds apm.yml if missing)") -@click.option("--force", is_flag=True, help="Overwrite an existing 'marketplace:' block in apm.yml") -@click.option( - "--no-gitignore-check", - is_flag=True, - help="Skip the .gitignore staleness check", -) -@click.option("--name", default=None, help="Marketplace/package name (default: my-marketplace)") -@click.option("--owner", default=None, help="Owner name for the marketplace") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def init(force, no_gitignore_check, name, owner, verbose): - """Scaffold a 'marketplace:' block in apm.yml (creates apm.yml if absent).""" - from ..marketplace.init_template import render_marketplace_block - - logger = CommandLogger("marketplace-init", verbose=verbose) - cwd = Path.cwd() - apm_path = cwd / "apm.yml" - scaffolded_apm_yml = False - - # If apm.yml is missing, scaffold a minimal one with the marketplace - # block included. Per design: marketplace authoring is folded into - # apm.yml; no new marketplace.yml files are created. - if not apm_path.exists(): - scaffold_name = name or "my-marketplace" - scaffold_text = ( - f"name: {scaffold_name}\n" - f"version: 0.1.0\n" - f"description: A short description of what this repo offers\n" - ) - try: - apm_path.write_text(scaffold_text, encoding="utf-8") - except OSError as exc: - logger.error(f"Failed to write apm.yml: {exc}", symbol="error") - sys.exit(1) - scaffolded_apm_yml = True - if verbose: - logger.verbose_detail(f" Path: {apm_path}") - - # apm.yml now exists -- inject the 'marketplace:' block. - if True: - # Inject marketplace block into apm.yml. - try: - from ruamel.yaml import YAML - rt = YAML(typ="rt") - rt.preserve_quotes = True - rt.indent(mapping=2, sequence=4, offset=2) - existing_text = apm_path.read_text(encoding="utf-8") - data = rt.load(existing_text) - except Exception as exc: # noqa: BLE001 -- guard malformed apm.yml - logger.error(f"Failed to parse apm.yml: {exc}", symbol="error") - sys.exit(1) - - # An empty apm.yml round-trips to None; treat it as an empty - # mapping so the marketplace block can still be inserted. - # A non-mapping top level (list, scalar) is a hard error. - if data is None: - from ruamel.yaml.comments import CommentedMap - data = CommentedMap() - elif not isinstance(data, dict): - logger.error( - "apm.yml must be a YAML mapping at the top level " - f"(got {type(data).__name__}).", - symbol="error", - ) - sys.exit(1) - - if "marketplace" in data and data["marketplace"] is not None \ - and not force: - logger.warning( - "apm.yml already has a 'marketplace:' block. Use --force to overwrite.", - symbol="warning", - ) - sys.exit(1) - - # Render the block as a YAML snippet, parse it, and inject. - block_text = render_marketplace_block(owner=owner) - block_data = rt.load(block_text) - # block_data is a dict with one key, 'marketplace'. - data["marketplace"] = block_data["marketplace"] - - from io import StringIO - out = StringIO() - rt.dump(data, out) - try: - apm_path.write_text(out.getvalue(), encoding="utf-8") - except OSError as exc: - logger.error(f"Failed to write apm.yml: {exc}", symbol="error") - sys.exit(1) - - if scaffolded_apm_yml: - success_msg = "Created apm.yml with 'marketplace:' block" - else: - success_msg = "Added 'marketplace:' block to apm.yml" - logger.success(success_msg, symbol="check") - if verbose: - logger.verbose_detail(f" Path: {apm_path}") - - if not no_gitignore_check: - _check_gitignore_for_marketplace_json(logger) - - next_steps = [ - "Edit the 'marketplace:' block in apm.yml to add your packages", - "Run 'apm pack' to generate .claude-plugin/marketplace.json", - "Commit BOTH apm.yml and the generated marketplace.json", - ] - - try: - from ..utils.console import _rich_panel - - _rich_panel( - "\n".join(f" {i}. {step}" for i, step in enumerate(next_steps, 1)), - title=" Next Steps", - style="cyan", - ) - except (ImportError, NameError): - logger.progress("Next steps:") - for i, step in enumerate(next_steps, 1): - logger.tree_item(f" {i}. {step}") +marketplace.add_command(package) def _check_gitignore_for_marketplace_json(logger): @@ -352,12 +235,6 @@ def _check_gitignore_for_marketplace_json(logger): ) return - -# --------------------------------------------------------------------------- -# marketplace add -# --------------------------------------------------------------------------- - - @marketplace.command(help="Register a marketplace") @click.argument("repo", required=True) @click.option("--name", "-n", default=None, help="Display name (defaults to repo name)") @@ -368,9 +245,9 @@ def add(repo, name, branch, host, verbose): """Register a marketplace from OWNER/REPO or HOST/OWNER/REPO.""" logger = CommandLogger("marketplace-add", verbose=verbose) try: - from ..marketplace.client import _auto_detect_path, fetch_marketplace - from ..marketplace.models import MarketplaceSource - from ..marketplace.registry import add_marketplace + from ...marketplace.client import _auto_detect_path, fetch_marketplace + from ...marketplace.models import MarketplaceSource + from ...marketplace.registry import add_marketplace # Parse OWNER/REPO or HOST/OWNER/REPO if "/" not in repo: @@ -380,7 +257,7 @@ def add(repo, name, branch, host, verbose): ) sys.exit(1) - from ..utils.github_host import default_host, is_valid_fqdn + from ...utils.github_host import default_host, is_valid_fqdn parts = repo.split("/") if len(parts) == 3 and parts[0] and parts[1] and parts[2]: @@ -526,19 +403,13 @@ def add(repo, name, branch, host, verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace list -# --------------------------------------------------------------------------- - - @marketplace.command(name="list", help="List registered marketplaces") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") def list_cmd(verbose): """Show all registered marketplaces.""" logger = CommandLogger("marketplace-list", verbose=verbose) try: - from ..marketplace.registry import get_registered_marketplaces + from ...marketplace.registry import get_registered_marketplaces sources = get_registered_marketplaces() @@ -589,12 +460,6 @@ def list_cmd(verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace browse -# --------------------------------------------------------------------------- - - @marketplace.command(help="Browse plugins in a marketplace") @click.argument("name", required=True) @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") @@ -602,8 +467,8 @@ def browse(name, verbose): """Show available plugins in a marketplace.""" logger = CommandLogger("marketplace-browse", verbose=verbose) try: - from ..marketplace.client import fetch_marketplace - from ..marketplace.registry import get_marketplace_by_name + from ...marketplace.client import fetch_marketplace + from ...marketplace.registry import get_marketplace_by_name source = get_marketplace_by_name(name) logger.start(f"Fetching plugins from '{name}'...", symbol="search") @@ -659,12 +524,6 @@ def browse(name, verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace update -# --------------------------------------------------------------------------- - - @marketplace.command(help="Refresh marketplace cache") @click.argument("name", required=False, default=None) @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") @@ -672,8 +531,8 @@ def update(name, verbose): """Refresh cached marketplace data (one or all).""" logger = CommandLogger("marketplace-update", verbose=verbose) try: - from ..marketplace.client import clear_marketplace_cache, fetch_marketplace - from ..marketplace.registry import ( + from ...marketplace.client import clear_marketplace_cache, fetch_marketplace + from ...marketplace.registry import ( get_marketplace_by_name, get_registered_marketplaces, ) @@ -716,12 +575,6 @@ def update(name, verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace remove -# --------------------------------------------------------------------------- - - @marketplace.command(help="Remove a registered marketplace") @click.argument("name", required=True) @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") @@ -730,8 +583,8 @@ def remove(name, yes, verbose): """Unregister a marketplace.""" logger = CommandLogger("marketplace-remove", verbose=verbose) try: - from ..marketplace.client import clear_marketplace_cache - from ..marketplace.registry import get_marketplace_by_name, remove_marketplace + from ...marketplace.client import clear_marketplace_cache + from ...marketplace.registry import get_marketplace_by_name, remove_marketplace # Verify it exists first source = get_marketplace_by_name(name) @@ -761,251 +614,73 @@ def remove(name, yes, verbose): logger.progress(traceback.format_exc(), symbol="info") sys.exit(1) - -# --------------------------------------------------------------------------- -# marketplace validate -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Validate a marketplace manifest") -@click.argument("name", required=True) -@click.option( - "--check-refs", is_flag=True, hidden=True, help="Verify version refs are reachable (network)" -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def validate(name, check_refs, verbose): - """Validate the manifest of a registered marketplace.""" - logger = CommandLogger("marketplace-validate", verbose=verbose) - try: - from ..marketplace.client import fetch_marketplace - from ..marketplace.registry import get_marketplace_by_name - from ..marketplace.validator import validate_marketplace - - source = get_marketplace_by_name(name) - logger.start(f"Validating marketplace '{name}'...", symbol="gear") - - manifest = fetch_marketplace(source, force_refresh=True) - +def _render_build_error(logger, exc): + """Render a BuildError with actionable hints.""" + if isinstance(exc, GitLsRemoteError): + logger.error(exc.summary_text, symbol="error") + if exc.hint: + logger.progress(f"Hint: {exc.hint}", symbol="info") + elif isinstance(exc, NoMatchingVersionError): + logger.error(str(exc), symbol="error") logger.progress( - f"Found {len(manifest.plugins)} plugins", + "Check that your version range matches published tags.", symbol="info", ) - - # Verbose: per-plugin details - if verbose: - for p in manifest.plugins: - source_type = "dict" if isinstance(p.source, dict) else "string" - logger.verbose_detail( - f" {p.name}: source type: {source_type}" - ) - - # Run validation - results = validate_marketplace(manifest) - - # Check-refs placeholder - if check_refs: - logger.warning( - "Ref checking not yet implemented -- skipping ref " - "reachability checks", - symbol="warning", - ) - - # Render results - passed = 0 - warning_count = 0 - error_count = 0 - click.echo() - logger.progress("Validation Results:", symbol="info") - for r in results: - if r.passed and not r.warnings: - logger.success( - f" {r.check_name}: all plugins valid", symbol="check" - ) - passed += 1 - elif r.warnings and not r.errors: - for w in r.warnings: - logger.warning(f" {r.check_name}: {w}", symbol="warning") - warning_count += len(r.warnings) - else: - for e in r.errors: - logger.error(f" {r.check_name}: {e}", symbol="error") - for w in r.warnings: - logger.warning(f" {r.check_name}: {w}", symbol="warning") - error_count += len(r.errors) - warning_count += len(r.warnings) - - click.echo() + elif isinstance(exc, RefNotFoundError): + logger.error(str(exc), symbol="error") logger.progress( - f"Summary: {passed} passed, {warning_count} warnings, " - f"{error_count} errors", + "Verify the ref is spelled correctly and the remote is reachable.", symbol="info", ) + elif isinstance(exc, HeadNotAllowedError): + logger.error(str(exc), symbol="error") + elif isinstance(exc, OfflineMissError): + logger.error(str(exc), symbol="error") + logger.progress( + "Run a build online first to populate the cache.", + symbol="info", + ) + else: + logger.error(f"Build failed: {exc}", symbol="error") - if error_count > 0: - sys.exit(1) - - except Exception as e: # noqa: BLE001 -- top-level command catch-all - logger.error(f"Failed to validate marketplace: {e}") - logger.verbose_detail(traceback.format_exc()) - sys.exit(1) - - -# --------------------------------------------------------------------------- -# marketplace build -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# marketplace outdated -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Show packages with available upgrades") -@click.option("--offline", is_flag=True, help="Use cached refs only (no network)") -@click.option( - "--include-prerelease", is_flag=True, help="Include prerelease versions" -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def outdated(offline, include_prerelease, verbose): - """Compare installed versions against latest available tags.""" - logger = CommandLogger("marketplace-outdated", verbose=verbose) - - _, yml = _load_config_or_exit(logger) - - # Load current marketplace.json for "Current" column - current_versions = _load_current_versions() - - resolver = RefResolver(offline=offline) - try: - rows = [] - upgradable = 0 - up_to_date = 0 - for entry in yml.packages: - # Entries with explicit ref (no range) are skipped - if entry.ref is not None: - rows.append(_OutdatedRow( - name=entry.name, - current=current_versions.get(entry.name, "--"), - range_spec="--", - latest_in_range="--", - latest_overall="--", - status="[i]", - note="Pinned to ref; skipped", - )) - continue - - version_range = entry.version or "" - if not version_range: - rows.append(_OutdatedRow( - name=entry.name, - current=current_versions.get(entry.name, "--"), - range_spec="--", - latest_in_range="--", - latest_overall="--", - status="[i]", - note="No version range", - )) - continue - - try: - refs = resolver.list_remote_refs(entry.source) - except (BuildError, Exception) as exc: - rows.append(_OutdatedRow( - name=entry.name, - current=current_versions.get(entry.name, "--"), - range_spec=version_range, - latest_in_range="--", - latest_overall="--", - status="[x]", - note=str(exc)[:60], - )) - continue - - # Parse tags into semvers - tag_versions = _extract_tag_versions( - refs, entry, yml, include_prerelease - ) - - if not tag_versions: - rows.append(_OutdatedRow( - name=entry.name, - current=current_versions.get(entry.name, "--"), - range_spec=version_range, - latest_in_range="--", - latest_overall="--", - status="[!]", - note="No matching tags found", - )) - continue - - # Find highest in-range and highest overall - in_range = [ - (sv, tag) for sv, tag in tag_versions - if satisfies_range(sv, version_range) - ] - latest_overall_sv, latest_overall_tag = max( - tag_versions, key=lambda x: x[0] - ) - latest_in_range_tag = "--" - if in_range: - _, latest_in_range_tag = max(in_range, key=lambda x: x[0]) - - current = current_versions.get(entry.name, "--") - - # Determine status - if current == latest_in_range_tag: - status = "[+]" - up_to_date += 1 - elif latest_in_range_tag != "--" and current != latest_in_range_tag: - status = "[!]" - upgradable += 1 - else: - status = "[!]" - upgradable += 1 - - # Check if major upgrade available outside range - if latest_overall_tag != latest_in_range_tag: - status = "[*]" - - rows.append(_OutdatedRow( - name=entry.name, - current=current, - range_spec=version_range, - latest_in_range=latest_in_range_tag, - latest_overall=latest_overall_tag, - status=status, - note="", - )) - - _render_outdated_table(logger, rows) - - if upgradable > 0: - logger.progress( - f"{upgradable} package(s) can be updated", - symbol="info", - ) - else: - logger.progress( - "All packages are up to date", - symbol="info", +def _render_build_table(logger, report): + """Render the resolved-packages table (Rich with colorama fallback).""" + console = _get_console() + if not console: + # Colorama fallback + for pkg in report.resolved: + sha_short = pkg.sha[:8] if pkg.sha else "--" + ref_kind = "tag" if not pkg.ref.startswith("refs/heads/") else "branch" + logger.tree_item( + f" [+] {pkg.name} {pkg.ref} {sha_short} ({ref_kind})" ) + return - if verbose: - logger.verbose_detail(f" {upgradable} upgradable entries") - - if upgradable > 0: - sys.exit(1) - sys.exit(0) + from rich.table import Table + from rich.text import Text - except SystemExit: - raise - except Exception as e: # noqa: BLE001 -- top-level command catch-all - logger.error(f"Failed to check outdated packages: {e}", symbol="error") - logger.verbose_detail(traceback.format_exc()) - sys.exit(1) - finally: - resolver.close() + table = Table( + title="Resolved Packages", + show_header=True, + header_style="bold cyan", + border_style="cyan", + ) + table.add_column("Status", style="green", no_wrap=True, width=6) + table.add_column("Package", style="bold white", no_wrap=True) + table.add_column("Version", style="cyan") + table.add_column("Commit", style="dim") + table.add_column("Ref Kind", style="white") + + for pkg in report.resolved: + sha_short = pkg.sha[:8] if pkg.sha else "--" + # Determine ref kind + ref_kind = "tag" + if pkg.ref and not parse_semver(pkg.ref.lstrip("vV")): + ref_kind = "ref" + table.add_row(Text("[+]"), pkg.name, pkg.ref, sha_short, ref_kind) + console.print() + console.print(table) class _OutdatedRow: """Simple container for outdated table row data.""" @@ -1025,7 +700,6 @@ def __init__(self, name, current, range_spec, latest_in_range, self.status = status self.note = note - def _load_current_versions(): """Load current ref versions from marketplace.json if present.""" mkt_path = Path.cwd() / "marketplace.json" @@ -1043,10 +717,9 @@ def _load_current_versions(): except (json.JSONDecodeError, OSError): return {} - def _extract_tag_versions(refs, entry, yml, include_prerelease): """Extract (SemVer, tag_name) pairs from remote refs for a package entry.""" - from ..marketplace.tag_pattern import build_tag_regex + from ...marketplace.tag_pattern import build_tag_regex pattern = entry.tag_pattern or yml.build.tag_pattern tag_rx = build_tag_regex(pattern) @@ -1067,7 +740,6 @@ def _extract_tag_versions(refs, entry, yml, include_prerelease): results.append((sv, tag_name)) return results - def _render_outdated_table(logger, rows): """Render the outdated-packages table.""" console = _get_console() @@ -1113,129 +785,6 @@ def _render_outdated_table(logger, rows): console.print() console.print(table) - -# --------------------------------------------------------------------------- -# marketplace check -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Validate marketplace.yml entries are resolvable") -@click.option("--offline", is_flag=True, help="Schema + cached-ref checks only (no network)") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def check(offline, verbose): - """Validate marketplace.yml and check each entry is resolvable.""" - logger = CommandLogger("marketplace-check", verbose=verbose) - - _, yml = _load_config_or_exit(logger) - - # Defence-in-depth: flag duplicate package names (yml_schema - # also rejects them, but an extra check keeps diagnostics visible). - _warn_duplicate_names(logger, yml) - - if offline: - logger.progress( - "Offline mode -- only schema and cached-ref checks", - symbol="info", - ) - - resolver = RefResolver(offline=offline) - results = [] - failure_count = 0 - - try: - for entry in yml.packages: - try: - # Attempt to resolve each entry - refs = resolver.list_remote_refs(entry.source) - - # Check version/ref resolution - ref_ok = False - if entry.ref is not None: - # Check the explicit ref exists - for r in refs: - tag_name = r.name - if tag_name.startswith("refs/tags/"): - tag_name = tag_name[len("refs/tags/"):] - elif tag_name.startswith("refs/heads/"): - tag_name = tag_name[len("refs/heads/"):] - if tag_name == entry.ref or r.name == entry.ref: - ref_ok = True - break - if not ref_ok: - results.append(_CheckResult( - name=entry.name, reachable=True, - version_found=False, ref_ok=False, - error=f"Ref '{entry.ref}' not found", - )) - failure_count += 1 - continue - else: - # Version range -- check at least one tag satisfies - tag_versions = _extract_tag_versions( - refs, entry, yml, False - ) - version_range = entry.version or "" - matching = [ - (sv, tag) for sv, tag in tag_versions - if satisfies_range(sv, version_range) - ] - if matching: - ref_ok = True - else: - results.append(_CheckResult( - name=entry.name, reachable=True, - version_found=len(tag_versions) > 0, - ref_ok=False, - error=f"No tag matching '{version_range}'", - )) - failure_count += 1 - continue - - results.append(_CheckResult( - name=entry.name, reachable=True, - version_found=True, ref_ok=True, error="", - )) - - except OfflineMissError: - results.append(_CheckResult( - name=entry.name, reachable=False, - version_found=False, ref_ok=False, - error="No cached refs (offline)", - )) - failure_count += 1 - except GitLsRemoteError as exc: - results.append(_CheckResult( - name=entry.name, reachable=False, - version_found=False, ref_ok=False, - error=exc.summary_text[:60], - )) - failure_count += 1 - except Exception as exc: # noqa: BLE001 -- per-entry diagnostic catch-all - results.append(_CheckResult( - name=entry.name, reachable=False, - version_found=False, ref_ok=False, - error=str(exc)[:60], - )) - failure_count += 1 - logger.verbose_detail(traceback.format_exc()) - - _render_check_table(logger, results) - - total = len(results) - if failure_count > 0: - logger.error( - f"{failure_count} entries have issues", symbol="error" - ) - sys.exit(1) - else: - logger.success( - f"All {total} entries OK", symbol="check" - ) - - finally: - resolver.close() - - class _CheckResult: """Container for per-entry check results.""" @@ -1248,7 +797,6 @@ def __init__(self, name, reachable, version_found, ref_ok, error): self.ref_ok = ref_ok self.error = error - def _render_check_table(logger, results): """Render the check-results table.""" console = _get_console() @@ -1292,189 +840,6 @@ def _render_check_table(logger, results): console.print() console.print(table) - -# --------------------------------------------------------------------------- -# marketplace doctor -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Run environment diagnostics for marketplace builds") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def doctor(verbose): - """Check git, network, auth, and marketplace.yml readiness.""" - logger = CommandLogger("marketplace-doctor", verbose=verbose) - checks = [] - - # Check 1: git on PATH - git_ok = False - git_detail = "" - try: - result = subprocess.run( - ["git", "--version"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0: - git_ok = True - git_detail = result.stdout.strip() - else: - git_detail = "git returned non-zero exit code" - except FileNotFoundError: - git_detail = "git not found on PATH" - except subprocess.TimeoutExpired: - git_detail = "git --version timed out" - except (subprocess.SubprocessError, OSError) as exc: - git_detail = str(exc)[:60] - - checks.append(_DoctorCheck( - name="git", - passed=git_ok, - detail=git_detail, - )) - - # Check 2: network reachability - net_ok = False - net_detail = "" - try: - result = subprocess.run( - ["git", "ls-remote", "https://github.com/git/git.git", "HEAD"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0: - net_ok = True - net_detail = "github.com reachable" - else: - translated = translate_git_stderr( - result.stderr, - exit_code=result.returncode, - operation="ls-remote", - remote="github.com", - ) - net_detail = translated.hint[:80] - except subprocess.TimeoutExpired: - net_detail = "Network check timed out (5s)" - except FileNotFoundError: - net_detail = "git not found; cannot test network" - except (subprocess.SubprocessError, OSError) as exc: - net_detail = str(exc)[:60] - - checks.append(_DoctorCheck( - name="network", - passed=net_ok, - detail=net_detail, - )) - - # Check 3: auth tokens (delegate to AuthResolver for full coverage) - try: - from ..core.auth import AuthResolver - resolver = AuthResolver() - # Try to get a token for github.com as a representative check - token = resolver.resolve("github.com").token - has_token = bool(token) - except Exception: # noqa: BLE001 -- best-effort auth probe - has_token = False - auth_detail = "Token detected" if has_token else "No token; unauthenticated rate limits apply" - checks.append(_DoctorCheck( - name="auth", - passed=True, # informational; never fails - detail=auth_detail, - informational=True, - )) - - # Check 4: gh CLI availability (informational; only needed for publish) - gh_ok = False - gh_detail = "" - try: - result = subprocess.run( - ["gh", "--version"], - capture_output=True, text=True, timeout=10, - ) - if result.returncode == 0: - gh_ok = True - gh_detail = result.stdout.strip().split("\n")[0] - else: - gh_detail = "gh CLI returned non-zero exit code" - except FileNotFoundError: - gh_detail = "gh CLI not found (install: https://cli.github.com/)" - except subprocess.TimeoutExpired: - gh_detail = "gh --version timed out" - except (subprocess.SubprocessError, OSError) as exc: - gh_detail = str(exc)[:60] - - checks.append(_DoctorCheck( - name="gh CLI", - passed=gh_ok, - detail=gh_detail, - informational=True, - )) - - # Check 5: marketplace authoring config (apm.yml block or legacy file) - project_root = Path.cwd() - apm_path = project_root / "apm.yml" - legacy_path = project_root / "marketplace.yml" - yml_obj = None - config_detail = "" - config_passed = True - config_informational = True - try: - source = detect_config_source(project_root) - if source == ConfigSource.APM_YML: - from ..marketplace.yml_schema import load_marketplace_from_apm_yml - try: - yml_obj = load_marketplace_from_apm_yml(apm_path) - config_detail = "apm.yml 'marketplace:' block found and valid" - except MarketplaceYmlError as exc: - config_passed = False - config_detail = f"apm.yml marketplace block has errors: {str(exc)[:60]}" - elif source == ConfigSource.LEGACY_YML: - try: - yml_obj = load_marketplace_yml(legacy_path) - config_detail = ( - "marketplace.yml found (legacy). " - "Run 'apm marketplace migrate' to fold it into apm.yml." - ) - except MarketplaceYmlError as exc: - config_passed = False - config_detail = f"marketplace.yml has errors: {str(exc)[:60]}" - else: - config_detail = "No marketplace authoring config in current directory" - except MarketplaceYmlError as exc: - # Both files present. - config_passed = False - config_detail = str(exc)[:120] - - checks.append(_DoctorCheck( - name="marketplace config", - passed=config_passed, - detail=config_detail, - informational=config_informational, - )) - - # Check 6: duplicate package names (defence-in-depth) - if yml_obj is not None: - dup_detail = _find_duplicate_names(yml_obj) - if dup_detail: - checks.append(_DoctorCheck( - name="duplicate names", - passed=False, - detail=dup_detail, - informational=True, - )) - else: - checks.append(_DoctorCheck( - name="duplicate names", - passed=True, - detail="No duplicate package names", - informational=True, - )) - - _render_doctor_table(logger, checks) - - # Exit: 0 if checks 1-2 pass; checks 3-6 are informational - critical_checks = [c for c in checks if not c.informational] - if any(not c.passed for c in critical_checks): - sys.exit(1) - - class _DoctorCheck: """Container for a single doctor check result.""" @@ -1486,7 +851,6 @@ def __init__(self, name, passed, detail, informational=False): self.detail = detail self.informational = informational - def _render_doctor_table(logger, checks): """Render the doctor results table.""" console = _get_console() @@ -1526,12 +890,6 @@ def _render_doctor_table(logger, checks): console.print() console.print(table) - -# --------------------------------------------------------------------------- -# marketplace publish -# --------------------------------------------------------------------------- - - def _load_targets_file(path): """Load and validate a consumer-targets YAML file. @@ -1592,224 +950,6 @@ def _load_targets_file(path): return targets, None - -@marketplace.command(help="Publish marketplace updates to consumer repositories") -@click.option( - "--targets", - "targets_file", - default=None, - type=click.Path(exists=False), - help="Path to consumer-targets YAML file (default: ./consumer-targets.yml)", -) -@click.option("--dry-run", is_flag=True, help="Preview without pushing or opening PRs") -@click.option("--no-pr", is_flag=True, help="Push branches but skip PR creation") -@click.option("--draft", is_flag=True, help="Create PRs as drafts") -@click.option("--allow-downgrade", is_flag=True, help="Allow version downgrades") -@click.option("--allow-ref-change", is_flag=True, help="Allow switching ref types") -@click.option( - "--parallel", - default=4, - show_default=True, - type=int, - help="Maximum number of concurrent target updates", -) -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def publish( - targets_file, - dry_run, - no_pr, - draft, - allow_downgrade, - allow_ref_change, - parallel, - yes, - verbose, -): - """Publish marketplace updates to consumer repositories.""" - logger = CommandLogger("marketplace-publish", verbose=verbose) - - # ------------------------------------------------------------------ - # 1. Pre-flight checks - # ------------------------------------------------------------------ - - # 1a. Load marketplace.yml - _, yml = _load_config_or_exit(logger) - - # 1b. Load marketplace.json - mkt_json_path = Path.cwd() / "marketplace.json" - if not mkt_json_path.exists(): - logger.error( - "marketplace.json not found. Run 'apm pack' first.", - symbol="error", - ) - sys.exit(1) - - # 1c. Load targets - if targets_file: - targets_path = Path(targets_file) - if not targets_path.exists(): - logger.error( - f"Targets file not found: {targets_file}", - symbol="error", - ) - sys.exit(1) - else: - targets_path = Path.cwd() / "consumer-targets.yml" - if not targets_path.exists(): - logger.error( - "No consumer-targets.yml found. " - "Create one or pass --targets .\n" - "\n" - "Example consumer-targets.yml:\n" - " targets:\n" - " - repo: acme-org/service-a\n" - " branch: main\n" - " - repo: acme-org/service-b\n" - " branch: develop", - symbol="error", - ) - sys.exit(1) - - targets, error = _load_targets_file(targets_path) - if error: - logger.error(error, symbol="error") - sys.exit(1) - - # 1d. Check gh availability (unless --no-pr) - pr = None - if not no_pr: - pr = PrIntegrator() - available, hint = pr.check_available() - if not available: - logger.error(hint, symbol="error") - sys.exit(1) - - # ------------------------------------------------------------------ - # 2. Plan and confirm - # ------------------------------------------------------------------ - - publisher = MarketplacePublisher(Path.cwd()) - plan = publisher.plan( - targets, - allow_downgrade=allow_downgrade, - allow_ref_change=allow_ref_change, - ) - - # Render publish plan - _render_publish_plan(logger, plan) - - # Confirmation logic - if not yes: - if not _is_interactive(): - logger.error( - "Non-interactive session: pass --yes to confirm the publish.", - symbol="error", - ) - sys.exit(1) - try: - if not click.confirm( - f"Confirm publish to {len(targets)} repositories?", - default=False, - ): - logger.progress("Publish cancelled.", symbol="info") - sys.exit(0) - except click.Abort: - logger.progress("Publish cancelled.", symbol="info") - sys.exit(0) - - if dry_run: - logger.progress( - "Dry run: no branches will be pushed and no PRs will be opened.", - symbol="info", - ) - - # ------------------------------------------------------------------ - # 3. Execute publish - # ------------------------------------------------------------------ - - results = publisher.execute(plan, dry_run=dry_run, parallel=parallel) - - # PR integration - pr_results = [] - if not no_pr: - if pr is None: - pr = PrIntegrator() - - for result in results: - if dry_run: - # In dry-run, preview what PR would do for UPDATED targets - if result.outcome == PublishOutcome.UPDATED: - pr_result = pr.open_or_update( - plan, - result.target, - result, - no_pr=False, - draft=draft, - dry_run=True, - ) - pr_results.append(pr_result) - else: - pr_results.append(PrResult( - target=result.target, - state=PrState.SKIPPED, - pr_number=None, - pr_url=None, - message=f"No PR needed: {result.outcome.value}", - )) - else: - if result.outcome == PublishOutcome.UPDATED: - pr_result = pr.open_or_update( - plan, - result.target, - result, - no_pr=False, - draft=draft, - dry_run=False, - ) - pr_results.append(pr_result) - else: - pr_results.append(PrResult( - target=result.target, - state=PrState.SKIPPED, - pr_number=None, - pr_url=None, - message=f"No PR needed: {result.outcome.value}", - )) - - # ------------------------------------------------------------------ - # 4. Summary rendering - # ------------------------------------------------------------------ - - _render_publish_summary(logger, results, pr_results, no_pr, dry_run) - - # State file path -- use soft_wrap so the path is never split mid-word - # in narrow terminals (Rich would otherwise break at hyphens). - state_path = Path.cwd() / ".apm" / "publish-state.json" - try: - from rich.text import Text - - console = _get_console() - if console is not None: - console.print( - Text(f"[i] State file: {state_path}", no_wrap=True), - style="blue", - highlight=False, - soft_wrap=True, - ) - else: - logger.progress(f"State file: {state_path}", symbol="info") - except Exception: # noqa: BLE001 -- best-effort Rich rendering fallback - logger.progress(f"State file: {state_path}", symbol="info") - - # Exit code - failed_count = sum( - 1 for r in results if r.outcome == PublishOutcome.FAILED - ) - if failed_count > 0: - sys.exit(1) - - def _render_publish_plan(logger, plan): """Render the publish plan as a Rich panel + target table.""" console = _get_console() @@ -1860,7 +1000,6 @@ def _render_publish_plan(logger, plan): console.print(table) console.print() - def _render_publish_summary(logger, results, pr_results, no_pr, dry_run): """Render the final publish summary table.""" console = _get_console() @@ -1938,7 +1077,6 @@ def _render_publish_summary(logger, results, pr_results, no_pr, dry_run): _render_publish_footer(logger, updated_count, failed_count, total, dry_run) - def _outcome_symbol(outcome): """Map a ``PublishOutcome`` to a bracket symbol.""" if outcome == PublishOutcome.UPDATED: @@ -1954,7 +1092,6 @@ def _outcome_symbol(outcome): return "[*]" return "[*]" - def _render_publish_footer(logger, updated, failed, total, dry_run): """Render the footer success/warning line.""" suffix = " (dry-run)" if dry_run else "" @@ -1970,12 +1107,6 @@ def _render_publish_footer(logger, updated, failed, total, dry_run): symbol="warning", ) - -# --------------------------------------------------------------------------- -# Top-level search command (registered separately in cli.py) -# --------------------------------------------------------------------------- - - @click.command( name="search", help="Search plugins in a marketplace (QUERY@MARKETPLACE)", @@ -1990,8 +1121,8 @@ def search(expression, limit, verbose): """ logger = CommandLogger("marketplace-search", verbose=verbose) try: - from ..marketplace.client import search_marketplace - from ..marketplace.registry import get_marketplace_by_name + from ...marketplace.client import search_marketplace + from ...marketplace.registry import get_marketplace_by_name if "@" not in expression: logger.error( @@ -2076,57 +1207,67 @@ def search(expression, limit, verbose): -# --------------------------------------------------------------------------- -# marketplace migrate -# --------------------------------------------------------------------------- - - -@marketplace.command(help="Fold marketplace.yml into apm.yml's 'marketplace:' block") -@click.option( - "--force", - "--yes", - "-y", - "force", - is_flag=True, - help="Overwrite an existing 'marketplace:' block in apm.yml (alias: --yes/-y)", -) -@click.option( - "--dry-run", - is_flag=True, - help="Show the proposed apm.yml changes without writing them", -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def migrate(force, dry_run, verbose): - """One-shot conversion from legacy marketplace.yml to apm.yml block.""" - logger = CommandLogger("marketplace-migrate", verbose=verbose) - project_root = Path.cwd() +from .check import check # noqa: E402 +from .doctor import doctor # noqa: E402 +from .init import init # noqa: E402 +from .migrate import migrate # noqa: E402 +from .outdated import outdated # noqa: E402 +from .publish import publish # noqa: E402 +from .validate import validate # noqa: E402 + +# Public surface: the click group + per-command callables. Domain types are +# re-exported from canonical sources for backward compatibility with tests +# and external consumers that patch via this package path. Submodules import +# their domain types from the canonical sources directly, not from here. +__all__ = [ + "MarketplaceGroup", + "marketplace", + "package", + "init", + "add", + "list_cmd", + "browse", + "update", + "remove", + "validate", + "outdated", + "check", + "doctor", + "publish", + "migrate", + "search", + "BuildOptions", + "BuildReport", + "MarketplaceBuilder", + "ResolvedPackage", + "BuildError", + "GitLsRemoteError", + "HeadNotAllowedError", + "MarketplaceNotFoundError", + "MarketplaceYmlError", + "NoMatchingVersionError", + "OfflineMissError", + "RefNotFoundError", + "translate_git_stderr", + "ConfigSource", + "detect_config_source", + "load_marketplace_config", + "migrate_marketplace_yml", + "PrIntegrator", + "PrResult", + "PrState", + "ConsumerTarget", + "MarketplacePublisher", + "PublishOutcome", + "PublishPlan", + "TargetResult", + "RefResolver", + "RemoteRef", + "SemVer", + "parse_semver", + "satisfies_range", + "load_marketplace_yml", + "PathTraversalError", + "validate_path_segments", +] - try: - diff = migrate_marketplace_yml( - project_root, force=force, dry_run=dry_run - ) - except MarketplaceYmlError as exc: - logger.error(str(exc), symbol="error") - sys.exit(1) - except Exception as exc: # noqa: BLE001 -- top-level command catch-all - logger.error(f"Migration failed: {exc}", symbol="error") - logger.verbose_detail(traceback.format_exc()) - sys.exit(1) - - if dry_run: - logger.progress( - "Dry run -- the following changes would be applied to apm.yml:", - symbol="info", - ) - # Echo the diff verbatim (already ASCII). - click.echo(diff if diff else "(no changes)") - return - - logger.success( - "Migrated marketplace.yml into apm.yml's 'marketplace:' block", - symbol="check", - ) - logger.progress( - "marketplace.yml has been removed. Commit apm.yml to record the migration.", - symbol="info", - ) diff --git a/src/apm_cli/commands/marketplace/check.py b/src/apm_cli/commands/marketplace/check.py new file mode 100644 index 000000000..734dcada1 --- /dev/null +++ b/src/apm_cli/commands/marketplace/check.py @@ -0,0 +1,138 @@ +"""``apm marketplace check`` command.""" + +from __future__ import annotations + +import sys +import traceback + +import click + +from ...core.command_logger import CommandLogger +from ...marketplace.errors import GitLsRemoteError, OfflineMissError +from ...marketplace.ref_resolver import RefResolver +from ...marketplace.semver import satisfies_range +from . import ( + marketplace, + _CheckResult, + _extract_tag_versions, + _load_config_or_exit, + _render_check_table, + _warn_duplicate_names, +) + + +@marketplace.command(help="Validate marketplace entries are resolvable") +@click.option("--offline", is_flag=True, help="Schema + cached-ref checks only (no network)") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def check(offline, verbose): + """Validate marketplace.yml and check each entry is resolvable.""" + logger = CommandLogger("marketplace-check", verbose=verbose) + + _, yml = _load_config_or_exit(logger) + + # Defence-in-depth: flag duplicate package names (yml_schema + # also rejects them, but an extra check keeps diagnostics visible). + _warn_duplicate_names(logger, yml) + + if offline: + logger.progress( + "Offline mode -- only schema and cached-ref checks", + symbol="info", + ) + + resolver = RefResolver(offline=offline) + results = [] + failure_count = 0 + + try: + for entry in yml.packages: + try: + # Attempt to resolve each entry + refs = resolver.list_remote_refs(entry.source) + + # Check version/ref resolution + ref_ok = False + if entry.ref is not None: + # Check the explicit ref exists + for r in refs: + tag_name = r.name + if tag_name.startswith("refs/tags/"): + tag_name = tag_name[len("refs/tags/"):] + elif tag_name.startswith("refs/heads/"): + tag_name = tag_name[len("refs/heads/"):] + if tag_name == entry.ref or r.name == entry.ref: + ref_ok = True + break + if not ref_ok: + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=False, ref_ok=False, + error=f"Ref '{entry.ref}' not found", + )) + failure_count += 1 + continue + else: + # Version range -- check at least one tag satisfies + tag_versions = _extract_tag_versions( + refs, entry, yml, False + ) + version_range = entry.version or "" + matching = [ + (sv, tag) for sv, tag in tag_versions + if satisfies_range(sv, version_range) + ] + if matching: + ref_ok = True + else: + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=len(tag_versions) > 0, + ref_ok=False, + error=f"No tag matching '{version_range}'", + )) + failure_count += 1 + continue + + results.append(_CheckResult( + name=entry.name, reachable=True, + version_found=True, ref_ok=True, error="", + )) + + except OfflineMissError: + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error="No cached refs (offline)", + )) + failure_count += 1 + except GitLsRemoteError as exc: + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error=exc.summary_text[:60], + )) + failure_count += 1 + except Exception as exc: # noqa: BLE001 -- per-entry diagnostic catch-all + results.append(_CheckResult( + name=entry.name, reachable=False, + version_found=False, ref_ok=False, + error=str(exc)[:60], + )) + failure_count += 1 + logger.verbose_detail(traceback.format_exc()) + + _render_check_table(logger, results) + + total = len(results) + if failure_count > 0: + logger.error( + f"{failure_count} entries have issues", symbol="error" + ) + sys.exit(1) + else: + logger.success( + f"All {total} entries OK", symbol="check" + ) + + finally: + resolver.close() diff --git a/src/apm_cli/commands/marketplace/doctor.py b/src/apm_cli/commands/marketplace/doctor.py new file mode 100644 index 000000000..a4c018b92 --- /dev/null +++ b/src/apm_cli/commands/marketplace/doctor.py @@ -0,0 +1,199 @@ +"""``apm marketplace doctor`` command.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import click + +from ...core.command_logger import CommandLogger +from ...marketplace.errors import MarketplaceYmlError +from ...marketplace.git_stderr import translate_git_stderr +from ...marketplace.migration import ConfigSource, detect_config_source +from ...marketplace.yml_schema import ( + load_marketplace_from_apm_yml, + load_marketplace_yml, +) +from . import ( + marketplace, + _DoctorCheck, + _find_duplicate_names, + _render_doctor_table, +) + + +@marketplace.command(help="Run environment diagnostics for marketplace publishing") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def doctor(verbose): + """Check git, network, auth, and marketplace config readiness.""" + logger = CommandLogger("marketplace-doctor", verbose=verbose) + checks = [] + + # Check 1: git on PATH + git_ok = False + git_detail = "" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + git_ok = True + git_detail = result.stdout.strip() + else: + git_detail = "git returned non-zero exit code" + except FileNotFoundError: + git_detail = "git not found on PATH" + except subprocess.TimeoutExpired: + git_detail = "git --version timed out" + except (subprocess.SubprocessError, OSError) as exc: + git_detail = str(exc)[:60] + + checks.append(_DoctorCheck( + name="git", + passed=git_ok, + detail=git_detail, + )) + + # Check 2: network reachability + net_ok = False + net_detail = "" + try: + result = subprocess.run( + ["git", "ls-remote", "https://github.com/git/git.git", "HEAD"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + net_ok = True + net_detail = "github.com reachable" + else: + translated = translate_git_stderr( + result.stderr, + exit_code=result.returncode, + operation="ls-remote", + remote="github.com", + ) + net_detail = translated.hint[:80] + except subprocess.TimeoutExpired: + net_detail = "Network check timed out (5s)" + except FileNotFoundError: + net_detail = "git not found; cannot test network" + except (subprocess.SubprocessError, OSError) as exc: + net_detail = str(exc)[:60] + + checks.append(_DoctorCheck( + name="network", + passed=net_ok, + detail=net_detail, + )) + + # Check 3: auth tokens (delegate to AuthResolver for full coverage) + try: + from ...core.auth import AuthResolver + resolver = AuthResolver() + # Try to get a token for github.com as a representative check + token = resolver.resolve("github.com").token + has_token = bool(token) + except Exception: # noqa: BLE001 -- best-effort auth probe + has_token = False + auth_detail = "Token detected" if has_token else "No token; unauthenticated rate limits apply" + checks.append(_DoctorCheck( + name="auth", + passed=True, # informational; never fails + detail=auth_detail, + informational=True, + )) + + # Check 4: gh CLI availability (informational; only needed for publish) + gh_ok = False + gh_detail = "" + try: + result = subprocess.run( + ["gh", "--version"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + gh_ok = True + gh_detail = result.stdout.strip().split("\n")[0] + else: + gh_detail = "gh CLI returned non-zero exit code" + except FileNotFoundError: + gh_detail = "gh CLI not found (install: https://cli.github.com/)" + except subprocess.TimeoutExpired: + gh_detail = "gh --version timed out" + except (subprocess.SubprocessError, OSError) as exc: + gh_detail = str(exc)[:60] + + checks.append(_DoctorCheck( + name="gh CLI", + passed=gh_ok, + detail=gh_detail, + informational=True, + )) + + # Check 5: marketplace config presence + parsability + project_root = Path.cwd() + apm_path = project_root / "apm.yml" + legacy_path = project_root / "marketplace.yml" + yml_obj = None + config_passed = True + config_detail = "" + + try: + source = detect_config_source(project_root) + if source == ConfigSource.APM_YML: + try: + yml_obj = load_marketplace_from_apm_yml(apm_path) + config_detail = "apm.yml 'marketplace:' block found and valid" + except MarketplaceYmlError as exc: + config_passed = False + config_detail = f"apm.yml marketplace block has errors: {str(exc)[:60]}" + elif source == ConfigSource.LEGACY_YML: + try: + yml_obj = load_marketplace_yml(legacy_path) + config_detail = ( + "marketplace.yml found (legacy). Run 'apm marketplace " + "migrate' to fold it into apm.yml." + ) + except MarketplaceYmlError as exc: + config_passed = False + config_detail = f"marketplace.yml has errors: {str(exc)[:60]}" + else: + config_detail = "No marketplace authoring config in current directory" + except MarketplaceYmlError as exc: + config_passed = False + config_detail = str(exc)[:120] + + checks.append(_DoctorCheck( + name="marketplace config", + passed=config_passed, + detail=config_detail, + informational=True, + )) + + # Check 6: duplicate package names (defence-in-depth) + if yml_obj is not None: + dup_detail = _find_duplicate_names(yml_obj) + if dup_detail: + checks.append(_DoctorCheck( + name="duplicate names", + passed=False, + detail=dup_detail, + informational=True, + )) + else: + checks.append(_DoctorCheck( + name="duplicate names", + passed=True, + detail="No duplicate package names", + informational=True, + )) + + _render_doctor_table(logger, checks) + + # Exit: 0 if checks 1-2 pass; config checks are informational + critical_checks = [c for c in checks if not c.informational] + if any(not c.passed for c in critical_checks): + sys.exit(1) diff --git a/src/apm_cli/commands/marketplace/init.py b/src/apm_cli/commands/marketplace/init.py new file mode 100644 index 000000000..9934810f4 --- /dev/null +++ b/src/apm_cli/commands/marketplace/init.py @@ -0,0 +1,130 @@ +"""``apm marketplace init`` command.""" + +from __future__ import annotations + +import sys +from io import StringIO +from pathlib import Path + +import click + +from ...core.command_logger import CommandLogger +from . import ( + marketplace, + _check_gitignore_for_marketplace_json, +) + +@marketplace.command(help="Add a 'marketplace:' block to apm.yml (scaffolds apm.yml if missing)") +@click.option( + "--force", + is_flag=True, + help="Overwrite an existing 'marketplace:' block in apm.yml", +) +@click.option( + "--no-gitignore-check", + is_flag=True, + help="Skip the .gitignore staleness check", +) +@click.option("--name", default=None, help="Marketplace/package name (default: my-marketplace)") +@click.option("--owner", default=None, help="Owner name for the marketplace") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def init(force, no_gitignore_check, name, owner, verbose): + """Scaffold a ``marketplace:`` block in apm.yml (creates apm.yml if absent).""" + from ruamel.yaml import YAML + + from ...marketplace.init_template import render_marketplace_block + + logger = CommandLogger("marketplace-init", verbose=verbose) + apm_path = Path.cwd() / "apm.yml" + scaffolded_apm_yml = False + + if not apm_path.exists(): + scaffold_name = name or "my-marketplace" + scaffold_text = ( + f"name: {scaffold_name}\n" + "version: 0.1.0\n" + "description: A short description of what this repo offers\n" + ) + try: + apm_path.write_text(scaffold_text, encoding="utf-8") + except OSError as exc: + logger.error(f"Failed to write apm.yml: {exc}", symbol="error") + sys.exit(1) + scaffolded_apm_yml = True + if verbose: + logger.verbose_detail(f" Path: {apm_path}") + + try: + rt = YAML(typ="rt") + rt.preserve_quotes = True + rt.indent(mapping=2, sequence=4, offset=2) + data = rt.load(apm_path.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 -- guard malformed apm.yml + logger.error(f"Failed to parse apm.yml: {exc}", symbol="error") + sys.exit(1) + + if data is None: + from ruamel.yaml.comments import CommentedMap + + data = CommentedMap() + elif not isinstance(data, dict): + logger.error( + "apm.yml must be a YAML mapping at the top level " + f"(got {type(data).__name__}).", + symbol="error", + ) + sys.exit(1) + + if ( + "marketplace" in data + and data["marketplace"] is not None + and not force + ): + logger.warning( + "apm.yml already has a 'marketplace:' block. Use --force to overwrite.", + symbol="warning", + ) + sys.exit(1) + + block_data = rt.load(render_marketplace_block(owner=owner)) + data["marketplace"] = block_data["marketplace"] + + out = StringIO() + rt.dump(data, out) + try: + apm_path.write_text(out.getvalue(), encoding="utf-8") + except OSError as exc: + logger.error(f"Failed to write apm.yml: {exc}", symbol="error") + sys.exit(1) + + if scaffolded_apm_yml: + logger.success("Created apm.yml with 'marketplace:' block", symbol="check") + else: + logger.success("Added 'marketplace:' block to apm.yml", symbol="check") + + if verbose: + logger.verbose_detail(f" Path: {apm_path}") + + # .gitignore staleness check + if not no_gitignore_check: + _check_gitignore_for_marketplace_json(logger) + + # Next steps panel + next_steps = [ + "Edit the 'marketplace:' block in apm.yml to add your packages", + "Run 'apm pack' to generate .claude-plugin/marketplace.json", + "Commit BOTH apm.yml and the generated marketplace.json", + ] + + try: + from ...utils.console import _rich_panel + + _rich_panel( + "\n".join(f" {i}. {step}" for i, step in enumerate(next_steps, 1)), + title=" Next Steps", + style="cyan", + ) + except (ImportError, NameError): + logger.progress("Next steps:") + for i, step in enumerate(next_steps, 1): + logger.tree_item(f" {i}. {step}") diff --git a/src/apm_cli/commands/marketplace/migrate.py b/src/apm_cli/commands/marketplace/migrate.py new file mode 100644 index 000000000..45bde7f9e --- /dev/null +++ b/src/apm_cli/commands/marketplace/migrate.py @@ -0,0 +1,64 @@ +"""``apm marketplace migrate`` command.""" + +from __future__ import annotations + +import sys +import traceback +from pathlib import Path + +import click + +from ...core.command_logger import CommandLogger +from ...marketplace.errors import MarketplaceYmlError +from ...marketplace.migration import migrate_marketplace_yml +from . import marketplace + + +@marketplace.command(help="Fold marketplace.yml into apm.yml's 'marketplace:' block") +@click.option( + "--force", + "--yes", + "-y", + "force", + is_flag=True, + help="Overwrite an existing 'marketplace:' block in apm.yml (alias: --yes/-y)", +) +@click.option( + "--dry-run", + is_flag=True, + help="Show the proposed apm.yml changes without writing them", +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def migrate(force, dry_run, verbose): + """Convert legacy marketplace.yml to an apm.yml marketplace block.""" + logger = CommandLogger("marketplace-migrate", verbose=verbose) + project_root = Path.cwd() + + try: + diff = migrate_marketplace_yml( + project_root, force=force, dry_run=dry_run + ) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(1) + except Exception as exc: # noqa: BLE001 -- top-level command catch-all + logger.error(f"Migration failed: {exc}", symbol="error") + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) + + if dry_run: + logger.progress( + "Dry run -- the following changes would be applied to apm.yml:", + symbol="info", + ) + click.echo(diff if diff else "(no changes)") + return + + logger.success( + "Migrated marketplace.yml into apm.yml's 'marketplace:' block", + symbol="check", + ) + logger.progress( + "marketplace.yml has been removed. Commit apm.yml to record the migration.", + symbol="info", + ) diff --git a/src/apm_cli/commands/marketplace/outdated.py b/src/apm_cli/commands/marketplace/outdated.py new file mode 100644 index 000000000..a7ab85931 --- /dev/null +++ b/src/apm_cli/commands/marketplace/outdated.py @@ -0,0 +1,168 @@ +"""``apm marketplace outdated`` command.""" + +from __future__ import annotations + +import sys +import traceback + +import click + +from ...core.command_logger import CommandLogger +from ...marketplace.errors import BuildError +from ...marketplace.ref_resolver import RefResolver +from ...marketplace.semver import satisfies_range +from . import ( + marketplace, + _OutdatedRow, + _extract_tag_versions, + _load_current_versions, + _load_config_or_exit, + _render_outdated_table, +) + + +@marketplace.command(help="Show packages with available upgrades") +@click.option("--offline", is_flag=True, help="Use cached refs only (no network)") +@click.option( + "--include-prerelease", is_flag=True, help="Include prerelease versions" +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def outdated(offline, include_prerelease, verbose): + """Compare installed versions against latest available tags.""" + logger = CommandLogger("marketplace-outdated", verbose=verbose) + + _, yml = _load_config_or_exit(logger) + + # Load current marketplace.json for "Current" column + current_versions = _load_current_versions() + + resolver = RefResolver(offline=offline) + try: + rows = [] + upgradable = 0 + up_to_date = 0 + for entry in yml.packages: + # Entries with explicit ref (no range) are skipped + if entry.ref is not None: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec="--", + latest_in_range="--", + latest_overall="--", + status="[i]", + note="Pinned to ref; skipped", + )) + continue + + version_range = entry.version or "" + if not version_range: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec="--", + latest_in_range="--", + latest_overall="--", + status="[i]", + note="No version range", + )) + continue + + try: + refs = resolver.list_remote_refs(entry.source) + except (BuildError, Exception) as exc: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec=version_range, + latest_in_range="--", + latest_overall="--", + status="[x]", + note=str(exc)[:60], + )) + continue + + # Parse tags into semvers + tag_versions = _extract_tag_versions( + refs, entry, yml, include_prerelease + ) + + if not tag_versions: + rows.append(_OutdatedRow( + name=entry.name, + current=current_versions.get(entry.name, "--"), + range_spec=version_range, + latest_in_range="--", + latest_overall="--", + status="[!]", + note="No matching tags found", + )) + continue + + # Find highest in-range and highest overall + in_range = [ + (sv, tag) for sv, tag in tag_versions + if satisfies_range(sv, version_range) + ] + latest_overall_sv, latest_overall_tag = max( + tag_versions, key=lambda x: x[0] + ) + latest_in_range_tag = "--" + if in_range: + _, latest_in_range_tag = max(in_range, key=lambda x: x[0]) + + current = current_versions.get(entry.name, "--") + + # Determine status + if current == latest_in_range_tag: + status = "[+]" + up_to_date += 1 + elif latest_in_range_tag != "--" and current != latest_in_range_tag: + status = "[!]" + upgradable += 1 + else: + status = "[!]" + upgradable += 1 + + # Check if major upgrade available outside range + if latest_overall_tag != latest_in_range_tag: + status = "[*]" + + rows.append(_OutdatedRow( + name=entry.name, + current=current, + range_spec=version_range, + latest_in_range=latest_in_range_tag, + latest_overall=latest_overall_tag, + status=status, + note="", + )) + + _render_outdated_table(logger, rows) + + if upgradable > 0: + logger.progress( + f"{upgradable} package(s) can be updated", + symbol="info", + ) + else: + logger.progress( + "All packages are up to date", + symbol="info", + ) + + if verbose: + logger.verbose_detail(f" {upgradable} upgradable entries") + + if upgradable > 0: + sys.exit(1) + sys.exit(0) + + except SystemExit: + raise + except Exception as e: # noqa: BLE001 -- top-level command catch-all + logger.error(f"Failed to check outdated packages: {e}", symbol="error") + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) + finally: + resolver.close() diff --git a/src/apm_cli/commands/marketplace/plugin/__init__.py b/src/apm_cli/commands/marketplace/plugin/__init__.py new file mode 100644 index 000000000..e24015f28 --- /dev/null +++ b/src/apm_cli/commands/marketplace/plugin/__init__.py @@ -0,0 +1,211 @@ +"""Marketplace package subgroup helpers and click wiring.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import click +import yaml + +from ....core.command_logger import CommandLogger +from ....marketplace.errors import ( + GitLsRemoteError, + MarketplaceYmlError, + OfflineMissError, +) +from ..._helpers import _is_interactive + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def _yml_path() -> Path: + """Return the active marketplace authoring config path in CWD.""" + cwd = Path.cwd() + apm_path = cwd / "apm.yml" + legacy_path = cwd / "marketplace.yml" + + if _has_marketplace_block(apm_path): + return apm_path + if legacy_path.exists(): + return legacy_path + return apm_path + +def _ensure_yml_exists(logger: CommandLogger) -> Path: + """Return the yml path or exit with guidance if it does not exist.""" + cwd = Path.cwd() + apm_path = cwd / "apm.yml" + legacy_path = cwd / "marketplace.yml" + + if _has_marketplace_block(apm_path) and legacy_path.exists(): + logger.error( + "Both apm.yml (with a 'marketplace:' block) and " + "marketplace.yml exist. Remove marketplace.yml or run " + "'apm marketplace migrate --force' to consolidate.", + symbol="error", + ) + sys.exit(1) + + path = _yml_path() + if not path.exists() or (path == apm_path and not _has_marketplace_block(path)): + logger.error( + "No marketplace authoring config found. " + "Run 'apm marketplace init' to scaffold one.", + symbol="error", + ) + sys.exit(1) + return path + +def _has_marketplace_block(apm_path: Path) -> bool: + """Return True when *apm_path* has a populated ``marketplace:`` block.""" + if not apm_path.exists(): + return False + try: + data = yaml.safe_load(apm_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return False + return ( + isinstance(data, dict) + and "marketplace" in data + and data["marketplace"] is not None + ) + +def _parse_tags(raw: str | None) -> list[str] | None: + """Split a comma-separated tag string into a list, or return None.""" + if raw is None: + return None + parts = [t.strip() for t in raw.split(",") if t.strip()] + return parts if parts else None + +def _verify_source(logger: CommandLogger, source: str) -> None: + """Run ``git ls-remote`` against *source* to verify reachability.""" + from ....marketplace.ref_resolver import RefResolver + + resolver = RefResolver() + try: + resolver.list_remote_refs(source) + except GitLsRemoteError as exc: + logger.error( + f"Source '{source}' is not reachable: {exc}", + symbol="error", + ) + sys.exit(2) + except OfflineMissError: + logger.warning( + f"Cannot verify source '{source}' (offline / no cache).", + symbol="warning", + ) + +def _resolve_ref( + logger: CommandLogger, + source: str, + ref: str | None, + version: str | None, + no_verify: bool, +) -> str | None: + """Resolve *ref* to a concrete SHA when it is mutable. + + Returns the (possibly resolved) ref string, or ``None`` when + *version* is set (version-based pinning, no ref needed). + """ + from ....marketplace.ref_resolver import RefResolver + + # Version-based - no ref resolution needed. + if version is not None: + return None + + # Already a concrete SHA - store as-is. + if ref is not None and _SHA_RE.match(ref): + return ref + + # HEAD (explicit or implicit) requires network access. + is_head = ref is None or ref.upper() == "HEAD" + if is_head: + if no_verify: + logger.error( + "Cannot resolve HEAD ref without network access. " + "Provide an explicit --ref SHA.", + symbol="error", + ) + sys.exit(2) + if ref is not None: + logger.warning( + "'HEAD' is a mutable ref. Resolving to current SHA for safety.", + symbol="warning", + ) + resolver = RefResolver() + try: + sha = resolver.resolve_ref_sha(source, "HEAD") + except GitLsRemoteError as exc: + logger.error( + f"Failed to resolve HEAD for '{source}': {exc}", + symbol="error", + ) + sys.exit(2) + logger.progress( + f"Resolved HEAD to {sha[:12]}", + symbol="info", + ) + return sha + + # Non-HEAD, non-SHA ref - check whether it is a branch name. + resolver = RefResolver() + try: + remote_refs = resolver.list_remote_refs(source) + except (GitLsRemoteError, OfflineMissError): + # Cannot verify - store as-is but warn the user. + logger.warning( + f"Could not verify ref '{ref}' for '{source}' (network unavailable). " + "Storing unresolved -- run with network access to pin a concrete SHA.", + symbol="warning", + ) + return ref + + for remote_ref in remote_refs: + if remote_ref.name == f"refs/heads/{ref}": + if no_verify: + logger.error( + "Cannot resolve branch ref without network access. " + "Provide an explicit --ref SHA.", + symbol="error", + ) + sys.exit(2) + logger.warning( + f"'{ref}' is a branch (mutable ref). " + "Resolving to current SHA for safety.", + symbol="warning", + ) + logger.progress( + f"Resolved {ref} to {remote_ref.sha[:12]}", + symbol="info", + ) + return remote_ref.sha + + # Not a branch - tag or unknown ref; store as-is. + return ref + +@click.group(help="Manage packages in marketplace authoring config") +def package(): + """Add, update, or remove packages in marketplace authoring config.""" + + + +from .add import add # noqa: E402 +from .remove import remove # noqa: E402 +from .set import set_cmd # noqa: E402 + +__all__ = [ + "package", + "add", + "set_cmd", + "remove", + "_SHA_RE", + "_yml_path", + "_ensure_yml_exists", + "_has_marketplace_block", + "_parse_tags", + "_verify_source", + "_resolve_ref", +] + diff --git a/src/apm_cli/commands/marketplace/plugin/add.py b/src/apm_cli/commands/marketplace/plugin/add.py new file mode 100644 index 000000000..33e9e6397 --- /dev/null +++ b/src/apm_cli/commands/marketplace/plugin/add.py @@ -0,0 +1,90 @@ +"""``apm marketplace package add`` command.""" + +from __future__ import annotations + +import sys + +import click + + +from ....core.command_logger import CommandLogger +from ....marketplace.errors import MarketplaceYmlError +from . import ( + package, + _ensure_yml_exists, + _parse_tags, + _resolve_ref, + _verify_source, +) + +@package.command(help="Add a package to marketplace authoring config") +@click.argument("source") +@click.option("--name", default=None, help="Package name (default: repo name)") +@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") +@click.option( + "--ref", + default=None, + help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", +) +@click.option("-s", "--subdir", default=None, help="Subdirectory inside source repo") +@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") +@click.option("--tags", default=None, help="Comma-separated tags") +@click.option( + "--include-prerelease", is_flag=True, help="Include prerelease versions" +) +@click.option("--no-verify", is_flag=True, help="Skip remote reachability check") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def add( + source, + name, + version, + ref, + subdir, + tag_pattern, + tags, + include_prerelease, + no_verify, + verbose, +): + """Add a package entry to marketplace authoring config.""" + from ....marketplace.yml_editor import add_plugin_entry + + logger = CommandLogger("marketplace-package-add", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # --version and --ref are mutually exclusive. + if version and ref: + raise click.UsageError( + "--version and --ref are mutually exclusive. " + "Use --version for semver ranges or --ref for git refs." + ) + + parsed_tags = _parse_tags(tags) + + # Verify source reachability unless skipped. + if not no_verify: + _verify_source(logger, source) + + # Resolve mutable refs to concrete SHAs. + ref = _resolve_ref(logger, source, ref, version, no_verify) + + try: + resolved_name = add_plugin_entry( + yml, + source=source, + name=name, + version=version, + ref=ref, + subdir=subdir, + tag_pattern=tag_pattern, + tags=parsed_tags, + include_prerelease=include_prerelease, + ) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success( + f"Added package '{resolved_name}' from {source}", + symbol="check", + ) diff --git a/src/apm_cli/commands/marketplace/plugin/remove.py b/src/apm_cli/commands/marketplace/plugin/remove.py new file mode 100644 index 000000000..878fab738 --- /dev/null +++ b/src/apm_cli/commands/marketplace/plugin/remove.py @@ -0,0 +1,52 @@ +"""``apm marketplace package remove`` command.""" + +from __future__ import annotations + +import sys + +import click + + +from ....core.command_logger import CommandLogger +from ....marketplace.errors import MarketplaceYmlError +from . import ( + package, + _ensure_yml_exists, + _is_interactive, +) + +@package.command(help="Remove a package from marketplace authoring config") +@click.argument("name") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def remove(name, yes, verbose): + """Remove a package entry from marketplace.yml.""" + from ....marketplace.yml_editor import remove_plugin_entry + + logger = CommandLogger("marketplace-package-remove", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # Confirmation gate. + if not yes: + if not _is_interactive(): + logger.error( + "Use --yes to skip confirmation in non-interactive mode", + symbol="error", + ) + sys.exit(1) + try: + click.confirm( + f"Remove package '{name}' from marketplace authoring config?", + abort=True, + ) + except click.Abort: + logger.progress("Cancelled.", symbol="info") + return + + try: + remove_plugin_entry(yml, name) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success(f"Removed package '{name}'", symbol="check") diff --git a/src/apm_cli/commands/marketplace/plugin/set.py b/src/apm_cli/commands/marketplace/plugin/set.py new file mode 100644 index 000000000..a05b8ffea --- /dev/null +++ b/src/apm_cli/commands/marketplace/plugin/set.py @@ -0,0 +1,112 @@ +"""``apm marketplace package set`` command.""" + +from __future__ import annotations + +import sys + +import click + + +from ....core.command_logger import CommandLogger +from ....marketplace.errors import MarketplaceYmlError +from . import ( + package, + _ensure_yml_exists, + _parse_tags, + _resolve_ref, + _SHA_RE, +) + +@package.command("set", help="Update a package entry in marketplace authoring config") +@click.argument("name") +@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") +@click.option( + "--ref", + default=None, + help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", +) +@click.option("--subdir", default=None, help="Subdirectory inside source repo") +@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") +@click.option("--tags", default=None, help="Comma-separated tags") +@click.option( + "--include-prerelease", + is_flag=True, + default=None, + help="Include prerelease versions", +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def set_cmd( + name, + version, + ref, + subdir, + tag_pattern, + tags, + include_prerelease, + verbose, +): + """Update fields on an existing package entry.""" + from ....marketplace.yml_editor import update_plugin_entry + + logger = CommandLogger("marketplace-package-set", verbose=verbose) + yml = _ensure_yml_exists(logger) + + # --version and --ref are mutually exclusive. + if version and ref: + raise click.UsageError( + "--version and --ref are mutually exclusive. " + "Use --version for semver ranges or --ref for git refs." + ) + + # Resolve mutable refs to concrete SHAs. + if ref is not None and not _SHA_RE.match(ref): + from ....marketplace.yml_schema import ( + load_marketplace_from_apm_yml, + load_marketplace_yml, + ) + + if yml.name == "apm.yml": + yml_data = load_marketplace_from_apm_yml(yml) + else: + yml_data = load_marketplace_yml(yml) + source = None + for pkg in yml_data.packages: + if pkg.name.lower() == name.lower(): + source = pkg.source + break + if source is None: + logger.error(f"Package '{name}' not found", symbol="error") + sys.exit(2) + ref = _resolve_ref(logger, source, ref, version, no_verify=False) + + parsed_tags = _parse_tags(tags) + + fields = {} + if version is not None: + fields["version"] = version + if ref is not None: + fields["ref"] = ref + if subdir is not None: + fields["subdir"] = subdir + if tag_pattern is not None: + fields["tag_pattern"] = tag_pattern + if parsed_tags is not None: + fields["tags"] = parsed_tags + if include_prerelease is not None: + fields["include_prerelease"] = include_prerelease + + if not fields: + logger.error( + "No fields specified. Pass at least one option " + "(e.g. --version, --ref, --subdir).", + symbol="error", + ) + sys.exit(1) + + try: + update_plugin_entry(yml, name, **fields) + except MarketplaceYmlError as exc: + logger.error(str(exc), symbol="error") + sys.exit(2) + + logger.success(f"Updated package '{name}'", symbol="check") diff --git a/src/apm_cli/commands/marketplace/publish.py b/src/apm_cli/commands/marketplace/publish.py new file mode 100644 index 000000000..7d4ef1780 --- /dev/null +++ b/src/apm_cli/commands/marketplace/publish.py @@ -0,0 +1,237 @@ +"""``apm marketplace publish`` command.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from ...core.command_logger import CommandLogger +from ...marketplace.pr_integration import PrIntegrator, PrResult, PrState +from ...marketplace.publisher import MarketplacePublisher, PublishOutcome +from .._helpers import _get_console, _is_interactive +from . import ( + marketplace, + _load_targets_file, + _load_config_or_exit, + _render_publish_plan, + _render_publish_summary, +) + + +@marketplace.command(help="Publish marketplace updates to consumer repositories") +@click.option( + "--targets", + "targets_file", + default=None, + type=click.Path(exists=False), + help="Path to consumer-targets YAML file (default: ./consumer-targets.yml)", +) +@click.option("--dry-run", is_flag=True, help="Preview without pushing or opening PRs") +@click.option("--no-pr", is_flag=True, help="Push branches but skip PR creation") +@click.option("--draft", is_flag=True, help="Create PRs as drafts") +@click.option("--allow-downgrade", is_flag=True, help="Allow version downgrades") +@click.option("--allow-ref-change", is_flag=True, help="Allow switching ref types") +@click.option( + "--parallel", + default=4, + show_default=True, + type=int, + help="Maximum number of concurrent target updates", +) +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def publish( + targets_file, + dry_run, + no_pr, + draft, + allow_downgrade, + allow_ref_change, + parallel, + yes, + verbose, +): + """Publish marketplace updates to consumer repositories.""" + logger = CommandLogger("marketplace-publish", verbose=verbose) + + # ------------------------------------------------------------------ + # 1. Pre-flight checks + # ------------------------------------------------------------------ + + # 1a. Load marketplace authoring config + _load_config_or_exit(logger) + + # 1b. Load marketplace.json + mkt_json_path = Path.cwd() / "marketplace.json" + if not mkt_json_path.exists(): + logger.error( + "marketplace.json not found. Run 'apm pack' first.", + symbol="error", + ) + sys.exit(1) + + # 1c. Load targets + if targets_file: + targets_path = Path(targets_file) + if not targets_path.exists(): + logger.error( + f"Targets file not found: {targets_file}", + symbol="error", + ) + sys.exit(1) + else: + targets_path = Path.cwd() / "consumer-targets.yml" + if not targets_path.exists(): + logger.error( + "No consumer-targets.yml found. " + "Create one or pass --targets .\n" + "\n" + "Example consumer-targets.yml:\n" + " targets:\n" + " - repo: acme-org/service-a\n" + " branch: main\n" + " - repo: acme-org/service-b\n" + " branch: develop", + symbol="error", + ) + sys.exit(1) + + targets, error = _load_targets_file(targets_path) + if error: + logger.error(error, symbol="error") + sys.exit(1) + + # 1d. Check gh availability (unless --no-pr) + pr = None + if not no_pr: + pr = PrIntegrator() + available, hint = pr.check_available() + if not available: + logger.error(hint, symbol="error") + sys.exit(1) + + # ------------------------------------------------------------------ + # 2. Plan and confirm + # ------------------------------------------------------------------ + + publisher = MarketplacePublisher(Path.cwd()) + plan = publisher.plan( + targets, + allow_downgrade=allow_downgrade, + allow_ref_change=allow_ref_change, + ) + + # Render publish plan + _render_publish_plan(logger, plan) + + # Confirmation logic + if not yes: + if not _is_interactive(): + logger.error( + "Non-interactive session: pass --yes to confirm the publish.", + symbol="error", + ) + sys.exit(1) + try: + if not click.confirm( + f"Confirm publish to {len(targets)} repositories?", + default=False, + ): + logger.progress("Publish cancelled.", symbol="info") + sys.exit(0) + except click.Abort: + logger.progress("Publish cancelled.", symbol="info") + sys.exit(0) + + if dry_run: + logger.progress( + "Dry run: no branches will be pushed and no PRs will be opened.", + symbol="info", + ) + + # ------------------------------------------------------------------ + # 3. Execute publish + # ------------------------------------------------------------------ + + results = publisher.execute(plan, dry_run=dry_run, parallel=parallel) + + # PR integration + pr_results = [] + if not no_pr: + if pr is None: + pr = PrIntegrator() + + for result in results: + if dry_run: + # In dry-run, preview what PR would do for UPDATED targets + if result.outcome == PublishOutcome.UPDATED: + pr_result = pr.open_or_update( + plan, + result.target, + result, + no_pr=False, + draft=draft, + dry_run=True, + ) + pr_results.append(pr_result) + else: + pr_results.append(PrResult( + target=result.target, + state=PrState.SKIPPED, + pr_number=None, + pr_url=None, + message=f"No PR needed: {result.outcome.value}", + )) + else: + if result.outcome == PublishOutcome.UPDATED: + pr_result = pr.open_or_update( + plan, + result.target, + result, + no_pr=False, + draft=draft, + dry_run=False, + ) + pr_results.append(pr_result) + else: + pr_results.append(PrResult( + target=result.target, + state=PrState.SKIPPED, + pr_number=None, + pr_url=None, + message=f"No PR needed: {result.outcome.value}", + )) + + # ------------------------------------------------------------------ + # 4. Summary rendering + # ------------------------------------------------------------------ + + _render_publish_summary(logger, results, pr_results, no_pr, dry_run) + + # State file path -- use soft_wrap so the path is never split mid-word + # in narrow terminals (Rich would otherwise break at hyphens). + state_path = Path.cwd() / ".apm" / "publish-state.json" + try: + from rich.text import Text + + console = _get_console() + if console is not None: + console.print( + Text(f"[i] State file: {state_path}", no_wrap=True), + style="blue", + highlight=False, + soft_wrap=True, + ) + else: + logger.progress(f"State file: {state_path}", symbol="info") + except Exception: # noqa: BLE001 -- best-effort Rich rendering fallback + logger.progress(f"State file: {state_path}", symbol="info") + + # Exit code + failed_count = sum( + 1 for r in results if r.outcome == PublishOutcome.FAILED + ) + if failed_count > 0: + sys.exit(1) diff --git a/src/apm_cli/commands/marketplace/validate.py b/src/apm_cli/commands/marketplace/validate.py new file mode 100644 index 000000000..6f3bb5dd2 --- /dev/null +++ b/src/apm_cli/commands/marketplace/validate.py @@ -0,0 +1,93 @@ +"""``apm marketplace validate`` command.""" + +from __future__ import annotations + +import sys +import traceback + +import click + +from ...core.command_logger import CommandLogger +from . import marketplace + +@marketplace.command(help="Validate a marketplace manifest") +@click.argument("name", required=True) +@click.option( + "--check-refs", is_flag=True, hidden=True, help="Verify version refs are reachable (network)" +) +@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +def validate(name, check_refs, verbose): + """Validate the manifest of a registered marketplace.""" + logger = CommandLogger("marketplace-validate", verbose=verbose) + try: + from ...marketplace.client import fetch_marketplace + from ...marketplace.registry import get_marketplace_by_name + from ...marketplace.validator import validate_marketplace + + source = get_marketplace_by_name(name) + logger.start(f"Validating marketplace '{name}'...", symbol="gear") + + manifest = fetch_marketplace(source, force_refresh=True) + + logger.progress( + f"Found {len(manifest.plugins)} plugins", + symbol="info", + ) + + # Verbose: per-plugin details + if verbose: + for p in manifest.plugins: + source_type = "dict" if isinstance(p.source, dict) else "string" + logger.verbose_detail( + f" {p.name}: source type: {source_type}" + ) + + # Run validation + results = validate_marketplace(manifest) + + # Check-refs placeholder + if check_refs: + logger.warning( + "Ref checking not yet implemented -- skipping ref " + "reachability checks", + symbol="warning", + ) + + # Render results + passed = 0 + warning_count = 0 + error_count = 0 + logger.blank_line() + logger.progress("Validation Results:", symbol="info") + for r in results: + if r.passed and not r.warnings: + logger.success( + f" {r.check_name}: all plugins valid", symbol="check" + ) + passed += 1 + elif r.warnings and not r.errors: + for w in r.warnings: + logger.warning(f" {r.check_name}: {w}", symbol="warning") + warning_count += len(r.warnings) + else: + for e in r.errors: + logger.error(f" {r.check_name}: {e}", symbol="error") + for w in r.warnings: + logger.warning(f" {r.check_name}: {w}", symbol="warning") + error_count += len(r.errors) + warning_count += len(r.warnings) + + logger.blank_line() + logger.progress( + f"Summary: {passed} passed, {warning_count} warnings, " + f"{error_count} errors", + symbol="info", + ) + + if error_count > 0: + sys.exit(1) + + except Exception as e: # noqa: BLE001 -- top-level command catch-all + logger.error(f"Failed to validate marketplace: {e}", symbol="error") + logger.verbose_detail(traceback.format_exc()) + sys.exit(1) diff --git a/src/apm_cli/commands/marketplace_plugin.py b/src/apm_cli/commands/marketplace_plugin.py deleted file mode 100644 index d285ca990..000000000 --- a/src/apm_cli/commands/marketplace_plugin.py +++ /dev/null @@ -1,451 +0,0 @@ -"""``apm marketplace package {add,set,remove}`` subgroup. - -Lets maintainers programmatically manage package entries in -``marketplace.yml`` instead of hand-editing YAML. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -import click - -from ..core.command_logger import CommandLogger -from ..marketplace.errors import ( - GitLsRemoteError, - MarketplaceYmlError, - OfflineMissError, -) -from ._helpers import _is_interactive - - -# ------------------------------------------------------------------- -# Constants -# ------------------------------------------------------------------- - -_SHA_RE = re.compile(r"^[0-9a-f]{40}$") - - -# ------------------------------------------------------------------- -# Helpers -# ------------------------------------------------------------------- - - -def _yml_path() -> Path: - """Return the path to the active marketplace authoring config. - - Prefers ``apm.yml`` when it has a ``marketplace:`` block; falls back - to legacy ``marketplace.yml`` otherwise. Returns the apm.yml path - when both files are absent (so callers can produce a consistent - error message). - """ - cwd = Path.cwd() - apm_path = cwd / "apm.yml" - legacy_path = cwd / "marketplace.yml" - - # Detect apm.yml with marketplace block. - if apm_path.exists(): - try: - import yaml - text = apm_path.read_text(encoding="utf-8") - data = yaml.safe_load(text) - if isinstance(data, dict) and "marketplace" in data \ - and data["marketplace"] is not None: - return apm_path - except (OSError, yaml.YAMLError): - pass - if legacy_path.exists(): - return legacy_path - return apm_path - - -def _ensure_yml_exists(logger: CommandLogger) -> Path: - """Return the yml path or exit with guidance if it does not exist.""" - cwd = Path.cwd() - apm_path = cwd / "apm.yml" - legacy_path = cwd / "marketplace.yml" - - # Hard error when both files are present. - if apm_path.exists(): - try: - import yaml - data = yaml.safe_load(apm_path.read_text(encoding="utf-8")) - has_block = isinstance(data, dict) and "marketplace" in data \ - and data["marketplace"] is not None - except (OSError, yaml.YAMLError): - has_block = False - if has_block and legacy_path.exists(): - logger.error( - "Both apm.yml (with a 'marketplace:' block) and " - "marketplace.yml exist. Remove marketplace.yml or run " - "'apm marketplace migrate --force' to consolidate.", - symbol="error", - ) - sys.exit(1) - - path = _yml_path() - if not path.exists() or ( - path == apm_path and path.exists() and not _has_marketplace_block(path) - ): - logger.error( - "No marketplace authoring config found. " - "Run 'apm marketplace init' to scaffold one.", - symbol="error", - ) - sys.exit(1) - return path - - -def _has_marketplace_block(apm_path: Path) -> bool: - """Return True when *apm_path* has a populated ``marketplace:`` block.""" - try: - import yaml - data = yaml.safe_load(apm_path.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError): - return False - return isinstance(data, dict) and "marketplace" in data and \ - data["marketplace"] is not None - - -def _parse_tags(raw: str | None) -> list[str] | None: - """Split a comma-separated tag string into a list, or return None.""" - if raw is None: - return None - parts = [t.strip() for t in raw.split(",") if t.strip()] - return parts if parts else None - - -def _verify_source(logger: CommandLogger, source: str) -> None: - """Run ``git ls-remote`` against *source* to verify reachability.""" - from ..marketplace.ref_resolver import RefResolver - - resolver = RefResolver() - try: - resolver.list_remote_refs(source) - except GitLsRemoteError as exc: - logger.error( - f"Source '{source}' is not reachable: {exc}", - symbol="error", - ) - sys.exit(2) - except OfflineMissError: - logger.warning( - f"Cannot verify source '{source}' (offline / no cache).", - symbol="warning", - ) - - -def _resolve_ref( - logger: CommandLogger, - source: str, - ref: str | None, - version: str | None, - no_verify: bool, -) -> str | None: - """Resolve *ref* to a concrete SHA when it is mutable. - - Returns the (possibly resolved) ref string, or ``None`` when - *version* is set (version-based pinning, no ref needed). - """ - from ..marketplace.ref_resolver import RefResolver - - # Version-based — no ref resolution needed. - if version is not None: - return None - - # Already a concrete SHA — store as-is. - if ref is not None and _SHA_RE.match(ref): - return ref - - # HEAD (explicit or implicit) requires network access. - is_head = ref is None or ref.upper() == "HEAD" - if is_head: - if no_verify: - logger.error( - "Cannot resolve HEAD ref without network access. " - "Provide an explicit --ref SHA.", - symbol="error", - ) - sys.exit(2) - if ref is not None: - logger.warning( - "'HEAD' is a mutable ref. Resolving to current SHA for safety.", - symbol="warning", - ) - resolver = RefResolver() - try: - sha = resolver.resolve_ref_sha(source, "HEAD") - except GitLsRemoteError as exc: - logger.error( - f"Failed to resolve HEAD for '{source}': {exc}", - symbol="error", - ) - sys.exit(2) - logger.progress( - f"Resolved HEAD to {sha[:12]}", - symbol="info", - ) - return sha - - # Non-HEAD, non-SHA ref — check whether it is a branch name. - resolver = RefResolver() - try: - remote_refs = resolver.list_remote_refs(source) - except (GitLsRemoteError, OfflineMissError): - # Cannot verify — store as-is but warn the user. - logger.warning( - f"Could not verify ref '{ref}' for '{source}' (network unavailable). " - "Storing unresolved -- run with network access to pin a concrete SHA.", - symbol="warning", - ) - return ref - - for remote_ref in remote_refs: - if remote_ref.name == f"refs/heads/{ref}": - if no_verify: - logger.error( - "Cannot resolve branch ref without network access. " - "Provide an explicit --ref SHA.", - symbol="error", - ) - sys.exit(2) - logger.warning( - f"'{ref}' is a branch (mutable ref). " - "Resolving to current SHA for safety.", - symbol="warning", - ) - logger.progress( - f"Resolved {ref} to {remote_ref.sha[:12]}", - symbol="info", - ) - return remote_ref.sha - - # Not a branch — tag or unknown ref; store as-is. - return ref - - -# ------------------------------------------------------------------- -# Click group -# ------------------------------------------------------------------- - - -@click.group(help="Manage packages in marketplace.yml (add, set, remove)") -def package(): - """Add, update, or remove packages in marketplace.yml.""" - - - -# ------------------------------------------------------------------- -# package add -# ------------------------------------------------------------------- - - -@package.command(help="Add a package to marketplace.yml") -@click.argument("source") -@click.option("--name", default=None, help="Package name (default: repo name)") -@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") -@click.option( - "--ref", - default=None, - help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", -) -@click.option("-s", "--subdir", default=None, help="Subdirectory inside source repo") -@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") -@click.option("--tags", default=None, help="Comma-separated tags") -@click.option( - "--include-prerelease", is_flag=True, help="Include prerelease versions" -) -@click.option("--no-verify", is_flag=True, help="Skip remote reachability check") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def add( - source, - name, - version, - ref, - subdir, - tag_pattern, - tags, - include_prerelease, - no_verify, - verbose, -): - """Add a package entry to marketplace.yml.""" - from ..marketplace.yml_editor import add_plugin_entry - - logger = CommandLogger("marketplace-package-add", verbose=verbose) - yml = _ensure_yml_exists(logger) - - # --version and --ref are mutually exclusive. - if version and ref: - raise click.UsageError( - "--version and --ref are mutually exclusive. " - "Use --version for semver ranges or --ref for git refs." - ) - - parsed_tags = _parse_tags(tags) - - # Verify source reachability unless skipped. - if not no_verify: - _verify_source(logger, source) - - # Resolve mutable refs to concrete SHAs. - ref = _resolve_ref(logger, source, ref, version, no_verify) - - try: - resolved_name = add_plugin_entry( - yml, - source=source, - name=name, - version=version, - ref=ref, - subdir=subdir, - tag_pattern=tag_pattern, - tags=parsed_tags, - include_prerelease=include_prerelease, - ) - except MarketplaceYmlError as exc: - logger.error(str(exc), symbol="error") - sys.exit(2) - - logger.success( - f"Added package '{resolved_name}' from {source}", - symbol="check", - ) - - -# ------------------------------------------------------------------- -# package set -# ------------------------------------------------------------------- - - -@package.command("set", help="Update a package entry in marketplace.yml") -@click.argument("name") -@click.option("--version", default=None, help="Semver range (e.g. '>=1.0.0')") -@click.option( - "--ref", - default=None, - help="Pin to a git ref (SHA, tag, or HEAD). Mutable refs are auto-resolved to SHA.", -) -@click.option("--subdir", default=None, help="Subdirectory inside source repo") -@click.option("--tag-pattern", default=None, help="Tag pattern (e.g. 'v{version}')") -@click.option("--tags", default=None, help="Comma-separated tags") -@click.option( - "--include-prerelease", - is_flag=True, - default=None, - help="Include prerelease versions", -) -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def set_cmd( - name, - version, - ref, - subdir, - tag_pattern, - tags, - include_prerelease, - verbose, -): - """Update fields on an existing package entry.""" - from ..marketplace.yml_editor import update_plugin_entry - - logger = CommandLogger("marketplace-package-set", verbose=verbose) - yml = _ensure_yml_exists(logger) - - # --version and --ref are mutually exclusive. - if version and ref: - raise click.UsageError( - "--version and --ref are mutually exclusive. " - "Use --version for semver ranges or --ref for git refs." - ) - - # Resolve mutable refs to concrete SHAs. - if ref is not None and not _SHA_RE.match(ref): - from ..marketplace.yml_schema import load_marketplace_yml - - yml_data = load_marketplace_yml(yml) - source = None - for pkg in yml_data.packages: - if pkg.name.lower() == name.lower(): - source = pkg.source - break - if source is None: - logger.error(f"Package '{name}' not found", symbol="error") - sys.exit(2) - ref = _resolve_ref(logger, source, ref, version, no_verify=False) - - parsed_tags = _parse_tags(tags) - - fields = {} - if version is not None: - fields["version"] = version - if ref is not None: - fields["ref"] = ref - if subdir is not None: - fields["subdir"] = subdir - if tag_pattern is not None: - fields["tag_pattern"] = tag_pattern - if parsed_tags is not None: - fields["tags"] = parsed_tags - if include_prerelease is not None: - fields["include_prerelease"] = include_prerelease - - if not fields: - logger.error( - "No fields specified. Pass at least one option " - "(e.g. --version, --ref, --subdir).", - symbol="error", - ) - sys.exit(1) - - try: - update_plugin_entry(yml, name, **fields) - except MarketplaceYmlError as exc: - logger.error(str(exc), symbol="error") - sys.exit(2) - - logger.success(f"Updated package '{name}'", symbol="check") - - -# ------------------------------------------------------------------- -# package remove -# ------------------------------------------------------------------- - - -@package.command(help="Remove a package from marketplace.yml") -@click.argument("name") -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -@click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def remove(name, yes, verbose): - """Remove a package entry from marketplace.yml.""" - from ..marketplace.yml_editor import remove_plugin_entry - - logger = CommandLogger("marketplace-package-remove", verbose=verbose) - yml = _ensure_yml_exists(logger) - - # Confirmation gate. - if not yes: - if not _is_interactive(): - logger.error( - "Use --yes to skip confirmation in non-interactive mode", - symbol="error", - ) - sys.exit(1) - try: - click.confirm( - f"Remove package '{name}' from marketplace.yml?", - abort=True, - ) - except click.Abort: - logger.progress("Cancelled.", symbol="info") - return - - try: - remove_plugin_entry(yml, name) - except MarketplaceYmlError as exc: - logger.error(str(exc), symbol="error") - sys.exit(2) - - logger.success(f"Removed package '{name}'", symbol="check") diff --git a/src/apm_cli/core/command_logger.py b/src/apm_cli/core/command_logger.py index 5a95d0eb0..581622b98 100644 --- a/src/apm_cli/core/command_logger.py +++ b/src/apm_cli/core/command_logger.py @@ -111,6 +111,10 @@ def tree_item(self, message: str): """ _rich_echo(message, color="green") + def blank_line(self): + """Log a blank line through the shared console output path.""" + _rich_echo("") + def package_inline_warning(self, message: str): """Log an inline warning under a package block (verbose only). diff --git a/tests/integration/marketplace/README.md b/tests/integration/marketplace/README.md index 40f772d9a..00e880cd2 100644 --- a/tests/integration/marketplace/README.md +++ b/tests/integration/marketplace/README.md @@ -146,7 +146,7 @@ APM-only keys (`subdir`, `version`, `ref` in yml, `tag_pattern`, | Unit tests fail | Library logic | Check the specific unit test file in tests/unit/marketplace/. | | Integration tests fail on yml parse | yml_schema.py | Confirm the test fixture YAML is valid. | | Integration tests fail on JSON content | builder.compose_marketplace_json | Check key order and golden fixture. | -| Integration tests fail on exit code | CLI command handler | Inspect the sys.exit() paths in commands/marketplace.py. | +| Integration tests fail on exit code | CLI command handler | Inspect the sys.exit() paths in the relevant module under src/apm_cli/commands/marketplace/. | | Integration tests fail on mock | conftest.py fixture | Confirm mock_ref_resolver patches the right import path. | | Live tests fail on resolution | Real remote | Check that APM_E2E_MARKETPLACE points to a valid repo with tags. | | Live tests fail on timeout | Network or rate limit | Increase timeout or set GITHUB_TOKEN to raise rate limit. | diff --git a/tests/integration/marketplace/test_publish_integration.py b/tests/integration/marketplace/test_publish_integration.py index ed974d6b5..bd9303094 100644 --- a/tests/integration/marketplace/test_publish_integration.py +++ b/tests/integration/marketplace/test_publish_integration.py @@ -168,23 +168,23 @@ def _run_publish(tmp_path: Path, extra_args=(), mock_plan=None, mock_results=Non with ( patch( - "apm_cli.commands.marketplace.MarketplacePublisher.plan", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", return_value=plan, ), patch( - "apm_cli.commands.marketplace.MarketplacePublisher.execute", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", return_value=results, ), patch( - "apm_cli.commands.marketplace.PrIntegrator.check_available", + "apm_cli.commands.marketplace.publish.PrIntegrator.check_available", return_value=(mock_pr_available, "gh available"), ), patch( - "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", side_effect=pr_results, ), patch( - "apm_cli.commands.marketplace._is_interactive", + "apm_cli.commands.marketplace.publish._is_interactive", return_value=False, ), patch.dict(os.environ, env, clear=False), @@ -258,23 +258,23 @@ def test_dry_run_forwarded_to_execute(self, tmp_path: Path): with ( patch( - "apm_cli.commands.marketplace.MarketplacePublisher.plan", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", return_value=plan, ), patch( - "apm_cli.commands.marketplace.MarketplacePublisher.execute", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", execute_mock, ), patch( - "apm_cli.commands.marketplace.PrIntegrator.check_available", + "apm_cli.commands.marketplace.publish.PrIntegrator.check_available", return_value=(True, "ok"), ), patch( - "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", return_value=_make_pr_result("consumer-org/service-a", PrState.SKIPPED), ), patch( - "apm_cli.commands.marketplace._is_interactive", + "apm_cli.commands.marketplace.publish._is_interactive", return_value=False, ), ): @@ -384,21 +384,21 @@ def test_no_pr_skips_pr_integrator(self, tmp_path: Path): with ( patch( - "apm_cli.commands.marketplace.MarketplacePublisher.plan", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.plan", return_value=plan, ), patch( - "apm_cli.commands.marketplace.MarketplacePublisher.execute", + "apm_cli.commands.marketplace.publish.MarketplacePublisher.execute", return_value=[ _make_target_result("consumer-org/service-a", PublishOutcome.UPDATED) ], ), patch( - "apm_cli.commands.marketplace.PrIntegrator.open_or_update", + "apm_cli.commands.marketplace.publish.PrIntegrator.open_or_update", open_or_update_mock, ), patch( - "apm_cli.commands.marketplace._is_interactive", + "apm_cli.commands.marketplace.publish._is_interactive", return_value=False, ), ): diff --git a/tests/unit/commands/test_marketplace_check.py b/tests/unit/commands/test_marketplace_check.py index 36330cb61..266f6020d 100644 --- a/tests/unit/commands/test_marketplace_check.py +++ b/tests/unit/commands/test_marketplace_check.py @@ -100,7 +100,7 @@ def yml_cwd(tmp_path, monkeypatch): class TestCheckAllOK: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_all_entries_pass(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -110,7 +110,7 @@ def test_all_entries_pass(self, MockResolver, runner, yml_cwd): assert result.exit_code == 0 assert "All 2 entries OK" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_shows_package_names(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -120,7 +120,7 @@ def test_shows_package_names(self, MockResolver, runner, yml_cwd): assert "pkg-alpha" in result.output assert "pkg-beta" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_success_icon_shown(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -136,7 +136,7 @@ def test_success_icon_shown(self, MockResolver, runner, yml_cwd): class TestCheckExplicitRef: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_ref_found(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_YML_WITH_REF, encoding="utf-8") @@ -150,7 +150,7 @@ def test_ref_found(self, MockResolver, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "pinned-pkg" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_ref_not_found(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("COLUMNS", "200") @@ -173,7 +173,7 @@ def test_ref_not_found(self, MockResolver, runner, tmp_path, monkeypatch): class TestCheckFailures: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_one_failure_exits_1(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value # First package OK, second fails @@ -189,7 +189,7 @@ def test_one_failure_exits_1(self, MockResolver, runner, yml_cwd): assert result.exit_code == 1 assert "1 entries have issues" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_all_failures_exits_1(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = GitLsRemoteError( @@ -201,7 +201,7 @@ def test_all_failures_exits_1(self, MockResolver, runner, yml_cwd): assert result.exit_code == 1 assert "2 entries have issues" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_failure_icon_shown(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = GitLsRemoteError( @@ -212,7 +212,7 @@ def test_failure_icon_shown(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["check"]) assert "[x]" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_no_matching_version(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value # Return tags that don't match the version range @@ -251,7 +251,7 @@ def test_schema_error_exits_2(self, runner, tmp_path, monkeypatch): class TestCheckOffline: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_offline_label_shown(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = OfflineMissError( @@ -263,7 +263,7 @@ def test_offline_label_shown(self, MockResolver, runner, yml_cwd): assert "Offline mode" in result.output or "offline" in result.output.lower() MockResolver.assert_called_once_with(offline=True) - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_offline_cache_miss_fails_entry(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = OfflineMissError( @@ -281,7 +281,7 @@ def test_offline_cache_miss_fails_entry(self, MockResolver, runner, yml_cwd): class TestCheckVerbose: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_verbose_no_crash(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -297,7 +297,7 @@ def test_verbose_no_crash(self, MockResolver, runner, yml_cwd): class TestCheckResolverCleanup: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_resolver_close_called(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_GOOD, _REFS_BETA_GOOD] @@ -306,7 +306,7 @@ def test_resolver_close_called(self, MockResolver, runner, yml_cwd): runner.invoke(marketplace, ["check"]) mock_inst.close.assert_called_once() - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_resolver_close_on_failure(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = GitLsRemoteError( @@ -324,7 +324,7 @@ def test_resolver_close_on_failure(self, MockResolver, runner, yml_cwd): class TestCheckEdgeCases: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_single_entry_all_ok(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_YML_SINGLE, encoding="utf-8") @@ -336,7 +336,7 @@ def test_single_entry_all_ok(self, MockResolver, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "All 1 entries OK" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.check.RefResolver") def test_generic_exception_handled(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = RuntimeError("Unexpected") @@ -355,8 +355,8 @@ def test_generic_exception_handled(self, MockResolver, runner, yml_cwd): class TestCheckDuplicateNames: """Defence-in-depth duplicate name check in the check command.""" - @patch("apm_cli.commands.marketplace.RefResolver") - @patch("apm_cli.commands.marketplace.load_marketplace_config") + @patch("apm_cli.commands.marketplace.check.RefResolver") + @patch("apm_cli.commands.marketplace.check._load_config_or_exit") def test_duplicate_names_warned( self, mock_load, MockResolver, runner, tmp_path, monkeypatch, ): @@ -364,7 +364,7 @@ def test_duplicate_names_warned( (tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8") # Return a MarketplaceYml with duplicate package names - mock_load.return_value = MarketplaceYml( + mock_load.return_value = (tmp_path, MarketplaceYml( name="test", description="Test", version="1.0.0", @@ -379,7 +379,7 @@ def test_duplicate_names_warned( version="^1.0.0", ), ), - ) + )) mock_inst = MockResolver.return_value mock_inst.list_remote_refs.return_value = [ @@ -390,15 +390,15 @@ def test_duplicate_names_warned( result = runner.invoke(marketplace, ["check"]) assert "Duplicate package name 'learning'" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") - @patch("apm_cli.commands.marketplace.load_marketplace_config") + @patch("apm_cli.commands.marketplace.check.RefResolver") + @patch("apm_cli.commands.marketplace.check._load_config_or_exit") def test_no_warning_when_unique( self, mock_load, MockResolver, runner, tmp_path, monkeypatch, ): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8") - mock_load.return_value = MarketplaceYml( + mock_load.return_value = (tmp_path, MarketplaceYml( name="test", description="Test", version="1.0.0", @@ -411,7 +411,7 @@ def test_no_warning_when_unique( name="beta", source="acme/beta", version="^1.0.0", ), ), - ) + )) mock_inst = MockResolver.return_value mock_inst.list_remote_refs.return_value = [ diff --git a/tests/unit/commands/test_marketplace_doctor.py b/tests/unit/commands/test_marketplace_doctor.py index fbb815599..f14c6e316 100644 --- a/tests/unit/commands/test_marketplace_doctor.py +++ b/tests/unit/commands/test_marketplace_doctor.py @@ -82,7 +82,7 @@ def _make_run_result(returncode=0, stdout="", stderr=""): class TestDoctorAllPass: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_all_pass_exit_0(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("GITHUB_TOKEN", "test-token") @@ -97,7 +97,7 @@ def test_all_pass_exit_0(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert result.exit_code == 0 - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_version_shown(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -109,7 +109,7 @@ def test_git_version_shown(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert "git version" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_network_reachable_shown(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -128,7 +128,7 @@ def test_network_reachable_shown(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorGitCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_missing_exits_1(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = FileNotFoundError("git not found") @@ -137,7 +137,7 @@ def test_git_missing_exits_1(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 1 assert "not found" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_timeout(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5) @@ -146,7 +146,7 @@ def test_git_timeout(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 1 assert "timed out" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -165,7 +165,7 @@ def test_git_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorNetworkCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_network_failure_exits_1(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -177,7 +177,7 @@ def test_network_failure_exits_1(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert result.exit_code == 1 - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_network_timeout(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -190,7 +190,7 @@ def test_network_timeout(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 1 assert "timed out" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_network_auth_error(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -209,7 +209,7 @@ def test_network_auth_error(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorAuthCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_github_token_detected(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123") @@ -224,7 +224,7 @@ def test_github_token_detected(self, mock_run, runner, tmp_path, monkeypatch): # Must NOT print the actual token assert "ghp_test123" not in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_token_detected(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.delenv("GITHUB_TOKEN", raising=False) @@ -239,7 +239,7 @@ def test_gh_token_detected(self, mock_run, runner, tmp_path, monkeypatch): assert "Token detected" in result.output assert "gho_test456" not in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_no_token_informational(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) # Override the autouse mock so AuthResolver reports no token. @@ -265,7 +265,7 @@ def test_no_token_informational(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorGhCliCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_found_shows_version(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -278,7 +278,7 @@ def test_gh_found_shows_version(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "gh version" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_missing_is_warning_not_error(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -292,7 +292,7 @@ def test_gh_missing_is_warning_not_error(self, mock_run, runner, tmp_path, monke assert "not found" in result.output.lower() assert "cli.github.com" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -305,7 +305,7 @@ def test_gh_nonzero_exit(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 # informational assert "non-zero" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_timeout(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -318,7 +318,7 @@ def test_gh_timeout(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 # informational assert "timed out" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_general_exception(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -331,7 +331,7 @@ def test_gh_general_exception(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 # informational assert "Permission denied" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_gh_shown_in_table(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -350,7 +350,7 @@ def test_gh_shown_in_table(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorYmlCheck: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_yml_present_and_valid(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") @@ -364,7 +364,7 @@ def test_yml_present_and_valid(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "valid" in result.output.lower() or "found" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_yml_present_but_invalid(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text("bad: true\n", encoding="utf-8") @@ -379,7 +379,7 @@ def test_yml_present_but_invalid(self, mock_run, runner, tmp_path, monkeypatch): assert result.exit_code == 0 assert "error" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_yml_absent(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -399,7 +399,7 @@ def test_yml_absent(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorExitCodes: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_yml_invalid_does_not_cause_exit_1(self, mock_run, runner, tmp_path, monkeypatch): """Check 5 is informational; invalid yml alone should not exit 1.""" monkeypatch.chdir(tmp_path) @@ -413,7 +413,7 @@ def test_yml_invalid_does_not_cause_exit_1(self, mock_run, runner, tmp_path, mon result = runner.invoke(marketplace, ["doctor"]) assert result.exit_code == 0 - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_fail_plus_valid_yml_exits_1(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_BASIC_YML, encoding="utf-8") @@ -429,7 +429,7 @@ def test_git_fail_plus_valid_yml_exits_1(self, mock_run, runner, tmp_path, monke class TestDoctorVerbose: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_verbose_no_crash(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -448,7 +448,7 @@ def test_verbose_no_crash(self, mock_run, runner, tmp_path, monkeypatch): class TestDoctorTable: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_table_has_check_column(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -463,7 +463,7 @@ def test_table_has_check_column(self, mock_run, runner, tmp_path, monkeypatch): assert "network" in result.output.lower() assert "auth" in result.output.lower() - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_info_icon_for_auth(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) monkeypatch.delenv("GITHUB_TOKEN", raising=False) @@ -477,7 +477,7 @@ def test_info_icon_for_auth(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert "[i]" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_pass_icon_for_git(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = [ @@ -489,7 +489,7 @@ def test_pass_icon_for_git(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert "[+]" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_fail_icon_for_git_missing(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = FileNotFoundError("not found") @@ -504,7 +504,7 @@ def test_fail_icon_for_git_missing(self, mock_run, runner, tmp_path, monkeypatch class TestDoctorEdgeCases: - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_general_exception_in_git_check(self, mock_run, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_run.side_effect = OSError("Permission denied") @@ -513,7 +513,7 @@ def test_general_exception_in_git_check(self, mock_run, runner, tmp_path, monkey assert result.exit_code == 1 assert "Permission denied" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_git_ok_network_file_not_found(self, mock_run, runner, tmp_path, monkeypatch): """When git works but network check raises FileNotFoundError.""" monkeypatch.chdir(tmp_path) @@ -535,8 +535,8 @@ def test_git_ok_network_file_not_found(self, mock_run, runner, tmp_path, monkeyp class TestDoctorDuplicateNames: """Defence-in-depth duplicate name check in the doctor command.""" - @patch("apm_cli.commands.marketplace.subprocess.run") - @patch("apm_cli.commands.marketplace.load_marketplace_yml") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.load_marketplace_yml") def test_duplicate_names_flagged( self, mock_load, mock_run, runner, tmp_path, monkeypatch, ): @@ -568,8 +568,8 @@ def test_duplicate_names_flagged( assert "duplicate" in result.output.lower() assert "learning" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") - @patch("apm_cli.commands.marketplace.load_marketplace_yml") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.load_marketplace_yml") def test_no_duplicate_names_shows_pass( self, mock_load, mock_run, runner, tmp_path, monkeypatch, ): @@ -599,7 +599,7 @@ def test_no_duplicate_names_shows_pass( assert result.exit_code == 0 assert "No duplicate package names" in result.output - @patch("apm_cli.commands.marketplace.subprocess.run") + @patch("apm_cli.commands.marketplace.doctor.subprocess.run") def test_no_duplicate_check_when_yml_absent( self, mock_run, runner, tmp_path, monkeypatch, ): diff --git a/tests/unit/commands/test_marketplace_outdated.py b/tests/unit/commands/test_marketplace_outdated.py index b1476f7e6..6021af6ba 100644 --- a/tests/unit/commands/test_marketplace_outdated.py +++ b/tests/unit/commands/test_marketplace_outdated.py @@ -103,7 +103,7 @@ def yml_cwd(tmp_path, monkeypatch): class TestOutdatedHappyPath: """outdated -- basic success.""" - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_shows_package_names(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -115,7 +115,7 @@ def test_shows_package_names(self, MockResolver, runner, yml_cwd): assert "pkg-alpha" in result.output assert "pkg-beta" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_shows_latest_in_range(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -127,7 +127,7 @@ def test_shows_latest_in_range(self, MockResolver, runner, yml_cwd): # v1.2.0 is highest in ^1.0.0 range assert "v1.2.0" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_shows_latest_overall(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -137,7 +137,7 @@ def test_shows_latest_overall(self, MockResolver, runner, yml_cwd): # v2.0.0 is highest overall for alpha assert "v2.0.0" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): """Exit code 1 when packages are outdated (CI-friendly).""" mock_inst = MockResolver.return_value @@ -147,7 +147,7 @@ def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["outdated"]) assert result.exit_code == 1 - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_with_marketplace_json_present(self, MockResolver, runner, yml_cwd): """Current versions read from marketplace.json.""" mkt = { @@ -177,7 +177,7 @@ def test_with_marketplace_json_present(self, MockResolver, runner, yml_cwd): class TestOutdatedRefPinned: """Entries with explicit ref: are skipped.""" - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_ref_pinned_shows_skip_note(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_YML_WITH_REF, encoding="utf-8") @@ -214,7 +214,7 @@ def test_schema_error_exits_2(self, runner, tmp_path, monkeypatch): class TestOutdatedOffline: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_offline_passed_to_resolver(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = OfflineMissError( @@ -233,7 +233,7 @@ def test_offline_passed_to_resolver(self, MockResolver, runner, yml_cwd): class TestOutdatedErrors: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_resolver_error_shows_in_table(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = GitLsRemoteError( @@ -245,7 +245,7 @@ def test_resolver_error_shows_in_table(self, MockResolver, runner, yml_cwd): assert result.exit_code == 0 assert "pkg-alpha" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_no_matching_tags(self, MockResolver, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) (tmp_path / "marketplace.yml").write_text(_YML_SINGLE, encoding="utf-8") @@ -267,7 +267,7 @@ def test_no_matching_tags(self, MockResolver, runner, tmp_path, monkeypatch): class TestOutdatedVerbose: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_verbose_shows_upgradable_count(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -285,7 +285,7 @@ def test_verbose_shows_upgradable_count(self, MockResolver, runner, yml_cwd): class TestOutdatedStatusSymbols: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_up_to_date_status(self, MockResolver, runner, yml_cwd): """When current == latest-in-range, status is [+].""" mkt = { @@ -304,7 +304,7 @@ def test_up_to_date_status(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["outdated"]) assert result.exit_code == 0 - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_major_upgrade_status(self, MockResolver, runner, yml_cwd): """When latest-overall differs from latest-in-range, status is [*].""" mock_inst = MockResolver.return_value @@ -324,7 +324,7 @@ def test_major_upgrade_status(self, MockResolver, runner, yml_cwd): class TestOutdatedResolverCleanup: - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_resolver_close_called(self, MockResolver, runner, yml_cwd): mock_inst = MockResolver.return_value mock_inst.list_remote_refs.side_effect = [_REFS_ALPHA, _REFS_BETA] @@ -342,7 +342,7 @@ def test_resolver_close_called(self, MockResolver, runner, yml_cwd): class TestOutdatedSummaryLine: """outdated -- summary line and CI exit code.""" - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_summary_line_when_outdated(self, MockResolver, runner, yml_cwd): """Summary line reports outdated and up-to-date counts.""" mock_inst = MockResolver.return_value @@ -352,7 +352,7 @@ def test_summary_line_when_outdated(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["outdated"]) assert "package(s) can be updated" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_exit_code_zero_when_up_to_date(self, MockResolver, runner, yml_cwd): """Exit code 0 when all packages are up to date.""" mkt = { @@ -372,7 +372,7 @@ def test_exit_code_zero_when_up_to_date(self, MockResolver, runner, yml_cwd): assert result.exit_code == 0 assert "All packages are up to date" in result.output - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): """Exit code 1 when packages are outdated (CI-friendly).""" mock_inst = MockResolver.return_value @@ -382,7 +382,7 @@ def test_exit_code_one_when_outdated(self, MockResolver, runner, yml_cwd): result = runner.invoke(marketplace, ["outdated"]) assert result.exit_code == 1 - @patch("apm_cli.commands.marketplace.RefResolver") + @patch("apm_cli.commands.marketplace.outdated.RefResolver") def test_summary_counts_up_to_date(self, MockResolver, runner, yml_cwd): """Up-to-date count reflects packages at latest in range.""" mkt = { diff --git a/tests/unit/commands/test_marketplace_plugin.py b/tests/unit/commands/test_marketplace_plugin.py index 6c07761ac..118de3aa7 100644 --- a/tests/unit/commands/test_marketplace_plugin.py +++ b/tests/unit/commands/test_marketplace_plugin.py @@ -10,7 +10,7 @@ from click.testing import CliRunner from apm_cli.commands.marketplace import marketplace -from apm_cli.commands.marketplace_plugin import _resolve_ref, _SHA_RE +from apm_cli.commands.marketplace.plugin import _resolve_ref, _SHA_RE from apm_cli.core.command_logger import CommandLogger from apm_cli.marketplace.ref_resolver import RemoteRef diff --git a/tests/unit/commands/test_marketplace_publish.py b/tests/unit/commands/test_marketplace_publish.py index 4069b88b1..9932d2ec4 100644 --- a/tests/unit/commands/test_marketplace_publish.py +++ b/tests/unit/commands/test_marketplace_publish.py @@ -112,8 +112,8 @@ def runner(): class TestPublishHappyPath: """Happy path: publish to 2 targets with PRs opened.""" - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_happy_path_exit_0( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -142,8 +142,8 @@ def test_happy_path_exit_0( assert "Published 2/2 targets" in result.output assert "publish-state.json" in result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_pr_integrator_called_for_updated_targets( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -179,7 +179,7 @@ def test_pr_integrator_called_for_updated_targets( class TestPublishNoPr: """--no-pr: publisher runs but PR integrator is not called.""" - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_no_pr_skips_pr_integration( self, MockPublisher, runner, tmp_path, monkeypatch ): @@ -196,7 +196,7 @@ def test_no_pr_skips_pr_integration( _fake_result(targets[1]), ] - with patch("apm_cli.commands.marketplace.PrIntegrator") as MockPr: + with patch("apm_cli.commands.marketplace.publish.PrIntegrator") as MockPr: result = runner.invoke(marketplace, ["publish", "--yes", "--no-pr"]) assert result.exit_code == 0, result.output # PrIntegrator should not have been instantiated for operations @@ -212,8 +212,8 @@ def test_no_pr_skips_pr_integration( class TestPublishDryRun: """--dry-run: publisher.execute with dry_run=True, PR with dry_run=True.""" - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_dry_run_passes_flag_to_execute( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -242,8 +242,8 @@ def test_dry_run_passes_flag_to_execute( plan, dry_run=True, parallel=4, ) - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_dry_run_passes_flag_to_pr_integration( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -270,8 +270,8 @@ def test_dry_run_passes_flag_to_pr_integration( for c in mock_pr.open_or_update.call_args_list: assert c.kwargs.get("dry_run") is True or c[1].get("dry_run") is True - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_dry_run_shows_info_note( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -342,8 +342,8 @@ def test_missing_targets_file_exit_1_with_guidance( assert "consumer-targets.yml" in result.output assert "--targets" in result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_explicit_targets_file( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -435,7 +435,7 @@ def test_target_missing_branch(self, runner, tmp_path, monkeypatch): class TestPublishGhAvailability: - @patch("apm_cli.commands.marketplace.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") def test_gh_not_available_exit_1( self, MockPr, runner, tmp_path, monkeypatch ): @@ -452,7 +452,7 @@ def test_gh_not_available_exit_1( assert result.exit_code == 1 assert "gh" in result.output.lower() - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_gh_not_available_but_no_pr_proceeds( self, MockPublisher, runner, tmp_path, monkeypatch ): @@ -478,9 +478,9 @@ def test_gh_not_available_but_no_pr_proceeds( class TestPublishInteractive: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") - @patch("apm_cli.commands.marketplace._is_interactive", return_value=False) + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=False) def test_non_tty_without_yes_exit_1( self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -498,9 +498,9 @@ def test_non_tty_without_yes_exit_1( assert result.exit_code == 1 assert "Non-interactive session" in result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") - @patch("apm_cli.commands.marketplace._is_interactive", return_value=False) + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=False) def test_non_tty_with_yes_proceeds( self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -522,9 +522,9 @@ def test_non_tty_with_yes_proceeds( result = runner.invoke(marketplace, ["publish", "--yes"]) assert result.exit_code == 0, result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") - @patch("apm_cli.commands.marketplace._is_interactive", return_value=True) + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=True) def test_tty_user_types_n_aborts_gracefully( self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -543,9 +543,9 @@ def test_tty_user_types_n_aborts_gracefully( assert "cancelled" in result.output.lower() mock_pub.execute.assert_not_called() - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") - @patch("apm_cli.commands.marketplace._is_interactive", return_value=True) + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish._is_interactive", return_value=True) def test_tty_user_types_y_proceeds( self, mock_interactive, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -577,8 +577,8 @@ def test_tty_user_types_y_proceeds( class TestPublishDraft: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_draft_passed_to_pr_integrator( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -611,8 +611,8 @@ def test_draft_passed_to_pr_integrator( class TestPublishPlanFlags: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_allow_downgrade_passed_to_plan( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -636,8 +636,8 @@ def test_allow_downgrade_passed_to_plan( _, kwargs = mock_pub.plan.call_args assert kwargs.get("allow_downgrade") is True - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_allow_ref_change_passed_to_plan( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -668,8 +668,8 @@ def test_allow_ref_change_passed_to_plan( class TestPublishParallel: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_parallel_passed_to_execute( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -701,8 +701,8 @@ def test_parallel_passed_to_execute( class TestPublishMixedOutcomes: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_mixed_outcomes_exit_1( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -744,8 +744,8 @@ def test_mixed_outcomes_exit_1( assert "acme-org/service-b" in result.output assert "acme-org/service-c" in result.output - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_summary_table_has_all_outcomes( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -793,8 +793,8 @@ def test_summary_table_has_all_outcomes( class TestPublishVerbose: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_verbose_does_not_crash( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -823,8 +823,8 @@ def test_verbose_does_not_crash( class TestPublishStateFile: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_state_file_path_printed( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -853,8 +853,8 @@ def test_state_file_path_printed( class TestPublishPlanRendering: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_plan_shows_marketplace_name( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -884,8 +884,8 @@ def test_plan_shows_marketplace_name( class TestPublishNoChange: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_all_no_change_exit_0( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ): @@ -915,7 +915,7 @@ def test_all_no_change_exit_0( class TestPublishDryRunNoPr: - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_dry_run_no_pr_exit_0( self, MockPublisher, runner, tmp_path, monkeypatch ): @@ -977,8 +977,8 @@ def test_empty_targets_list(self, runner, tmp_path, monkeypatch): class TestPublishDefaultFlags: - @patch("apm_cli.commands.marketplace.PrIntegrator") - @patch("apm_cli.commands.marketplace.MarketplacePublisher") + @patch("apm_cli.commands.marketplace.publish.PrIntegrator") + @patch("apm_cli.commands.marketplace.publish.MarketplacePublisher") def test_defaults_no_allow_downgrade_no_allow_ref_change( self, MockPublisher, MockPr, runner, tmp_path, monkeypatch ):