diff --git a/src/apm_cli/commands/init.py b/src/apm_cli/commands/init.py index 7582d3d61..5f82f645c 100644 --- a/src/apm_cli/commands/init.py +++ b/src/apm_cli/commands/init.py @@ -32,13 +32,18 @@ @click.option( "--plugin", is_flag=True, help="Initialize as plugin author (creates plugin.json + apm.yml)" ) +@click.option( + "--marketplace", "marketplace_flag", is_flag=True, + help="Seed apm.yml with a 'marketplace:' authoring block", +) @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") @click.pass_context -def init(ctx, project_name, yes, plugin, verbose): +def init(ctx, project_name, yes, plugin, marketplace_flag, verbose): """Initialize a new APM project (like npm init). Creates a minimal apm.yml with auto-detected metadata. With --plugin, also creates plugin.json for plugin authors. + With --marketplace, also seeds apm.yml with a marketplace authoring block. """ logger = CommandLogger("init", verbose=verbose) try: @@ -110,6 +115,22 @@ def init(ctx, project_name, yes, plugin, verbose): if plugin: _create_plugin_json(config) + # Append marketplace authoring block when requested. + if marketplace_flag: + from ..marketplace.init_template import render_marketplace_block + apm_yml_path = Path.cwd() / APM_YML_FILENAME + try: + existing = apm_yml_path.read_text(encoding="utf-8") + if not existing.endswith("\n"): + existing += "\n" + block = render_marketplace_block(owner=config.get("name")) + apm_yml_path.write_text(existing + "\n" + block, encoding="utf-8") + except OSError as exc: + logger.warning( + f"Failed to append marketplace block to apm.yml: {exc}", + symbol="warning", + ) + logger.success("APM project initialized successfully!") # Display created file info diff --git a/src/apm_cli/commands/marketplace.py b/src/apm_cli/commands/marketplace.py index 1ea8a7ccd..1706d8069 100644 --- a/src/apm_cli/commands/marketplace.py +++ b/src/apm_cli/commands/marketplace.py @@ -39,6 +39,13 @@ ) from ..marketplace.ref_resolver import RefResolver, RemoteRef from ..marketplace.semver import SemVer, parse_semver, satisfies_range +from ..marketplace.migration import ( + DEPRECATION_MESSAGE, + 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 @@ -123,6 +130,32 @@ def _load_yml_or_exit(logger): sys.exit(2) +def _load_config_or_exit(logger): + """Load the marketplace config from CWD (apm.yml or legacy marketplace.yml). + + 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. + """ + project_root = Path.cwd() + try: + config = load_marketplace_config( + project_root, + warn_callback=lambda msg: logger.warning(msg, symbol="warning"), + ) + except MarketplaceYmlError as exc: + msg = str(exc) + if msg.startswith("No marketplace config"): + logger.error(msg, symbol="error") + sys.exit(1) + if msg.startswith("Both apm.yml"): + logger.error(msg, symbol="error") + sys.exit(1) + logger.error(f"marketplace config error: {exc}", symbol="error") + 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] = {} @@ -195,55 +228,98 @@ def marketplace(ctx): # --------------------------------------------------------------------------- -@marketplace.command(help="Scaffold a new marketplace.yml in the current directory") -@click.option("--force", is_flag=True, help="Overwrite existing marketplace.yml") +@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 name (default: my-marketplace)") +@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): - """Create a richly-commented marketplace.yml scaffold.""" + """Scaffold a 'marketplace:' block in apm.yml (creates apm.yml if absent).""" _require_authoring_flag() - from ..marketplace.init_template import render_marketplace_yml_template + from ..marketplace.init_template import render_marketplace_block logger = CommandLogger("marketplace-init", verbose=verbose) - yml_path = Path.cwd() / "marketplace.yml" - - # Guard: file already exists - if yml_path.exists() and not force: - logger.error( - "marketplace.yml already exists. Use --force to overwrite.", - symbol="error", + 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" ) - sys.exit(1) + 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}") - # Write template - template_text = render_marketplace_yml_template(name=name, owner=owner) - try: - yml_path.write_text(template_text, encoding="utf-8") - except OSError as exc: - logger.error(f"Failed to write marketplace.yml: {exc}", symbol="error") - sys.exit(1) + # 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) - logger.success("Created marketplace.yml", symbol="check") + if isinstance(data, dict) and "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) - if verbose: - logger.verbose_detail(f" Path: {yml_path}") + # 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"] - # .gitignore staleness check - if not no_gitignore_check: - _check_gitignore_for_marketplace_json(logger) + 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) - # Next steps panel - next_steps = [ - "Edit marketplace.yml to add your packages", - "Run 'apm marketplace build' to generate marketplace.json", - "Commit BOTH marketplace.yml and marketplace.json", - ] + 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 marketplace build' to generate .claude-plugin/marketplace.json", + "Commit BOTH apm.yml and the generated marketplace.json", + ] try: from ..utils.console import _rich_panel @@ -799,10 +875,14 @@ def build(dry_run, offline, include_prerelease, verbose): """Resolve packages and compile marketplace.json.""" _require_authoring_flag() logger = CommandLogger("marketplace-build", verbose=verbose) - yml_path = Path.cwd() / "marketplace.yml" - # Load yml (exit 1 on missing, exit 2 on schema error) - _load_yml_or_exit(logger) + project_root, _config = _load_config_or_exit(logger) + + # Pick the right path for the builder constructor (shape-aware lazy load). + apm_path = project_root / "apm.yml" + legacy_path = project_root / "marketplace.yml" + yml_path = apm_path if _config.source_path == apm_path or \ + (apm_path.exists() and not legacy_path.exists()) else legacy_path try: opts = BuildOptions( @@ -813,7 +893,7 @@ def build(dry_run, offline, include_prerelease, verbose): builder = MarketplaceBuilder(yml_path, options=opts) report = builder.build() except MarketplaceYmlError as exc: - logger.error(f"marketplace.yml schema error: {exc}", symbol="error") + logger.error(f"marketplace config error: {exc}", symbol="error") sys.exit(2) except BuildError as exc: _render_build_error(logger, exc) @@ -928,7 +1008,7 @@ def outdated(offline, include_prerelease, verbose): _require_authoring_flag() logger = CommandLogger("marketplace-outdated", verbose=verbose) - yml = _load_yml_or_exit(logger) + _, yml = _load_config_or_exit(logger) # Load current marketplace.json for "Current" column current_versions = _load_current_versions() @@ -1185,7 +1265,7 @@ def check(offline, verbose): _require_authoring_flag() logger = CommandLogger("marketplace-check", verbose=verbose) - yml = _load_yml_or_exit(logger) + _, 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). @@ -1467,27 +1547,46 @@ def doctor(verbose): informational=True, )) - # Check 5: marketplace.yml presence + parsability - yml_path = Path.cwd() / "marketplace.yml" - yml_found = yml_path.exists() - yml_detail = "" - yml_parsed = False + # 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 - if yml_found: - try: - yml_obj = load_marketplace_yml(yml_path) - yml_parsed = True - yml_detail = "marketplace.yml found and valid" - except MarketplaceYmlError as exc: - yml_detail = f"marketplace.yml has errors: {str(exc)[:60]}" - else: - yml_detail = "No marketplace.yml in current directory" + 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.yml", - passed=yml_parsed if yml_found else True, # informational if absent - detail=yml_detail, - informational=True, + name="marketplace config", + passed=config_passed, + detail=config_detail, + informational=config_informational, )) # Check 6: duplicate package names (defence-in-depth) @@ -1676,7 +1775,7 @@ def publish( # ------------------------------------------------------------------ # 1a. Load marketplace.yml - yml = _load_yml_or_exit(logger) + _, yml = _load_config_or_exit(logger) # 1b. Load marketplace.json mkt_json_path = Path.cwd() / "marketplace.json" @@ -2116,3 +2215,60 @@ def search(expression, limit, verbose): logger.verbose_detail(traceback.format_exc()) sys.exit(1) + + +# --------------------------------------------------------------------------- +# 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.""" + _require_authoring_flag() + 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", + ) + # 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_plugin.py b/src/apm_cli/commands/marketplace_plugin.py index 26eafc8e4..b1a040f65 100644 --- a/src/apm_cli/commands/marketplace_plugin.py +++ b/src/apm_cli/commands/marketplace_plugin.py @@ -34,16 +34,63 @@ def _yml_path() -> Path: - """Return the canonical ``marketplace.yml`` path in CWD.""" - return Path.cwd() / "marketplace.yml" + """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(): + if not path.exists() or ( + path == apm_path and path.exists() and not _has_marketplace_block(path) + ): logger.error( - "No marketplace.yml found. " + "No marketplace authoring config found. " "Run 'apm marketplace init' to scaffold one.", symbol="error", ) @@ -51,6 +98,17 @@ def _ensure_yml_exists(logger: CommandLogger) -> Path: 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: diff --git a/src/apm_cli/marketplace/__init__.py b/src/apm_cli/marketplace/__init__.py index 91ab890ea..9b01193b3 100644 --- a/src/apm_cli/marketplace/__init__.py +++ b/src/apm_cli/marketplace/__init__.py @@ -22,9 +22,12 @@ from .resolver import parse_marketplace_ref, resolve_marketplace_plugin from .yml_schema import ( MarketplaceBuild, + MarketplaceConfig, MarketplaceOwner, MarketplaceYml, PackageEntry, + load_marketplace_from_apm_yml, + load_marketplace_from_legacy_yml, load_marketplace_yml, ) from .builder import ( @@ -64,9 +67,12 @@ "parse_marketplace_ref", "resolve_marketplace_plugin", "MarketplaceBuild", + "MarketplaceConfig", "MarketplaceOwner", "MarketplaceYml", "PackageEntry", + "load_marketplace_from_apm_yml", + "load_marketplace_from_legacy_yml", "load_marketplace_yml", "BuildOptions", "BuildReport", diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index fd88ff03e..0e4561b38 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -149,6 +149,7 @@ def __init__( auth_resolver: Optional[object] = None, ) -> None: self._yml_path = marketplace_yml_path + self._project_root = marketplace_yml_path.parent self._options = options or BuildOptions() self._yml: Optional[MarketplaceYml] = None self._resolver: Optional[RefResolver] = None @@ -159,11 +160,47 @@ def __init__( self._host_info: Optional["HostInfo"] = None self._auth_resolved: bool = False + @classmethod + def from_config( + cls, + config: MarketplaceYml, + project_root: Path, + options: Optional[BuildOptions] = None, + auth_resolver: Optional[object] = None, + ) -> "MarketplaceBuilder": + """Construct a builder from an already-loaded MarketplaceConfig. + + Use this when the caller has already chosen between apm.yml and + the legacy ``marketplace.yml`` (typically via + ``migration.load_marketplace_config``). ``project_root`` is the + directory output paths are resolved against. + """ + # Use a synthetic path so legacy code paths that consult + # ``self._yml_path.parent`` still resolve to the project root. + synthetic_path = project_root / ( + config.source_path.name + if config.source_path is not None + else "apm.yml" + ) + instance = cls(synthetic_path, options=options, auth_resolver=auth_resolver) + instance._project_root = project_root + instance._yml = config + return instance + # -- lazy loaders ------------------------------------------------------- def _load_yml(self) -> MarketplaceYml: if self._yml is None: - self._yml = load_marketplace_yml(self._yml_path) + # Shape-aware load: when the configured path is an apm.yml + # file, use the apm.yml loader; otherwise default to the + # legacy marketplace.yml loader. Callers that have already + # loaded a config should use ``from_config`` to bypass this. + from .yml_schema import load_marketplace_from_apm_yml + + if self._yml_path.name == "apm.yml": + self._yml = load_marketplace_from_apm_yml(self._yml_path) + else: + self._yml = load_marketplace_yml(self._yml_path) return self._yml def _get_resolver(self) -> RefResolver: @@ -200,16 +237,27 @@ def _output_path(self) -> Path: if self._options.output_override is not None: return self._options.output_override yml = self._load_yml() - output_path = self._yml_path.parent / yml.output + output_path = self._project_root / yml.output # Containment guard -- reject output paths that escape the project root. - project_root = self._yml_path.parent - ensure_path_within(output_path, project_root) + ensure_path_within(output_path, self._project_root) return output_path # -- single-entry resolution -------------------------------------------- def _resolve_entry(self, entry: PackageEntry) -> ResolvedPackage: """Resolve a single package entry to a concrete tag + SHA.""" + # Local-path packages skip git resolution entirely. + if entry.is_local: + return ResolvedPackage( + name=entry.name, + source_repo="", + subdir=entry.source, + ref="", + sha="", + requested_version=entry.version, + tags=tuple(entry.tags), + is_prerelease=False, + ) yml = self._load_yml() resolver = self._get_resolver() owner_repo = entry.source @@ -561,6 +609,7 @@ def _prefetch_metadata( Returns a mapping of ``{package_name: {"description": ..., "version": ...}}`` for successful fetches. Skipped entirely when ``--offline`` is set. + Local-path packages are skipped (they carry their own metadata). A GitHub token is resolved once before spawning worker threads and stored on ``self._github_token`` for the workers to read. @@ -568,18 +617,20 @@ def _prefetch_metadata( if self._options.offline: return {} - if not resolved: + # Filter out local-path entries -- they don't have a remote to fetch from. + remote = [pkg for pkg in resolved if pkg.source_repo] + if not remote: return {} # Resolve token once -- threads read self._github_token (immutable). self._ensure_auth() results: Dict[str, Dict[str, str]] = {} - workers = min(self._options.concurrency, len(resolved)) + workers = min(self._options.concurrency, len(remote)) with ThreadPoolExecutor(max_workers=workers) as pool: future_to_name = { pool.submit(self._fetch_remote_metadata, pkg): pkg.name - for pkg in resolved + for pkg in remote } for future in as_completed(future_to_name): name = future_to_name[future] @@ -616,10 +667,22 @@ def compose_marketplace_json( # Pre-fetch metadata (description + version) from remote apm.yml remote_metadata = self._prefetch_metadata(resolved) + # Build a name -> entry map so we can reach back for local-package + # description / homepage that came from the yml itself. + entry_by_name: Dict[str, PackageEntry] = { + e.name: e for e in yml.packages + } + doc: Dict[str, Any] = OrderedDict() doc["name"] = yml.name - doc["description"] = yml.description - doc["version"] = yml.version + # Top-level description / version are emitted only when explicitly + # set in the marketplace block (or in a legacy marketplace.yml). + # apm.yml-sourced configs that inherit these from the project skip + # them so the marketplace.json doesn't drift on unrelated bumps. + if yml.description_overridden and yml.description: + doc["description"] = yml.description + if yml.version_overridden and yml.version: + doc["version"] = yml.version # Owner -- omit empty optional sub-fields owner_dict: Dict[str, Any] = OrderedDict() @@ -639,21 +702,43 @@ def compose_marketplace_json( for pkg in resolved: plugin: Dict[str, Any] = OrderedDict() plugin["name"] = pkg.name - meta = remote_metadata.get(pkg.name, {}) - if meta.get("description"): - plugin["description"] = meta["description"] - if meta.get("version"): - plugin["version"] = meta["version"] - plugin["tags"] = list(pkg.tags) - - source: Dict[str, Any] = OrderedDict() - source["type"] = "github" - source["repository"] = pkg.source_repo - if pkg.subdir: - source["path"] = pkg.subdir - source["ref"] = pkg.ref - source["commit"] = pkg.sha - plugin["source"] = source + + entry = entry_by_name.get(pkg.name) + is_local = entry is not None and entry.is_local + + if is_local: + # Local packages: description/version come from the yml + # entry itself (not a remote fetch). + if entry.description: + plugin["description"] = entry.description + if entry.version: + plugin["version"] = entry.version + else: + meta = remote_metadata.get(pkg.name, {}) + if meta.get("description"): + plugin["description"] = meta["description"] + if meta.get("version"): + plugin["version"] = meta["version"] + + if pkg.tags: + plugin["tags"] = list(pkg.tags) + + if is_local: + # Anthropic spec: local sources are emitted as plain + # strings (the path), not the {type, repository, ref, + # commit} object used for remote sources. + plugin["source"] = entry.source + if entry.homepage: + plugin["homepage"] = entry.homepage + else: + source: Dict[str, Any] = OrderedDict() + source["type"] = "github" + source["repository"] = pkg.source_repo + if pkg.subdir: + source["path"] = pkg.subdir + source["ref"] = pkg.ref + source["commit"] = pkg.sha + plugin["source"] = source plugins.append(plugin) @@ -664,7 +749,10 @@ def compose_marketplace_json( for p in plugins: pname = p["name"] src = p.get("source", {}) - src_label = src.get("path") or src.get("repository", "?") + if isinstance(src, str): + src_label = src + else: + src_label = src.get("path") or src.get("repository", "?") if pname in seen_names: build_warnings.append( f"Duplicate package name '{pname}': " @@ -699,6 +787,8 @@ def _compute_diff( src = p.get("source", {}) if isinstance(src, dict): sha = src.get("commit", "") + elif isinstance(src, str): + sha = src # local-path packages: use the path string itself old_plugins[name] = sha new_plugins: Dict[str, str] = {} @@ -708,6 +798,8 @@ def _compute_diff( src = p.get("source", {}) if isinstance(src, dict): sha = src.get("commit", "") + elif isinstance(src, str): + sha = src new_plugins[name] = sha unchanged = 0 diff --git a/src/apm_cli/marketplace/init_template.py b/src/apm_cli/marketplace/init_template.py index acd603db4..4a108ca5c 100644 --- a/src/apm_cli/marketplace/init_template.py +++ b/src/apm_cli/marketplace/init_template.py @@ -80,3 +80,55 @@ def render_marketplace_yml_template( name=name or "my-marketplace", owner=owner or "acme-org", ) + + +_MARKETPLACE_BLOCK_TEMPLATE = """\ +# Marketplace authoring config (APM-only). +# Run 'apm marketplace build' to compile this block to .claude-plugin/marketplace.json. +# +# Top-level 'name', 'description', and 'version' are inherited from +# the project (above) by default. Override them inside this block when +# the marketplace is published independently of the project's release +# cadence. +# +# For the full schema, see: +# https://microsoft.github.io/apm/guides/marketplace-authoring/ +marketplace: + owner: + name: {owner} + url: https://github.com/{owner} + + # Default tag pattern used to resolve version ranges for each package. + build: + tagPattern: "v{{version}}" + + packages: + - name: example-package + description: Human-readable description of the package + source: {owner}/example-package + version: "^1.0.0" + # Optional overrides: + # subdir: path/inside/repo + # tagPattern: "example-package-v{{version}}" + # include_prerelease: false + # ref: main # pin to an explicit ref instead of a version range + + # Local-path entry: ship a package shipped alongside this repo. + # - name: local-tool + # source: ./packages/local-tool + # description: A locally vendored tool + # version: 0.1.0 +""" + + +def render_marketplace_block(owner: str | None = None) -> str: + """Return a YAML snippet for the ``marketplace:`` block of apm.yml. + + Used by ``apm init --marketplace`` to seed a new project that ships + its own marketplace. ``name``/``description``/``version`` are + inherited from the surrounding apm.yml top level by default, so they + are intentionally omitted here. + """ + return _MARKETPLACE_BLOCK_TEMPLATE.format( + owner=owner or "acme-org", + ) diff --git a/src/apm_cli/marketplace/migration.py b/src/apm_cli/marketplace/migration.py new file mode 100644 index 000000000..a5eba6238 --- /dev/null +++ b/src/apm_cli/marketplace/migration.py @@ -0,0 +1,290 @@ +"""Detection + migration helpers for marketplace authoring config. + +Two config sources are supported: + +* ``apm.yml`` with a top-level ``marketplace:`` block (current). +* Standalone ``marketplace.yml`` (legacy, deprecated). + +This module provides: + +* :class:`ConfigSource` -- enum identifying which file (if any) holds + the marketplace config. +* :func:`detect_config_source` -- which loader the commands should use, + with a hard error if both files exist (no silent precedence). +* :func:`load_marketplace_config` -- smart loader that emits a one-line + deprecation warning when the legacy file is in play. +* :func:`migrate_marketplace_yml` -- one-shot conversion from legacy + ``marketplace.yml`` into ``apm.yml``'s ``marketplace:`` block. +""" + +from __future__ import annotations + +import enum +import logging +from io import StringIO +from pathlib import Path +from typing import Optional + +import yaml + +from .errors import MarketplaceYmlError +from .yml_schema import ( + MarketplaceConfig, + load_marketplace_from_apm_yml, + load_marketplace_from_legacy_yml, +) + +__all__ = [ + "ConfigSource", + "detect_config_source", + "load_marketplace_config", + "migrate_marketplace_yml", + "DEPRECATION_MESSAGE", +] + + +DEPRECATION_MESSAGE = ( + "marketplace.yml is deprecated. Run 'apm marketplace migrate' to " + "fold it into apm.yml's 'marketplace:' block." +) + + +logger = logging.getLogger(__name__) + + +class ConfigSource(enum.Enum): + """Which file (if any) holds the marketplace authoring config.""" + + APM_YML = "apm.yml" + LEGACY_YML = "marketplace.yml" + NONE = "none" + + +def _has_marketplace_block(apm_yml_path: Path) -> bool: + """Return ``True`` when *apm_yml_path* exists and has ``marketplace:``.""" + if not apm_yml_path.exists(): + return False + try: + text = apm_yml_path.read_text(encoding="utf-8") + data = yaml.safe_load(text) + except (OSError, yaml.YAMLError): + return False + return isinstance(data, dict) and "marketplace" in data and \ + data["marketplace"] is not None + + +def detect_config_source(project_root: Path) -> ConfigSource: + """Return the active config source for *project_root*. + + Raises :class:`MarketplaceYmlError` when both apm.yml (with a + ``marketplace:`` block) and a standalone ``marketplace.yml`` are + present -- the user must explicitly choose which one wins. + """ + apm_yml = project_root / "apm.yml" + legacy_yml = project_root / "marketplace.yml" + + has_apm_block = _has_marketplace_block(apm_yml) + has_legacy = legacy_yml.exists() + + if has_apm_block and has_legacy: + raise MarketplaceYmlError( + "Both apm.yml (with a 'marketplace:' block) and " + "marketplace.yml exist. Remove marketplace.yml or run " + "'apm marketplace migrate --force' to consolidate." + ) + if has_apm_block: + return ConfigSource.APM_YML + if has_legacy: + return ConfigSource.LEGACY_YML + return ConfigSource.NONE + + +def load_marketplace_config( + project_root: Path, + *, + warn_callback=None, +) -> MarketplaceConfig: + """Smart loader: detect source, load, warn if legacy. + + Parameters + ---------- + project_root : Path + Directory holding apm.yml / marketplace.yml. + warn_callback : callable, optional + Invoked with the deprecation message string when a legacy file + is loaded. Defaults to a stdlib ``logging.warning`` call. + + Raises + ------ + MarketplaceYmlError + When no config is found, or when both files are present, or + on any validation error from the underlying loaders. + """ + source = detect_config_source(project_root) + if source == ConfigSource.APM_YML: + return load_marketplace_from_apm_yml(project_root / "apm.yml") + if source == ConfigSource.LEGACY_YML: + msg = DEPRECATION_MESSAGE + if warn_callback is not None: + warn_callback(msg) + else: + logger.warning(msg) + return load_marketplace_from_legacy_yml( + project_root / "marketplace.yml" + ) + raise MarketplaceYmlError( + "No marketplace config found. Add a 'marketplace:' block to " + "apm.yml or run 'apm marketplace init' to scaffold one." + ) + + +# --------------------------------------------------------------------------- +# Migration +# --------------------------------------------------------------------------- + + +def _rt_yaml(): + """Return a configured ruamel.yaml round-trip instance.""" + from ruamel.yaml import YAML + + rt = YAML(typ="rt") + rt.preserve_quotes = True + rt.indent(mapping=2, sequence=4, offset=2) + return rt + + +def _build_marketplace_block(legacy_data, apm_top: dict): + """Construct a ruamel ``CommentedMap`` for the marketplace block. + + Inheritable fields (``name``, ``description``, ``version``) are + omitted from the block when they match the apm.yml top-level + values, and emitted as overrides when they differ. + """ + from ruamel.yaml.comments import CommentedMap + + block = CommentedMap() + + for key in ("name", "description", "version"): + if key in legacy_data and legacy_data[key] is not None: + top_val = apm_top.get(key) + legacy_val = legacy_data[key] + if top_val != legacy_val: + block[key] = legacy_val + + for key in ("owner", "output", "build", "metadata", "packages"): + if key in legacy_data and legacy_data[key] is not None: + block[key] = legacy_data[key] + + return block + + +def migrate_marketplace_yml( + project_root: Path, + *, + force: bool = False, + dry_run: bool = False, +) -> str: + """Fold ``marketplace.yml`` into ``apm.yml``'s ``marketplace:`` block. + + Parameters + ---------- + project_root : Path + Directory containing apm.yml and marketplace.yml. + force : bool + Overwrite an existing ``marketplace:`` block in apm.yml. + dry_run : bool + When ``True``, do not write to disk or delete the legacy file. + + Returns + ------- + str + Unified diff describing the proposed apm.yml changes. + + Raises + ------ + MarketplaceYmlError + When marketplace.yml is missing, apm.yml is missing, or apm.yml + already has a ``marketplace:`` block and ``force`` is ``False``. + """ + legacy_path = project_root / "marketplace.yml" + apm_path = project_root / "apm.yml" + + if not legacy_path.exists(): + raise MarketplaceYmlError( + "marketplace.yml not found -- nothing to migrate." + ) + if not apm_path.exists(): + raise MarketplaceYmlError( + "apm.yml not found. Run 'apm init' first." + ) + + # Validate legacy file before doing anything destructive. + load_marketplace_from_legacy_yml(legacy_path) + + rt = _rt_yaml() + + # Load legacy file (round-trip so comments/order are preserved when + # we copy the body into apm.yml). + legacy_data = rt.load(legacy_path.read_text(encoding="utf-8")) + + # Load apm.yml round-trip so we can safely insert the new key. + apm_text = apm_path.read_text(encoding="utf-8") + apm_data = rt.load(apm_text) + + if "marketplace" in apm_data and apm_data["marketplace"] is not None: + if not force: + raise MarketplaceYmlError( + "apm.yml already has a 'marketplace:' block. " + "Re-run with --force to overwrite." + ) + + block = _build_marketplace_block(legacy_data, apm_data) + apm_data["marketplace"] = block + + # Render the proposed apm.yml. + out_buf = StringIO() + rt.dump(apm_data, out_buf) + new_apm_text = out_buf.getvalue() + + # Build a unified diff for the caller to display. + import difflib + + diff_lines = list(difflib.unified_diff( + apm_text.splitlines(keepends=True), + new_apm_text.splitlines(keepends=True), + fromfile="apm.yml (current)", + tofile="apm.yml (after migrate)", + n=3, + )) + diff = "".join(diff_lines) + + if not dry_run: + apm_path.write_text(new_apm_text, encoding="utf-8") + legacy_path.unlink() + + return diff + + +def detect_inheritance_conflicts( + legacy_data: dict, apm_data: dict +) -> list: + """Return human-readable conflict descriptions. + + Compares the inheritable scalars between legacy and apm.yml. + Each conflict suggests the override to add to preserve the legacy + behaviour. + """ + conflicts: list = [] + for key in ("name", "description", "version"): + legacy_val = legacy_data.get(key) + apm_val = apm_data.get(key) + if legacy_val is None or apm_val is None: + continue + if legacy_val != apm_val: + conflicts.append( + f"{key} in marketplace.yml ({legacy_val!r}) differs from " + f"apm.yml ({apm_val!r}). The marketplace will use " + f"{apm_val!r} (from apm.yml). Add marketplace.{key}: " + f"{legacy_val!r} to override." + ) + return conflicts diff --git a/src/apm_cli/marketplace/yml_editor.py b/src/apm_cli/marketplace/yml_editor.py index dff1667e6..dea5fc1cf 100644 --- a/src/apm_cli/marketplace/yml_editor.py +++ b/src/apm_cli/marketplace/yml_editor.py @@ -23,7 +23,12 @@ from ..utils.path_security import PathTraversalError, validate_path_segments from ._io import atomic_write from .errors import MarketplaceYmlError -from .yml_schema import SOURCE_RE, load_marketplace_yml +from .yml_schema import ( + LOCAL_SOURCE_RE, + SOURCE_RE, + load_marketplace_from_apm_yml, + load_marketplace_yml, +) __all__ = [ "add_plugin_entry", @@ -60,6 +65,39 @@ def _dump_rt(data) -> str: return stream.getvalue() +def _is_apm_yml_with_marketplace(data) -> bool: + """Detect an apm.yml file that hosts a ``marketplace:`` block. + + The legacy ``marketplace.yml`` shape has marketplace fields (``owner``, + ``packages``) at the root; the apm.yml shape nests them under + ``marketplace:``. We pick whichever shape the file actually has. + """ + if not isinstance(data, dict): + return False + if "marketplace" not in data or data["marketplace"] is None: + return False + return True + + +def _get_marketplace_container(data): + """Return the dict-like container holding marketplace fields. + + For apm.yml: ``data["marketplace"]``. + For legacy marketplace.yml: ``data`` itself. + """ + if _is_apm_yml_with_marketplace(data): + return data["marketplace"] + return data + + +def _validate_after_write(yml_path: Path, data) -> None: + """Re-validate *yml_path* using the loader matching its shape.""" + if _is_apm_yml_with_marketplace(data): + load_marketplace_from_apm_yml(yml_path) + else: + load_marketplace_yml(yml_path) + + def _write_and_validate(yml_path: Path, data, original_text: str) -> None: """Atomically write *data* and re-validate. @@ -69,7 +107,7 @@ def _write_and_validate(yml_path: Path, data, original_text: str) -> None: new_text = _dump_rt(data) atomic_write(yml_path, new_text) try: - load_marketplace_yml(yml_path) + _validate_after_write(yml_path, data) except MarketplaceYmlError: # Restore original content before propagating. atomic_write(yml_path, original_text) @@ -87,18 +125,18 @@ def _find_entry_index(packages, name: str) -> int: if isinstance(entry_name, str) and entry_name.lower() == lower: return idx raise MarketplaceYmlError( - f"Package '{name}' not found in marketplace.yml" + f"Package '{name}' not found" ) def _validate_source(source: str) -> None: - """Validate that *source* has ``owner/repo`` shape.""" + """Validate that *source* has ``owner/repo`` shape or ``./...`` local path.""" if not SOURCE_RE.match(source): raise MarketplaceYmlError( - f"'source' must match '/' shape, got '{source}'" + f"'source' must match '/' or './' shape, got '{source}'" ) try: - validate_path_segments(source, context="source") + validate_path_segments(source, context="source", allow_current_dir=True) except PathTraversalError as exc: raise MarketplaceYmlError(str(exc)) from exc @@ -153,12 +191,13 @@ def add_plugin_entry( # --- load --- data, original_text = _load_rt(yml_path) - packages = data.get("packages") + container = _get_marketplace_container(data) + packages = container.get("packages") if packages is None: from ruamel.yaml.comments import CommentedSeq packages = CommentedSeq() - data["packages"] = packages + container["packages"] = packages # Duplicate check (case-insensitive). lower = name.lower() @@ -166,7 +205,7 @@ def add_plugin_entry( entry_name = entry.get("name", "") if isinstance(entry_name, str) and entry_name.lower() == lower: raise MarketplaceYmlError( - f"Package '{name}' already exists in marketplace.yml" + f"Package '{name}' already exists" ) # --- build entry mapping --- @@ -202,10 +241,11 @@ def update_plugin_entry(yml_path: Path, name: str, **fields) -> None: Only fields that are explicitly provided (not ``None``) are updated. """ data, original_text = _load_rt(yml_path) - packages = data.get("packages") + container = _get_marketplace_container(data) + packages = container.get("packages") if packages is None: raise MarketplaceYmlError( - f"Package '{name}' not found in marketplace.yml" + f"Package '{name}' not found" ) idx = _find_entry_index(packages, name) @@ -255,10 +295,11 @@ def update_plugin_entry(yml_path: Path, name: str, **fields) -> None: def remove_plugin_entry(yml_path: Path, name: str) -> None: """Remove a ``packages[]`` entry by name (case-insensitive match).""" data, original_text = _load_rt(yml_path) - packages = data.get("packages") + container = _get_marketplace_container(data) + packages = container.get("packages") if packages is None: raise MarketplaceYmlError( - f"Package '{name}' not found in marketplace.yml" + f"Package '{name}' not found" ) idx = _find_entry_index(packages, name) diff --git a/src/apm_cli/marketplace/yml_schema.py b/src/apm_cli/marketplace/yml_schema.py index 12edf9fa8..04cf75c72 100644 --- a/src/apm_cli/marketplace/yml_schema.py +++ b/src/apm_cli/marketplace/yml_schema.py @@ -1,10 +1,15 @@ -"""Dataclasses, loader, and validation for ``marketplace.yml``. +"""Dataclasses, loader, and validation for marketplace authoring config. -``marketplace.yml`` is the maintainer-authored source file that the -``mkt-builder`` compiles into an Anthropic-compliant ``marketplace.json``. -This module is responsible for parsing the YAML, enforcing structural -constraints, and producing immutable dataclass instances that downstream -code can inspect without further validation. +The marketplace publisher configuration may live in two places: + +* (Preferred, current) inside ``apm.yml`` under a top-level + ``marketplace:`` block. Loaded via + :func:`load_marketplace_from_apm_yml`. +* (Legacy, deprecated) inside a standalone ``marketplace.yml`` file. + Loaded via :func:`load_marketplace_from_legacy_yml`. + +Both paths produce the same immutable :class:`MarketplaceConfig` +dataclass that the builder consumes. Key design rules ---------------- @@ -16,9 +21,12 @@ ``version``, ``ref``, ``subdir``, ``tag_pattern``, ``include_prerelease``) live as explicit dataclass attributes so the builder can strip them cleanly. -* **Strict top-level and per-entry key sets.** Unknown keys raise - ``MarketplaceYmlError`` immediately so that typos are never silently - ignored. +* **Strict key sets.** Unknown keys inside the marketplace block raise + ``MarketplaceYmlError`` so typos are never silently ignored. The + apm.yml top-level is intentionally NOT strict here -- only the + ``marketplace:`` subtree is validated by this module. +* **Local-path packages.** ``source`` accepts ``./...`` paths in + addition to ``owner/repo`` shape. Local packages skip ref resolution. """ from __future__ import annotations @@ -34,13 +42,17 @@ from .errors import MarketplaceYmlError __all__ = [ - "MarketplaceYml", + "MarketplaceConfig", + "MarketplaceYml", # backwards-compat alias "MarketplaceOwner", "MarketplaceBuild", "PackageEntry", "MarketplaceYmlError", "SOURCE_RE", + "LOCAL_SOURCE_RE", "load_marketplace_yml", + "load_marketplace_from_apm_yml", + "load_marketplace_from_legacy_yml", ] # --------------------------------------------------------------------------- @@ -53,9 +65,11 @@ r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" ) -# ``owner/repo`` shape -- at least one char on each side of the slash. -# Used by both yml_schema and yml_editor for source field validation. -SOURCE_RE = re.compile(r"^[^/]+/[^/]+$") +# Source field accepts either ``owner/repo`` (remote) or ``./...`` (local +# path within the same repo). Used by both yml_schema and yml_editor for +# source field validation. +SOURCE_RE = re.compile(r"^(?:[^/]+/[^/]+|\./.*)$") +LOCAL_SOURCE_RE = re.compile(r"^\./") # Placeholder tokens accepted in ``tag_pattern`` / ``build.tagPattern``. _TAG_PLACEHOLDERS = ("{version}", "{name}") @@ -64,17 +78,6 @@ # Permitted key sets (strict mode) # --------------------------------------------------------------------------- -_TOP_LEVEL_KEYS = frozenset({ - "name", - "description", - "version", - "owner", - "output", - "metadata", - "build", - "packages", -}) - _BUILD_KEYS = frozenset({ "tagPattern", }) @@ -88,8 +91,24 @@ "tag_pattern", "include_prerelease", "description", + "homepage", "tags", }) + +# Keys permitted inside the ``marketplace:`` block of apm.yml. This is +# distinct from the legacy top-level keys (which include ``name``, +# ``description``, ``version`` -- those are inherited from apm.yml's +# top-level scalars in the new world). +_APM_MARKETPLACE_KEYS = frozenset({ + "name", # optional override of top-level apm.yml name + "description", # optional override of top-level apm.yml description + "version", # optional override of top-level apm.yml version + "owner", + "output", + "metadata", + "build", + "packages", +}) # --------------------------------------------------------------------------- # Dataclasses # --------------------------------------------------------------------------- @@ -116,9 +135,14 @@ class PackageEntry: """A single entry in the ``packages`` list. Attributes that are Anthropic pass-through (``description``, - ``tags``) are stored alongside APM-only attributes (``subdir``, - ``version``, ``ref``, ``tag_pattern``, ``include_prerelease``) so - the builder can partition them at compile time. + ``homepage``, ``tags``) are stored alongside APM-only attributes + (``subdir``, ``version``, ``ref``, ``tag_pattern``, + ``include_prerelease``) so the builder can partition them at + compile time. + + ``is_local`` is derived by the loader from the ``source`` field -- + a leading ``./`` marks a local-path package that skips git + resolution. """ name: str @@ -130,26 +154,50 @@ class PackageEntry: tag_pattern: Optional[str] = None include_prerelease: bool = False # Anthropic pass-through fields + description: Optional[str] = None + homepage: Optional[str] = None tags: Tuple[str, ...] = () + # Derived (set by loader, not by user) + is_local: bool = False @dataclass(frozen=True) -class MarketplaceYml: - """Top-level representation of a parsed ``marketplace.yml``. +class MarketplaceConfig: + """Parsed marketplace configuration. + + May originate from apm.yml's ``marketplace:`` block (current) or + from a standalone ``marketplace.yml`` (legacy, deprecated). ``metadata`` is stored as a plain ``dict`` preserving the original key casing so the builder can forward it verbatim to ``marketplace.json``. + + Override flags (``*_overridden``) record whether the marketplace + block explicitly set each inheritable field. The builder uses + these flags to decide whether to emit ``description``/``version`` + at the top level of ``marketplace.json`` -- per the Anthropic + azure-skills convention, inherited values are omitted from output. """ name: str description: str version: str owner: MarketplaceOwner - output: str = "marketplace.json" + output: str = ".claude-plugin/marketplace.json" metadata: Dict[str, Any] = field(default_factory=dict) build: MarketplaceBuild = field(default_factory=MarketplaceBuild) packages: Tuple[PackageEntry, ...] = () + # Origin tracking + override-detection metadata + source_path: Optional[Path] = None + is_legacy: bool = False + name_overridden: bool = False + description_overridden: bool = False + version_overridden: bool = False + + +# Backwards-compatibility alias for callers that still import +# ``MarketplaceYml``. Will be removed in a future minor release. +MarketplaceYml = MarketplaceConfig # --------------------------------------------------------------------------- @@ -184,14 +232,23 @@ def _validate_semver(version: str, *, context: str = "version") -> None: def _validate_source(source: str, *, index: int) -> None: - """Validate ``source`` field shape and path safety.""" + """Validate ``source`` field shape and path safety. + + Accepts either ``owner/repo`` (remote) or ``./...`` (local path). + """ ctx = f"packages[{index}].source" if not SOURCE_RE.match(source): raise MarketplaceYmlError( - f"'{ctx}' must match '/' shape, got '{source}'" + f"'{ctx}' must match '/' or './' shape, " + f"got '{source}'" ) + is_local = bool(LOCAL_SOURCE_RE.match(source)) try: - validate_path_segments(source, context=ctx) + # Local paths legitimately start with ``.`` (current dir) and + # may have trailing-slash forms like ``./``. Allow ``.`` here. + validate_path_segments( + source, context=ctx, allow_current_dir=is_local + ) except PathTraversalError as exc: raise MarketplaceYmlError(str(exc)) from exc @@ -273,8 +330,9 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry: name = _require_str(raw, "name", context=f"packages[{index}]") source = _require_str(raw, "source", context=f"packages[{index}]") _validate_source(source, index=index) + is_local = bool(LOCAL_SOURCE_RE.match(source)) - # APM-only: subdir + # APM-only: subdir (irrelevant for local packages but harmless) subdir: Optional[str] = raw.get("subdir") if subdir is not None: if not isinstance(subdir, str) or not subdir.strip(): @@ -305,11 +363,13 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry: f"'packages[{index}].ref' must be a non-empty string" ) - # At least one of version or ref must be present - if version is None and ref is None: + # At least one of version or ref must be present for REMOTE packages. + # Local-path packages skip git resolution so the requirement does not + # apply to them. + if not is_local and version is None and ref is None: raise MarketplaceYmlError( - f"packages[{index}] ('{name}'): at least one of " - f"'version' or 'ref' must be set" + f"packages[{index}] ('{name}'): remote packages require at " + f"least one of 'version' or 'ref'" ) # APM-only: tag_pattern @@ -331,6 +391,24 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry: f"'packages[{index}].include_prerelease' must be a boolean" ) + # Anthropic pass-through: description + description: Optional[str] = raw.get("description") + if description is not None: + if not isinstance(description, str) or not description.strip(): + raise MarketplaceYmlError( + f"'packages[{index}].description' must be a non-empty string" + ) + description = description.strip() + + # Anthropic pass-through: homepage + homepage: Optional[str] = raw.get("homepage") + if homepage is not None: + if not isinstance(homepage, str) or not homepage.strip(): + raise MarketplaceYmlError( + f"'packages[{index}].homepage' must be a non-empty string" + ) + homepage = homepage.strip() + # Anthropic pass-through: tags raw_tags = raw.get("tags") tags: Tuple[str, ...] = () @@ -349,7 +427,10 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry: ref=ref, tag_pattern=tag_pattern, include_prerelease=include_prerelease, + description=description, + homepage=homepage, tags=tags, + is_local=is_local, ) @@ -358,8 +439,21 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry: # --------------------------------------------------------------------------- -def load_marketplace_yml(path: Path) -> MarketplaceYml: - """Load and validate a ``marketplace.yml`` file. +def load_marketplace_yml(path: Path) -> MarketplaceConfig: + """Backwards-compatible loader for a standalone ``marketplace.yml``. + + Equivalent to :func:`load_marketplace_from_legacy_yml`. Preserved + for callers that imported the original symbol. + """ + return load_marketplace_from_legacy_yml(path) + + +def load_marketplace_from_legacy_yml(path: Path) -> MarketplaceConfig: + """Load and validate a standalone ``marketplace.yml`` (legacy). + + The legacy file holds the marketplace block at the YAML root. + ``name``, ``description``, ``version`` are all required at this + level (they are not inheritable in the legacy world). Parameters ---------- @@ -368,15 +462,149 @@ def load_marketplace_yml(path: Path) -> MarketplaceYml: Returns ------- - MarketplaceYml - Fully validated, immutable representation. + MarketplaceConfig + Fully validated, immutable representation, with + ``is_legacy=True`` and all override flags set to ``True`` (the + legacy file always carries the values explicitly). Raises ------ MarketplaceYmlError On any validation failure or YAML parse error. """ - # -- read + parse YAML -- + data = _read_yaml_mapping(path) + + # -- strict top-level key check -- + _check_unknown_keys(data, _APM_MARKETPLACE_KEYS, context="top level") + + # -- required scalars -- + name = _require_str(data, "name") + description = _require_str(data, "description") + version_str = _require_str(data, "version") + _validate_semver(version_str, context="version") + + return _build_config( + marketplace_dict=data, + name=name, + description=description, + version=version_str, + source_path=path, + is_legacy=True, + name_overridden=True, + description_overridden=True, + version_overridden=True, + default_output="marketplace.json", + ) + + +def load_marketplace_from_apm_yml(apm_yml_path: Path) -> MarketplaceConfig: + """Load marketplace config from apm.yml's ``marketplace:`` block. + + Reads the full YAML, extracts top-level ``name``/``version``/ + ``description``, then parses the ``marketplace:`` block. Inherits + the three top-level scalars when the marketplace block does not + explicitly override them. + + Parameters + ---------- + apm_yml_path : Path + Filesystem path to apm.yml. + + Returns + ------- + MarketplaceConfig + Fully validated, immutable representation. + + Raises + ------ + MarketplaceYmlError + If apm.yml is missing the ``marketplace:`` block or any + validation fails. + """ + data = _read_yaml_mapping(apm_yml_path) + + raw_block = data.get("marketplace") + if raw_block is None: + raise MarketplaceYmlError( + f"'{apm_yml_path}' has no 'marketplace:' block. " + "Add one or run 'apm marketplace init' to scaffold it." + ) + if not isinstance(raw_block, dict): + raise MarketplaceYmlError( + "'marketplace' in apm.yml must be a mapping" + ) + + # -- strict marketplace-block key check -- + _check_unknown_keys( + raw_block, _APM_MARKETPLACE_KEYS, context="marketplace" + ) + + # -- inheritance with optional overrides -- + top_name = data.get("name") + top_desc = data.get("description") + top_ver = data.get("version") + + name_overridden = "name" in raw_block and raw_block["name"] is not None + desc_overridden = ( + "description" in raw_block and raw_block["description"] is not None + ) + ver_overridden = ( + "version" in raw_block and raw_block["version"] is not None + ) + + if name_overridden: + name = _require_str(raw_block, "name", context="marketplace") + else: + if not isinstance(top_name, str) or not top_name.strip(): + raise MarketplaceYmlError( + "'name' is required (set it at apm.yml top level or " + "override via marketplace.name)" + ) + name = top_name.strip() + + if desc_overridden: + description = _require_str( + raw_block, "description", context="marketplace" + ) + else: + if not isinstance(top_desc, str) or not top_desc.strip(): + description = "" + else: + description = top_desc.strip() + + if ver_overridden: + version_str = _require_str( + raw_block, "version", context="marketplace" + ) + else: + if top_ver is None: + version_str = "" + else: + version_str = str(top_ver).strip() + + if version_str: + _validate_semver(version_str, context="version") + + return _build_config( + marketplace_dict=raw_block, + name=name, + description=description, + version=version_str, + source_path=apm_yml_path, + is_legacy=False, + name_overridden=name_overridden, + description_overridden=desc_overridden, + version_overridden=ver_overridden, + ) + + +# --------------------------------------------------------------------------- +# Shared internal helpers +# --------------------------------------------------------------------------- + + +def _read_yaml_mapping(path: Path) -> Dict[str, Any]: + """Read *path* and return its top-level mapping or raise.""" try: text = path.read_text(encoding="utf-8") except OSError as exc: @@ -387,7 +615,6 @@ def load_marketplace_yml(path: Path) -> MarketplaceYml: try: data = yaml.safe_load(text) except yaml.YAMLError as exc: - # Include line number when the YAML library provides it. detail = "" if hasattr(exc, "problem_mark") and exc.problem_mark is not None: mark = exc.problem_mark @@ -396,28 +623,41 @@ def load_marketplace_yml(path: Path) -> MarketplaceYml: f"YAML parse error in '{path}'{detail}: {exc}" ) from exc + if data is None: + return {} if not isinstance(data, dict): raise MarketplaceYmlError( f"'{path}' must contain a YAML mapping at the top level" ) + return data - # -- strict top-level key check -- - _check_unknown_keys(data, _TOP_LEVEL_KEYS, context="top level") - - # -- required scalars -- - name = _require_str(data, "name") - description = _require_str(data, "description") - version_str = _require_str(data, "version") - _validate_semver(version_str, context="version") +def _build_config( + *, + marketplace_dict: Dict[str, Any], + name: str, + description: str, + version: str, + source_path: Path, + is_legacy: bool, + name_overridden: bool, + description_overridden: bool, + version_overridden: bool, + default_output: str = ".claude-plugin/marketplace.json", +) -> MarketplaceConfig: + """Shared parser for the marketplace fields once name/desc/version + have been resolved (either inherited or read directly). + """ # -- owner -- - raw_owner = data.get("owner") + raw_owner = marketplace_dict.get("owner") if raw_owner is None: raise MarketplaceYmlError("'owner' is required") owner = _parse_owner(raw_owner) - # -- output -- - output = data.get("output", "marketplace.json") + # -- output (default differs between legacy and new layouts) -- + output = marketplace_dict.get("output") + if output is None: + output = default_output if not isinstance(output, str) or not output.strip(): raise MarketplaceYmlError( "'output' must be a non-empty string" @@ -426,23 +666,23 @@ def load_marketplace_yml(path: Path) -> MarketplaceYml: # Path-traversal guard -- reject output paths containing ".." segments. try: - validate_path_segments(output, context="marketplace.yml output") + validate_path_segments(output, context="marketplace output") except PathTraversalError as exc: raise MarketplaceYmlError(str(exc)) from exc # -- metadata (Anthropic pass-through, preserve verbatim) -- metadata: Dict[str, Any] = {} - raw_metadata = data.get("metadata") + raw_metadata = marketplace_dict.get("metadata") if raw_metadata is not None: if not isinstance(raw_metadata, dict): raise MarketplaceYmlError("'metadata' must be a mapping") metadata = dict(raw_metadata) # -- build -- - build = _parse_build(data.get("build")) + build = _parse_build(marketplace_dict.get("build")) # -- packages -- - raw_packages = data.get("packages") + raw_packages = marketplace_dict.get("packages") if raw_packages is None: raw_packages = [] if not isinstance(raw_packages, list): @@ -461,13 +701,18 @@ def load_marketplace_yml(path: Path) -> MarketplaceYml: seen_names[lower_name] = idx entries.append(entry) - return MarketplaceYml( + return MarketplaceConfig( name=name, description=description, - version=version_str, + version=version, owner=owner, output=output, metadata=metadata, build=build, packages=tuple(entries), + source_path=source_path, + is_legacy=is_legacy, + name_overridden=name_overridden, + description_overridden=description_overridden, + version_overridden=version_overridden, ) diff --git a/tests/unit/commands/test_marketplace_build.py b/tests/unit/commands/test_marketplace_build.py index 501fb3fea..a131dc6df 100644 --- a/tests/unit/commands/test_marketplace_build.py +++ b/tests/unit/commands/test_marketplace_build.py @@ -233,7 +233,7 @@ def test_missing_yml_exits_1(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = runner.invoke(marketplace, ["build"]) assert result.exit_code == 1 - assert "No marketplace.yml found" in result.output + assert "No marketplace config" in result.output def test_missing_yml_suggests_init(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -249,7 +249,10 @@ def test_schema_error_exits_2(self, runner, tmp_path, monkeypatch): (tmp_path / "marketplace.yml").write_text("not: valid\n", encoding="utf-8") result = runner.invoke(marketplace, ["build"]) assert result.exit_code == 2 - assert "schema error" in result.output.lower() or "required" in result.output.lower() + assert "config error" in result.output.lower() or \ + "schema error" in result.output.lower() or \ + "required" in result.output.lower() or \ + "unknown" in result.output.lower() def test_bad_yaml_syntax_exits_2(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) diff --git a/tests/unit/commands/test_marketplace_check.py b/tests/unit/commands/test_marketplace_check.py index a04bc6f76..36330cb61 100644 --- a/tests/unit/commands/test_marketplace_check.py +++ b/tests/unit/commands/test_marketplace_check.py @@ -356,7 +356,7 @@ 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_yml") + @patch("apm_cli.commands.marketplace.load_marketplace_config") def test_duplicate_names_warned( self, mock_load, MockResolver, runner, tmp_path, monkeypatch, ): @@ -391,7 +391,7 @@ def test_duplicate_names_warned( assert "Duplicate package name 'learning'" in result.output @patch("apm_cli.commands.marketplace.RefResolver") - @patch("apm_cli.commands.marketplace.load_marketplace_yml") + @patch("apm_cli.commands.marketplace.load_marketplace_config") def test_no_warning_when_unique( self, mock_load, MockResolver, runner, tmp_path, monkeypatch, ): diff --git a/tests/unit/commands/test_marketplace_doctor.py b/tests/unit/commands/test_marketplace_doctor.py index d3e92b5f1..fbb815599 100644 --- a/tests/unit/commands/test_marketplace_doctor.py +++ b/tests/unit/commands/test_marketplace_doctor.py @@ -390,7 +390,7 @@ def test_yml_absent(self, mock_run, runner, tmp_path, monkeypatch): result = runner.invoke(marketplace, ["doctor"]) assert result.exit_code == 0 - assert "No marketplace.yml" in result.output + assert "No marketplace authoring config" in result.output # --------------------------------------------------------------------------- diff --git a/tests/unit/commands/test_marketplace_init.py b/tests/unit/commands/test_marketplace_init.py index 773c5048c..56ecfa6e5 100644 --- a/tests/unit/commands/test_marketplace_init.py +++ b/tests/unit/commands/test_marketplace_init.py @@ -1,8 +1,12 @@ -"""Tests for ``apm marketplace init`` subcommand.""" +"""Tests for ``apm marketplace init`` subcommand (post-fold). + +After the fold (#1036), ``apm marketplace init`` writes a ``marketplace:`` +block into ``apm.yml`` (scaffolding ``apm.yml`` if absent). It no longer +creates standalone ``marketplace.yml`` files. +""" from __future__ import annotations -import textwrap from pathlib import Path import pytest @@ -10,12 +14,6 @@ from click.testing import CliRunner from apm_cli.commands.marketplace import marketplace -from apm_cli.marketplace.yml_schema import load_marketplace_yml - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- @pytest.fixture @@ -23,76 +21,83 @@ def runner(): return CliRunner() +def _load_marketplace_block(apm_yml_path: Path) -> dict: + data = yaml.safe_load(apm_yml_path.read_text(encoding="utf-8")) + assert isinstance(data, dict) + assert "marketplace" in data + return data["marketplace"] + + # --------------------------------------------------------------------------- -# Happy path +# Happy path: scaffolds apm.yml + marketplace: block # --------------------------------------------------------------------------- class TestInitHappyPath: - def test_creates_marketplace_yml(self, runner, tmp_path, monkeypatch): + def test_creates_apm_yml_when_absent(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = runner.invoke(marketplace, ["init"]) - assert result.exit_code == 0 - assert (tmp_path / "marketplace.yml").exists() + assert result.exit_code == 0, result.output + assert (tmp_path / "apm.yml").exists() + # No legacy marketplace.yml is created. + assert not (tmp_path / "marketplace.yml").exists() - def test_success_message(self, runner, tmp_path, monkeypatch): + def test_injects_marketplace_block_into_existing_apm_yml( + self, runner, tmp_path, monkeypatch, + ): monkeypatch.chdir(tmp_path) + (tmp_path / "apm.yml").write_text( + "name: my-app\nversion: 1.0.0\ndescription: existing\n", + encoding="utf-8", + ) result = runner.invoke(marketplace, ["init"]) - assert result.exit_code == 0 - assert "Created marketplace.yml" in result.output + assert result.exit_code == 0, result.output + block = _load_marketplace_block(tmp_path / "apm.yml") + assert "owner" in block + assert "packages" in block - def test_next_steps_shown(self, runner, tmp_path, monkeypatch): + def test_success_message(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = runner.invoke(marketplace, ["init"]) assert result.exit_code == 0 - assert "apm marketplace build" in result.output + # Single collapsed success line for scaffold-and-inject path. + assert "Created apm.yml with 'marketplace:' block" in result.output - def test_template_roundtrips_through_schema(self, runner, tmp_path, monkeypatch): - """The scaffolded file must parse without errors.""" + def test_next_steps_shown(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = runner.invoke(marketplace, ["init"]) assert result.exit_code == 0 - yml = load_marketplace_yml(tmp_path / "marketplace.yml") - assert yml.name == "my-marketplace" - assert yml.version == "0.1.0" - assert yml.owner.name == "acme-org" - assert len(yml.packages) >= 1 + assert "apm marketplace build" in result.output # --------------------------------------------------------------------------- -# File-already-exists guard +# Existing-block guard # --------------------------------------------------------------------------- class TestInitExistsGuard: - def test_error_when_file_exists(self, runner, tmp_path, monkeypatch): + def test_error_when_marketplace_block_exists(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - existing = tmp_path / "marketplace.yml" - existing.write_text("name: keep-me\n", encoding="utf-8") - + (tmp_path / "apm.yml").write_text( + "name: my-app\nversion: 1.0.0\ndescription: x\n" + "marketplace:\n owner:\n name: keep-me\n", + encoding="utf-8", + ) result = runner.invoke(marketplace, ["init"]) assert result.exit_code == 1 - assert "already exists" in result.output - - def test_file_unchanged_without_force(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - existing = tmp_path / "marketplace.yml" - original_content = "name: keep-me\n" - existing.write_text(original_content, encoding="utf-8") - - runner.invoke(marketplace, ["init"]) - assert existing.read_text(encoding="utf-8") == original_content + assert "already" in result.output.lower() - def test_force_overwrites(self, runner, tmp_path, monkeypatch): + def test_force_overwrites_existing_block(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - existing = tmp_path / "marketplace.yml" - existing.write_text("name: stale-sentinel\n", encoding="utf-8") - + (tmp_path / "apm.yml").write_text( + "name: my-app\nversion: 1.0.0\ndescription: x\n" + "marketplace:\n owner:\n name: stale-sentinel\n", + encoding="utf-8", + ) result = runner.invoke(marketplace, ["init", "--force"]) assert result.exit_code == 0 - new_content = existing.read_text(encoding="utf-8") - assert "my-marketplace" in new_content - assert "stale-sentinel" not in new_content + text = (tmp_path / "apm.yml").read_text(encoding="utf-8") + assert "stale-sentinel" not in text # --------------------------------------------------------------------------- @@ -101,31 +106,15 @@ def test_force_overwrites(self, runner, tmp_path, monkeypatch): class TestInitGitignoreCheck: + @pytest.mark.parametrize( + "pattern", + ["marketplace.json\n", "**/marketplace.json\n", "/marketplace.json\n"], + ) def test_warns_when_gitignore_ignores_marketplace_json( - self, runner, tmp_path, monkeypatch, + self, runner, tmp_path, monkeypatch, pattern, ): monkeypatch.chdir(tmp_path) - (tmp_path / ".gitignore").write_text( - "marketplace.json\n", encoding="utf-8", - ) - result = runner.invoke(marketplace, ["init"]) - assert result.exit_code == 0 - assert ".gitignore ignores marketplace.json" in result.output - - def test_warns_for_glob_pattern(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - (tmp_path / ".gitignore").write_text( - "**/marketplace.json\n", encoding="utf-8", - ) - result = runner.invoke(marketplace, ["init"]) - assert result.exit_code == 0 - assert ".gitignore ignores marketplace.json" in result.output - - def test_warns_for_rooted_pattern(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - (tmp_path / ".gitignore").write_text( - "/marketplace.json\n", encoding="utf-8", - ) + (tmp_path / ".gitignore").write_text(pattern, encoding="utf-8") result = runner.invoke(marketplace, ["init"]) assert result.exit_code == 0 assert ".gitignore ignores marketplace.json" in result.output @@ -176,25 +165,18 @@ def test_verbose_shows_path(self, runner, tmp_path, monkeypatch): class TestInitContentSafety: - def test_template_contains_acme_org(self, runner, tmp_path, monkeypatch): + def test_template_is_pure_ascii(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) runner.invoke(marketplace, ["init"]) - content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8") - assert "acme-org" in content + content = (tmp_path / "apm.yml").read_text(encoding="utf-8") + content.encode("ascii") # raises UnicodeEncodeError if non-ASCII def test_template_has_no_epam_references(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) runner.invoke(marketplace, ["init"]) - content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8").lower() - assert "epam" not in content - assert "bookstore" not in content - assert "agent-forge" not in content - - def test_template_is_pure_ascii(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - runner.invoke(marketplace, ["init"]) - content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8") - content.encode("ascii") # raises UnicodeEncodeError if non-ASCII + content = (tmp_path / "apm.yml").read_text(encoding="utf-8").lower() + for forbidden in ("epam", "bookstore", "agent-forge"): + assert forbidden not in content # --------------------------------------------------------------------------- @@ -203,48 +185,25 @@ def test_template_is_pure_ascii(self, runner, tmp_path, monkeypatch): class TestInitNameOwnerFlags: - def test_custom_name(self, runner, tmp_path, monkeypatch): + def test_custom_name_used_for_scaffolded_apm_yml( + self, runner, tmp_path, monkeypatch, + ): monkeypatch.chdir(tmp_path) result = runner.invoke(marketplace, ["init", "--name", "cool-tools"]) - assert result.exit_code == 0 - yml = load_marketplace_yml(tmp_path / "marketplace.yml") - assert yml.name == "cool-tools" + assert result.exit_code == 0, result.output + data = yaml.safe_load((tmp_path / "apm.yml").read_text(encoding="utf-8")) + assert data["name"] == "cool-tools" def test_custom_owner(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = runner.invoke(marketplace, ["init", "--owner", "my-org"]) - assert result.exit_code == 0 - yml = load_marketplace_yml(tmp_path / "marketplace.yml") - assert yml.owner.name == "my-org" - - def test_custom_name_and_owner(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke( - marketplace, ["init", "--name", "my-mkt", "--owner", "my-team"], - ) - assert result.exit_code == 0 - yml = load_marketplace_yml(tmp_path / "marketplace.yml") - assert yml.name == "my-mkt" - assert yml.owner.name == "my-team" - content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8") - assert "my-team" in content - # The default acme-org should not appear when owner is overridden. - assert "acme-org" not in content + assert result.exit_code == 0, result.output + block = _load_marketplace_block(tmp_path / "apm.yml") + assert block["owner"]["name"] == "my-org" - def test_defaults_without_flags(self, runner, tmp_path, monkeypatch): - """Without --name/--owner the defaults are used.""" + def test_default_owner_is_acme_org(self, runner, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) result = runner.invoke(marketplace, ["init"]) assert result.exit_code == 0 - yml = load_marketplace_yml(tmp_path / "marketplace.yml") - assert yml.name == "my-marketplace" - assert yml.owner.name == "acme-org" - - def test_custom_values_are_pure_ascii(self, runner, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke( - marketplace, ["init", "--name", "ascii-only", "--owner", "plain-org"], - ) - assert result.exit_code == 0 - content = (tmp_path / "marketplace.yml").read_text(encoding="utf-8") - content.encode("ascii") # raises UnicodeEncodeError if non-ASCII + block = _load_marketplace_block(tmp_path / "apm.yml") + assert block["owner"]["name"] == "acme-org" diff --git a/tests/unit/commands/test_marketplace_migrate.py b/tests/unit/commands/test_marketplace_migrate.py new file mode 100644 index 000000000..6811ce1ae --- /dev/null +++ b/tests/unit/commands/test_marketplace_migrate.py @@ -0,0 +1,124 @@ +"""Tests for ``apm marketplace migrate``.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import marketplace +from apm_cli.core import experimental + + +def _write(p: Path, content: str) -> None: + p.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") + + +_LEGACY = """\ +name: my-marketplace +description: A marketplace. +version: 1.0.0 +owner: + name: ACME + url: https://github.com/acme +build: + tagPattern: "v{version}" +packages: + - name: tool-a + source: acme/tool-a + ref: main +""" + + +_APM = """\ +name: my-project +description: A project. +version: 1.0.0 +""" + + +@pytest.fixture(autouse=True) +def _enable_authoring(monkeypatch: pytest.MonkeyPatch) -> None: + """Authoring commands are gated behind an experimental flag.""" + monkeypatch.setattr(experimental, "is_enabled", lambda _: True) + + +@pytest.fixture() +def runner() -> CliRunner: + return CliRunner() + + +class TestMigrateHappyPath: + def test_migrate_writes_block_and_removes_legacy( + self, tmp_path: Path, runner: CliRunner, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path / "apm.yml", _APM) + _write(tmp_path / "marketplace.yml", _LEGACY) + + result = runner.invoke(marketplace, ["migrate"]) + assert result.exit_code == 0, result.output + assert "Migrated" in result.output + assert not (tmp_path / "marketplace.yml").exists() + + new_apm = (tmp_path / "apm.yml").read_text(encoding="utf-8") + assert "marketplace:" in new_apm + assert "owner:" in new_apm + assert "tool-a" in new_apm + + def test_migrate_dry_run_keeps_legacy( + self, tmp_path: Path, runner: CliRunner, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path / "apm.yml", _APM) + _write(tmp_path / "marketplace.yml", _LEGACY) + + result = runner.invoke(marketplace, ["migrate", "--dry-run"]) + assert result.exit_code == 0, result.output + assert "Dry run" in result.output + assert (tmp_path / "marketplace.yml").exists() + # apm.yml unchanged + assert (tmp_path / "apm.yml").read_text(encoding="utf-8") == _APM + + +class TestMigrateGuards: + def test_missing_legacy_yml( + self, tmp_path: Path, runner: CliRunner, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path / "apm.yml", _APM) + result = runner.invoke(marketplace, ["migrate"]) + assert result.exit_code == 1 + assert "marketplace.yml not found" in result.output + + def test_missing_apm_yml( + self, tmp_path: Path, runner: CliRunner, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path / "marketplace.yml", _LEGACY) + result = runner.invoke(marketplace, ["migrate"]) + assert result.exit_code == 1 + assert "apm.yml not found" in result.output + + def test_existing_block_without_force( + self, tmp_path: Path, runner: CliRunner, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path / "apm.yml", _APM + "marketplace:\n owner:\n name: X\n") + _write(tmp_path / "marketplace.yml", _LEGACY) + result = runner.invoke(marketplace, ["migrate"]) + assert result.exit_code == 1 + assert "--force" in result.output + + def test_existing_block_with_force_overwrites( + self, tmp_path: Path, runner: CliRunner, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _write(tmp_path / "apm.yml", _APM + "marketplace:\n owner:\n name: X\n") + _write(tmp_path / "marketplace.yml", _LEGACY) + result = runner.invoke(marketplace, ["migrate", "--force"]) + assert result.exit_code == 0, result.output + new_apm = (tmp_path / "apm.yml").read_text(encoding="utf-8") + assert "ACME" in new_apm # legacy owner.name was 'ACME' diff --git a/tests/unit/marketplace/test_apm_yml_marketplace_loader.py b/tests/unit/marketplace/test_apm_yml_marketplace_loader.py new file mode 100644 index 000000000..7fca529f2 --- /dev/null +++ b/tests/unit/marketplace/test_apm_yml_marketplace_loader.py @@ -0,0 +1,136 @@ +"""Tests for ``load_marketplace_from_apm_yml``. + +Covers inheritance of name/description/version from the apm.yml top +level, override semantics inside the marketplace block, and rejection +of unknown keys at both levels. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from apm_cli.marketplace.errors import MarketplaceYmlError +from apm_cli.marketplace.yml_schema import load_marketplace_from_apm_yml + + +def _write(p: Path, content: str) -> None: + p.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") + + +_MIN_BLOCK_INHERIT = """\ +name: my-project +description: Project description. +version: 1.2.3 +marketplace: + owner: + name: ACME + packages: + - name: tool-a + source: acme/tool-a + ref: v1.0.0 +""" + + +_MIN_BLOCK_OVERRIDE = """\ +name: my-project +description: Project description. +version: 1.2.3 +marketplace: + name: my-marketplace + description: A separate marketplace. + version: 9.9.9 + owner: + name: ACME + packages: + - name: tool-a + source: acme/tool-a + ref: v1.0.0 +""" + + +class TestInheritance: + def test_name_description_version_inherited(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write(apm, _MIN_BLOCK_INHERIT) + config = load_marketplace_from_apm_yml(apm) + assert config.name == "my-project" + assert config.description == "Project description." + assert config.version == "1.2.3" + assert config.is_legacy is False + assert config.name_overridden is False + assert config.description_overridden is False + assert config.version_overridden is False + + def test_overrides_take_precedence(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write(apm, _MIN_BLOCK_OVERRIDE) + config = load_marketplace_from_apm_yml(apm) + assert config.name == "my-marketplace" + assert config.description == "A separate marketplace." + assert config.version == "9.9.9" + assert config.name_overridden is True + assert config.description_overridden is True + assert config.version_overridden is True + + def test_default_output_is_claude_plugin(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write(apm, _MIN_BLOCK_INHERIT) + config = load_marketplace_from_apm_yml(apm) + assert config.output == ".claude-plugin/marketplace.json" + + +class TestValidation: + def test_missing_marketplace_block_rejected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write(apm, "name: foo\nversion: 1.0.0\n") + with pytest.raises(MarketplaceYmlError, match="marketplace"): + load_marketplace_from_apm_yml(apm) + + def test_unknown_key_in_block_rejected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write(apm, """\ + name: my-project + marketplace: + owner: + name: A + bogus: 1 + packages: [] + """) + with pytest.raises(MarketplaceYmlError, match="bogus"): + load_marketplace_from_apm_yml(apm) + + def test_missing_owner_rejected(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write(apm, """\ + name: foo + version: 1.0.0 + description: x + marketplace: + packages: [] + """) + with pytest.raises(MarketplaceYmlError, match="owner"): + load_marketplace_from_apm_yml(apm) + + +class TestLocalPackages: + def test_local_source_skips_version_requirement(self, tmp_path: Path) -> None: + apm = tmp_path / "apm.yml" + _write(apm, """\ + name: my-project + version: 1.0.0 + marketplace: + owner: + name: A + packages: + - name: local-tool + source: ./packages/local-tool + """) + config = load_marketplace_from_apm_yml(apm) + pkg = config.packages[0] + assert pkg.is_local is True + assert pkg.source == "./packages/local-tool" + assert pkg.version is None + assert pkg.ref is None diff --git a/tests/unit/marketplace/test_local_path_compose.py b/tests/unit/marketplace/test_local_path_compose.py new file mode 100644 index 000000000..605509ec0 --- /dev/null +++ b/tests/unit/marketplace/test_local_path_compose.py @@ -0,0 +1,135 @@ +"""Local-path package compose tests for MarketplaceBuilder. + +Verifies that local sources (``./foo``) bypass git resolution and emit +plain-string ``source`` values per the Anthropic spec. +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from apm_cli.marketplace.builder import MarketplaceBuilder, BuildOptions +from apm_cli.marketplace.migration import load_marketplace_config + + +_APM_WITH_LOCAL_BLOCK = """\ +name: my-project +description: A project. +version: 1.0.0 +marketplace: + owner: + name: ACME + packages: + - name: local-tool + source: ./packages/local-tool + description: A locally vendored tool. + homepage: https://example.com/local-tool + version: 0.1.0 + tags: [local, demo] + - name: remote-tool + source: acme/remote-tool + ref: v1.0.0 + tags: [remote] +""" + + +def _write(p: Path, content: str) -> None: + p.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") + + +@pytest.fixture() +def project_with_local(tmp_path: Path) -> Path: + _write(tmp_path / "apm.yml", _APM_WITH_LOCAL_BLOCK) + return tmp_path + + +def test_local_package_skips_git_resolution( + project_with_local: Path, +) -> None: + """Local-path packages must not call git ls-remote.""" + config = load_marketplace_config(project_with_local) + builder = MarketplaceBuilder.from_config( + config, project_with_local, BuildOptions(offline=True) + ) + # Resolve only the local entry. + local_entry = next(p for p in config.packages if p.is_local) + resolved = builder._resolve_entry(local_entry) + assert resolved.source_repo == "" + assert resolved.ref == "" + assert resolved.sha == "" + assert resolved.subdir == "./packages/local-tool" + + +def test_compose_emits_local_source_as_string( + project_with_local: Path, +) -> None: + """Local-path packages must emit ``source`` as a plain string.""" + config = load_marketplace_config(project_with_local) + builder = MarketplaceBuilder.from_config( + config, project_with_local, BuildOptions(offline=True) + ) + + local_entry = next(p for p in config.packages if p.is_local) + local_resolved = builder._resolve_entry(local_entry) + doc = builder.compose_marketplace_json([local_resolved]) + + assert "plugins" in doc + plugin = doc["plugins"][0] + assert plugin["name"] == "local-tool" + assert plugin["source"] == "./packages/local-tool" + assert isinstance(plugin["source"], str) + assert plugin["description"] == "A locally vendored tool." + assert plugin["version"] == "0.1.0" + assert plugin["homepage"] == "https://example.com/local-tool" + + +def test_compose_inherited_top_level_omits_description_and_version( + project_with_local: Path, +) -> None: + """When marketplace block inherits name/desc/version from the project, + the resulting marketplace.json omits description and version at the + top level (Anthropic spec: only emit what the maintainer set). + """ + config = load_marketplace_config(project_with_local) + builder = MarketplaceBuilder.from_config( + config, project_with_local, BuildOptions(offline=True) + ) + local_entry = next(p for p in config.packages if p.is_local) + local_resolved = builder._resolve_entry(local_entry) + doc = builder.compose_marketplace_json([local_resolved]) + + assert doc["name"] == "my-project" + assert "description" not in doc + assert "version" not in doc + + +def test_legacy_compose_keeps_top_level_description(tmp_path: Path) -> None: + """Legacy marketplace.yml files always set the override flags so + the resulting marketplace.json keeps top-level description/version. + """ + legacy = """\ + name: legacy-mp + description: Legacy marketplace. + version: 2.0.0 + owner: + name: ACME + packages: + - name: tool + source: acme/tool + ref: v1.0.0 + """ + (tmp_path / "marketplace.yml").write_text( + textwrap.dedent(legacy), encoding="utf-8" + ) + config = load_marketplace_config(tmp_path) + builder = MarketplaceBuilder.from_config( + config, tmp_path, BuildOptions(offline=True) + ) + # Compose with no resolved packages -- we only inspect the top-level shape. + doc = builder.compose_marketplace_json([]) + assert doc["name"] == "legacy-mp" + assert doc["description"] == "Legacy marketplace." + assert doc["version"] == "2.0.0" diff --git a/tests/unit/marketplace/test_migration_detection.py b/tests/unit/marketplace/test_migration_detection.py new file mode 100644 index 000000000..485cc67b8 --- /dev/null +++ b/tests/unit/marketplace/test_migration_detection.py @@ -0,0 +1,121 @@ +"""Tests for marketplace config-source detection + smart loader.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from apm_cli.marketplace.errors import MarketplaceYmlError +from apm_cli.marketplace.migration import ( + ConfigSource, + DEPRECATION_MESSAGE, + detect_config_source, + load_marketplace_config, +) + + +def _write(p: Path, content: str) -> None: + p.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") + + +_LEGACY_BODY = """\ +name: my-marketplace +description: A marketplace. +version: 1.0.0 +owner: + name: ACME +output: marketplace.json +build: + tagPattern: "v{version}" +packages: + - name: tool-a + source: acme/tool-a + ref: main + version: 1.0.0 +""" + + +_APM_WITH_BLOCK = """\ +name: my-project +description: A project. +version: 1.0.0 +marketplace: + owner: + name: ACME + build: + tagPattern: "v{version}" + packages: + - name: tool-a + source: acme/tool-a + ref: main + version: 1.0.0 +""" + + +_APM_WITHOUT_BLOCK = """\ +name: my-project +description: A project. +version: 1.0.0 +""" + + +class TestDetectConfigSource: + def test_apm_yml_only(self, tmp_path: Path) -> None: + _write(tmp_path / "apm.yml", _APM_WITH_BLOCK) + assert detect_config_source(tmp_path) == ConfigSource.APM_YML + + def test_legacy_only(self, tmp_path: Path) -> None: + _write(tmp_path / "marketplace.yml", _LEGACY_BODY) + assert detect_config_source(tmp_path) == ConfigSource.LEGACY_YML + + def test_neither(self, tmp_path: Path) -> None: + assert detect_config_source(tmp_path) == ConfigSource.NONE + + def test_both_is_hard_error(self, tmp_path: Path) -> None: + _write(tmp_path / "apm.yml", _APM_WITH_BLOCK) + _write(tmp_path / "marketplace.yml", _LEGACY_BODY) + with pytest.raises(MarketplaceYmlError, match="Both apm.yml"): + detect_config_source(tmp_path) + + def test_apm_yml_without_marketplace_block_is_legacy( + self, tmp_path: Path + ) -> None: + _write(tmp_path / "apm.yml", _APM_WITHOUT_BLOCK) + _write(tmp_path / "marketplace.yml", _LEGACY_BODY) + # apm.yml has no marketplace: block, so legacy is the active source + assert detect_config_source(tmp_path) == ConfigSource.LEGACY_YML + + def test_apm_yml_without_marketplace_block_alone_is_none( + self, tmp_path: Path + ) -> None: + _write(tmp_path / "apm.yml", _APM_WITHOUT_BLOCK) + assert detect_config_source(tmp_path) == ConfigSource.NONE + + +class TestLoadMarketplaceConfig: + def test_apm_yml_load(self, tmp_path: Path) -> None: + _write(tmp_path / "apm.yml", _APM_WITH_BLOCK) + config = load_marketplace_config(tmp_path) + assert config.name == "my-project" + assert config.is_legacy is False + + def test_legacy_load_emits_deprecation(self, tmp_path: Path) -> None: + _write(tmp_path / "marketplace.yml", _LEGACY_BODY) + warnings: list = [] + config = load_marketplace_config( + tmp_path, warn_callback=warnings.append + ) + assert config.is_legacy is True + assert warnings == [DEPRECATION_MESSAGE] + + def test_no_config_raises(self, tmp_path: Path) -> None: + with pytest.raises(MarketplaceYmlError, match="No marketplace config"): + load_marketplace_config(tmp_path) + + def test_both_files_raises(self, tmp_path: Path) -> None: + _write(tmp_path / "apm.yml", _APM_WITH_BLOCK) + _write(tmp_path / "marketplace.yml", _LEGACY_BODY) + with pytest.raises(MarketplaceYmlError, match="Both apm.yml"): + load_marketplace_config(tmp_path) diff --git a/tests/unit/marketplace/test_yml_schema.py b/tests/unit/marketplace/test_yml_schema.py index 123ff83b7..008dc4995 100644 --- a/tests/unit/marketplace/test_yml_schema.py +++ b/tests/unit/marketplace/test_yml_schema.py @@ -687,17 +687,29 @@ def test_owner_not_a_mapping(self, tmp_path: Path): load_marketplace_yml(yml) def test_source_dot_traversal(self, tmp_path: Path): - """Single-dot segment in source is also traversal.""" + """Local-path source './acme' is now valid (no version/ref needed).""" content = _minimal_yml( packages=( "packages:\n" " - name: tool-a\n" - " source: ./acme\n" - " ref: main" + " source: ./acme" + ) + ) + yml = _write_yml(tmp_path, content) + result = load_marketplace_yml(yml) + assert result.packages[0].is_local is True + assert result.packages[0].source == "./acme" + + def test_source_double_dot_rejected(self, tmp_path: Path): + """``..`` traversal is still rejected for both remote and local sources.""" + content = _minimal_yml( + packages=( + "packages:\n" + " - name: tool-a\n" + " source: ./../acme" ) ) yml = _write_yml(tmp_path, content) - # '.' triggers traversal, but also fails the owner/repo regex with pytest.raises(MarketplaceYmlError): load_marketplace_yml(yml)