From b559389f89bee47d72a0c995fc5af7f8136d7ec5 Mon Sep 17 00:00:00 2001 From: Nadeesha-d-shalom Date: Thu, 22 Oct 2026 20:42:17 +0530 Subject: [PATCH] feat(marketplace): load explicitly selected local authoring catalogs --- .../owners/marketplace-plugins.json | 7 + .../consumer/installing-from-marketplaces.md | 13 + .../docs/producer/publish-to-a-marketplace.md | 18 ++ .../content/docs/reference/cli/marketplace.md | 44 ++- .../.apm/skills/apm-usage/commands.md | 14 +- .../marketplace_catalog_and_contract.py | 50 ++++ src/apm_cli/commands/marketplace/__init__.py | 43 ++- src/apm_cli/marketplace/authoring_source.py | 71 +++++ src/apm_cli/marketplace/builder.py | 40 ++- src/apm_cli/marketplace/client.py | 19 ++ .../test_architecture_authoring_source.py | 25 ++ .../test_marketplace_authoring_lifecycle.py | 258 ++++++++++++++++++ .../unit/marketplace/test_authoring_source.py | 195 +++++++++++++ 13 files changed, 781 insertions(+), 16 deletions(-) create mode 100644 src/apm_cli/marketplace/authoring_source.py create mode 100644 tests/integration/test_architecture_authoring_source.py create mode 100644 tests/integration/test_marketplace_authoring_lifecycle.py create mode 100644 tests/unit/marketplace/test_authoring_source.py diff --git a/.apm/architecture/owners/marketplace-plugins.json b/.apm/architecture/owners/marketplace-plugins.json index 9c995379c3..9903fef20b 100644 --- a/.apm/architecture/owners/marketplace-plugins.json +++ b/.apm/architecture/owners/marketplace-plugins.json @@ -1,6 +1,13 @@ { "version": 1, "owners": [ + { + "id": "local-authoring-catalog", + "decision": "Live local authoring catalog interpretation without generation", + "owner": "marketplace/authoring_source.py coordinates yml_schema, MarketplaceBuilder and resolver path authority", + "selectors": ["src/apm_cli/marketplace/authoring_source.py", "src/apm_cli/marketplace/client.py"], + "guards": ["marketplace-integrations-authoring-source"] + }, { "id": "marketplace-locked-selection", "decision": "Marketplace requested-ref consistency against locked selection evidence", diff --git a/docs/src/content/docs/consumer/installing-from-marketplaces.md b/docs/src/content/docs/consumer/installing-from-marketplaces.md index 4558ecdaac..57eee20f07 100644 --- a/docs/src/content/docs/consumer/installing-from-marketplaces.md +++ b/docs/src/content/docs/consumer/installing-from-marketplaces.md @@ -33,6 +33,19 @@ Most read `.claude-plugin/marketplace.json` (Anthropic-compatible schema); Codex reads `.agents/plugins/marketplace.json`. Producers who enable both outputs reach every runtime from one repo. +## Local authoring previews through APM + +Register an explicitly selected local authoring file with +`apm marketplace add ./apm.yml --name alice-dev`, then browse it with +`apm marketplace browse alice-dev` or select a package with +`apm install my-tool@alice-dev`. APM rereads the file on later explicit +operations; registration does not install its inventory or publish anything. +Relative package sources resolve from the file's directory. Remote entries +install through their actual dependency source and existing lock machinery; +locked replay does not require the author's preview file. Local entries still +require their local directories. See the [marketplace CLI reference](../../reference/cli/marketplace/) +for validation and offline behavior. + ## Codex compatibility bundles through APM APM recognizes a compatibility-only `.codex-plugin/plugin.json` without diff --git a/docs/src/content/docs/producer/publish-to-a-marketplace.md b/docs/src/content/docs/producer/publish-to-a-marketplace.md index e43e083689..1968921c36 100644 --- a/docs/src/content/docs/producer/publish-to-a-marketplace.md +++ b/docs/src/content/docs/producer/publish-to-a-marketplace.md @@ -13,6 +13,24 @@ your repo with `apm marketplace add`. This page covers the authoring surface: the registry schema and the `apm marketplace` verbs. +For a local preview, register the authoring file directly instead of generating +JSON first: + +```bash +apm marketplace add ./apm.yml --name alice-dev +apm marketplace browse alice-dev +``` + +This registers a live local discovery source. Later explicit operations reread +the file, resolving relative package paths from its directory. Registration +does not build, install inventory, execute hooks or publish packages. Use +`--offline` with `add` or `browse` for local entries or remote entries pinned to +full commit SHAs; remote tags and ranges need fresh resolution in a new CLI +invocation. Continue using `apm pack` +for native generated outputs and publication. A local package path is not a +portable distribution source. See the [CLI reference](../../reference/cli/marketplace/) +for resolution and locked replay semantics. + For the operational concerns that surround a marketplace, see: - [Repo shapes](../repo-shapes/) -- single-plugin, aggregator, and diff --git a/docs/src/content/docs/reference/cli/marketplace.md b/docs/src/content/docs/reference/cli/marketplace.md index 3b370c0cbf..5648946357 100644 --- a/docs/src/content/docs/reference/cli/marketplace.md +++ b/docs/src/content/docs/reference/cli/marketplace.md @@ -14,9 +14,9 @@ validating it, and preparing artifacts for consumer repositories). ```bash # Consume -apm marketplace add SOURCE [--name N] [--ref R | --branch B] [--host FQDN] +apm marketplace add SOURCE [--name N] [--ref R | --branch B] [--host FQDN] [--offline] apm marketplace list -apm marketplace browse NAME +apm marketplace browse NAME [--offline] apm marketplace update [NAME] apm marketplace remove NAME apm marketplace validate NAME @@ -71,9 +71,47 @@ Register a marketplace from a source reference. Accepted forms: select a branch, tag, or commit. - Local filesystem path -- absolute (`/srv/marketplaces/agent-forge`), relative (`./local-mkt`), home-based (`~/code/marketplace`), or a - direct `marketplace.json` file. + direct `marketplace.json` file, or an explicitly selected local `apm.yml`. - `file://` URI -- `file:///srv/marketplaces/agent-forge.git`. +#### Live local authoring catalogs + +```bash +apm marketplace add ./apm.yml --name alice-dev +apm marketplace browse alice-dev +apm view my-tool@alice-dev +apm install my-tool@alice-dev +``` + +The selected file must contain a valid `marketplace:` declaration. APM uses +the existing authoring parser, ref resolver and catalog composer in memory; +it does not run `apm pack` or write generated catalogs. Relative package +sources are anchored to the authoring file's directory, with the existing +path containment checks. Native `metadata.pluginRoot` layout hints do not +rebase authored package paths. Each invocation reads one configuration +snapshot; later explicit browse, view, update or selected-install operations +reread the file. A failed registration leaves an existing registration unchanged. + +Registration and browsing do not install inventory, activate plugins, execute +package hooks or publish packages. Remote tags and version ranges may require +Git network access to resolve. Cosmetic remote package metadata is not fetched. +For this local YAML workflow, `add --offline` and `browse --offline` use the +existing cached-ref resolver: local entries and explicit commit SHAs work +without network; an uncached tag or range fails with an actionable error. +Ref caches are in memory for one invocation; an earlier online registration +does not make tags or ranges available in a later offline invocation. Pin +remote entries to full commit SHAs for an offline authoring preview. +These flags do not add offline support to JSON, Git or HTTPS catalog sources. + +Installing a remote entry records its actual package dependency and existing +locked resolution. Locked replay does not require the preview file or its +registration. Local entries remain local filesystem dependencies: their +paths and hashes do not distribute files to another machine. Publishing and +selecting a distributed source remain explicit operations. Remote YAML +catalog URLs, automatic watching and automatic source promotion are unsupported. + +Generated JSON catalogs and repository registration continue to work as before. + ```bash # GitHub shorthand apm marketplace add my-org/awesome-agents diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 71abf3c858..ffd8690121 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -655,7 +655,7 @@ Credentials resolve via `APM_REGISTRY_TOKEN_{NAME}` env var (or `apm config set |---------|---------|-----------| | `apm marketplace add SOURCE` | Register a marketplace. `SOURCE` accepts `OWNER/REPO`, `HOST/OWNER/REPO`, nested `HOST/group/sub/.../REPO`, HTTPS git URL with optional `#ref`, hosted `marketplace.json` URL, SSH URL (`git@host:org/repo.git` or `ssh://git@host[:PORT]/org/repo.git`), local directory or file path, or `file://` URI. `--ref` applies only to git-backed sources; `--host` applies only to shorthand sources. | `-n NAME`, `-r REF`, `--host HOST` | | `apm marketplace list` | List registered marketplaces | -- | -| `apm marketplace browse NAME` | Browse marketplace plugins | -- | +| `apm marketplace browse NAME` | Browse marketplace plugins; reread live local `apm.yml` catalogs | `--offline` (local YAML only) | | `apm marketplace update [NAME]` | Update marketplace index | -- | | `apm marketplace remove NAME` | Remove a marketplace | `-y` skip confirm | | `apm marketplace validate NAME` | Validate raw structure, plugin schema, and duplicate plugin names. Add accepts unsupported or malformed entries with a warning; validate reports paths and exits 1 without rewriting the source manifest or registered marketplace entry. | `-v` | @@ -665,6 +665,18 @@ Credentials resolve via `APM_REGISTRY_TOKEN_{NAME}` env var (or `apm config set ## Marketplace authoring +For a live local preview, run `apm marketplace add ./apm.yml --name alice-dev`. +The file must contain a valid `marketplace:` declaration. Browse, view, update +and selected installs reread it on later invocations; relative package paths +resolve from the file's directory. Registration neither installs inventory nor +executes hooks, publishes packages or writes generated catalogs. `add --offline` +and `browse --offline` are limited to local YAML: local entries and commit SHAs +need no network, while uncached remote tags/ranges fail. Ref caches are not +persisted across CLI invocations. Remote entries install +through their actual dependency source and existing lock resolution, so locked +replay does not require the preview. Local directory dependencies remain local. +Native generated JSON and repository registration retain their existing flow. + > Source of truth is the `marketplace:` block in `apm.yml`. `apm pack` produces `.claude-plugin/marketplace.json` whenever that block is present. The legacy standalone `marketplace.yml` is deprecated -- use `apm marketplace migrate` to fold it in. | Command | Purpose | Key flags | diff --git a/scripts/architecture_linter/checks/marketplace_catalog_and_contract.py b/scripts/architecture_linter/checks/marketplace_catalog_and_contract.py index aa2faa9809..c37e53db26 100644 --- a/scripts/architecture_linter/checks/marketplace_catalog_and_contract.py +++ b/scripts/architecture_linter/checks/marketplace_catalog_and_contract.py @@ -828,7 +828,57 @@ def _scan_agent_plugin_duplicates( return tuple(findings) +def _check_authoring_source(provider: FactsProvider) -> tuple[Violation, ...]: + """Keep direct YAML consumption on canonical parsing and composition.""" + inv = frozenset(provider.inventory) + rule_id = "marketplace-integrations-authoring-source" + path = "src/apm_cli/marketplace/authoring_source.py" + findings = list( + _require_subs( + provider, + inv, + rule_id, + path, + ( + "load_marketplace_from_apm_yml(path)", + "MarketplaceBuilder.from_config(", + "builder.resolve()", + "builder.compose_discovery_catalog(", + "resolve_local_plugin_path(", + ), + "Local authoring catalogs must reuse canonical parser, resolver and composer", + ) + ) + facts, failures = checked_facts(provider, path, rule_id, require_python=True) + findings.extend(failures) + for call in facts.calls: + if call.qualname.rsplit(".", 1)[-1] in { + "build", + "write_output", + "write_text", + "write_bytes", + "safe_load", + "load_yaml_str", + }: + findings.append( + violation( + rule_id, + path, + "Authoring catalog reads must not parse YAML independently or invoke writing", + line=call.line, + ) + ) + return tuple(findings) + + RULES: tuple[Rule, ...] = ( + Rule( + id="marketplace-integrations-authoring-source", + group=GROUP, + guard_ids=("marketplace-integrations-authoring-source",), + description="Live local authoring catalogs reuse canonical owners without writing.", + check=_check_authoring_source, + ), Rule( id=_RID_CATALOG, group=GROUP, diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index ac22e5eb10..117de2715d 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -298,6 +298,7 @@ def _marketplace_add_unsupported_host_error( \b Examples: apm marketplace add owner/repo + apm marketplace add ./apm.yml --name alice-dev apm marketplace add github.com/owner/repo apm marketplace add https://github.com/owner/repo#v1.0.0 apm marketplace add https://catalog.example.com/marketplace.json --name catalog @@ -325,8 +326,13 @@ def _marketplace_add_unsupported_host_error( help="Git host FQDN for OWNER/REPO shorthand (default: github.com)", ) @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") +@click.option( + "--offline", + is_flag=True, + help="Read local apm.yml without network (remote entries need commit SHAs)", +) @serialized_lifecycle -def add(source, name, ref, branch, host, verbose): +def add(source, name, ref, branch, host, verbose, offline=False): """Register a marketplace. SOURCE accepts: OWNER/REPO shorthand, HOST/OWNER/REPO shorthand, a full @@ -334,11 +340,16 @@ def add(source, name, ref, branch, host, verbose): Gitea, Bitbucket Server, or any self-hosted git server), a hosted ``marketplace.json`` URL, an SSH URL (``git@host:org/repo.git`` or ``ssh://git@host:2222/org/repo.git``), - a local filesystem path, or a ``file://`` URI. + a local filesystem path (including an explicitly selected ``apm.yml``), + or a ``file://`` URI. """ logger = CommandLogger("marketplace-add", verbose=verbose) try: - from ...marketplace.client import _auto_detect_path, fetch_marketplace + from ...marketplace.client import ( + _auto_detect_path, + fetch_marketplace, + is_local_authoring_source, + ) from ...marketplace.models import MarketplaceSource from ...marketplace.registry import add_marketplace from ...marketplace.source_identity import parse_marketplace_source @@ -441,6 +452,12 @@ def add(source, name, ref, branch, host, verbose): ref="" if is_direct_url else effective_ref, path="" if is_direct_url else "marketplace.json", ) + if offline and not is_local_authoring_source(probe_source): + logger.error( + "--offline requires an explicitly selected local apm.yml. " + "Omit --offline to register a JSON or repository source." + ) + sys.exit(1) if is_direct_url or _local_source_points_to_file(probe_source): detected_path = "" else: @@ -461,7 +478,9 @@ def add(source, name, ref, branch, host, verbose): ref="" if is_direct_url else effective_ref, path=detected_path, ) - manifest = fetch_marketplace(fetch_source, force_refresh=True) + manifest = fetch_marketplace( + fetch_source, force_refresh=True, **({"offline": True} if offline else {}) + ) plugin_count = len(manifest.plugins) manifest_name = (manifest.name or "").strip() @@ -583,8 +602,11 @@ def _local_source_points_to_file(source: MarketplaceSource) -> bool: """Return True when a local marketplace source points directly to a file.""" if source.kind != "local": return False + from ...marketplace.client import is_local_authoring_source + try: - return Path(source.local_path).expanduser().is_file() + path = Path(source.local_path).expanduser() + return path.is_file() or is_local_authoring_source(source) except OSError: return False @@ -700,7 +722,12 @@ def list_cmd(verbose): @marketplace.command(help="Browse plugins in a marketplace") @click.argument("name", required=True) @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") -def browse(name, verbose): +@click.option( + "--offline", + is_flag=True, + help="Read local apm.yml without network (remote entries need commit SHAs)", +) +def browse(name, verbose, offline=False): """Show available plugins in a marketplace.""" logger = CommandLogger("marketplace-browse", verbose=verbose) try: @@ -710,7 +737,9 @@ def browse(name, verbose): source = get_marketplace_by_name(name) logger.start(f"Fetching plugins from '{name}'...", symbol="search") - manifest = fetch_marketplace(source, force_refresh=True) + manifest = fetch_marketplace( + source, force_refresh=True, **({"offline": True} if offline else {}) + ) if not manifest.plugins: logger.warning(f"Marketplace '{name}' has no plugins") diff --git a/src/apm_cli/marketplace/authoring_source.py b/src/apm_cli/marketplace/authoring_source.py new file mode 100644 index 0000000000..e2f3a0a460 --- /dev/null +++ b/src/apm_cli/marketplace/authoring_source.py @@ -0,0 +1,71 @@ +"""Read an explicitly selected local authoring catalog without producing files.""" + +from __future__ import annotations + +from pathlib import Path + +import click + +from .builder import BuildOptions, MarketplaceBuilder +from .errors import MarketplaceError, MarketplaceFetchError, OfflineMissError +from .models import MarketplaceSource, parse_marketplace_json +from .resolver import resolve_local_plugin_path +from .yml_schema import load_marketplace_from_apm_yml + + +def load_authoring_catalog( + source: MarketplaceSource, + *, + offline: bool = False, + auth_resolver: object | None = None, +) -> dict: + """Resolve and compose one snapshot per Click invocation, rereading next time. + + Only the user-selected local ``apm.yml`` enters this path. Native output + mappers still own the catalog shape; no packing or writing is involved. + Remote refs use the normal builder resolver. Cosmetic remote metadata is + deliberately omitted so browsing does not download every package manifest. + """ + path = Path(source.local_path).expanduser().absolute() + ctx = click.get_current_context(silent=True) + snapshots = ctx.find_root().meta.setdefault(__name__, {}) if ctx else {} + key = (str(path), offline) + if key in snapshots: + return snapshots[key] + + try: + config = load_marketplace_from_apm_yml(path) + builder = MarketplaceBuilder.from_config( + config, + path.parent, + options=BuildOptions(offline=offline), + auth_resolver=auth_resolver, + ) + # Authored paths are relative to the file. Validate them through the + # existing containment owner before resolving or composing the catalog. + for entry in config.packages: + if entry.is_local: + resolve_local_plugin_path(entry.source, source) + resolved = builder.resolve() + document = builder.compose_discovery_catalog(resolved.entries) + manifest = parse_marketplace_json(document, source.name) + # Validate the composed consumer interpretation through the same owner; + # native output layout hints must not change these author-relative paths. + for plugin in manifest.plugins: + if isinstance(plugin.source, str): + resolve_local_plugin_path(plugin.source, source, manifest.plugin_root) + if manifest.structural_errors: + raise ValueError("; ".join(manifest.structural_errors)) + except (MarketplaceError, OSError, ValueError) as exc: + retry_hint = ( + "Retry without --offline to resolve remote refs, or pin remote entries to full commit SHAs." + if isinstance(exc, OfflineMissError) + else "Check the marketplace section, package paths and refs in that file, then retry." + ) + raise MarketplaceFetchError( + source.name, + f"cannot load marketplace declaration from {path}: {exc}", + retry_hint=retry_hint, + ) from exc + snapshots[key] = document + return document diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index ee16396dbf..454457c09f 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -25,7 +25,7 @@ import urllib.request from collections.abc import Iterator, Mapping from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -744,11 +744,13 @@ def _map_output( profile: MarketplaceOutputProfile, resolved: tuple[ResolvedPackage, ...], remote_metadata: Mapping[str, dict[str, str]] | None = None, + *, + config: MarketplaceYml | None = None, ) -> MapperResult: """Map resolved packages into one marketplace output format.""" mapper = self._mapper_for_profile(profile) return mapper.compose( - config=self._load_yml(), + config=config if config is not None else self._load_yml(), resolved=resolved, remote_metadata=remote_metadata, ) @@ -1064,14 +1066,16 @@ def resolve(self) -> ResolveResult: # Eagerly resolve auth + create the shared RefResolver before # spawning workers -- avoids a race on _ensure_auth() and # matches the pattern used in _prefetch_metadata(). - self._get_resolver() + remote_entries = [entry for entry in entries if not entry.is_local] + if remote_entries: + self._get_resolver() # Pre-warm per-host resolvers on the main thread so workers never race # to create the same resolver. Include the sourceBase host because # base-relative entries derive their host during composition. - source_base_parts = self._get_source_base_parts() + source_base_parts = self._get_source_base_parts() if remote_entries else None if source_base_parts is not None: self._get_resolver_for_host(source_base_parts.host, org=source_base_parts.org_hint) - for entry in entries: + for entry in remote_entries: if entry.host: self._get_resolver_for_host(entry.host) @@ -1512,6 +1516,32 @@ def compose_marketplace_json(self, resolved: list[ResolvedPackage]) -> dict[str, self._compose_diagnostics = mapper_result.diagnostics return mapper_result.document + def compose_discovery_catalog(self, resolved: tuple[ResolvedPackage, ...]) -> dict[str, Any]: + """Compose an in-memory discovery catalog with local metadata only. + + Ref resolution is separate and may require network access. Discovery + does not fetch cosmetic manifests for every remote package, but keeps + the existing local metadata and curator precedence through the mapper. + """ + metadata = { + pkg.name: self._fetch_local_metadata(pkg) or {} + for pkg in resolved + if not pkg.source_repo + } + # Authored paths are already relative to the authoring file. pluginRoot + # is a native-output layout hint: applying its stripped representation + # to APM's local consumer would rebase bare and nested paths differently. + # Compose a separate immutable view; native output and the parsed + # configuration retain their original pluginRoot contract. + config = self._load_yml() + discovery_config = replace( + config, + metadata={key: value for key, value in config.metadata.items() if key != "pluginRoot"}, + ) + return self._map_output( + DEFAULT_MARKETPLACE_OUTPUT, resolved, remote_metadata=metadata, config=discovery_config + ).document + def compose_codex_marketplace_json( self, resolved: list[ResolvedPackage], diff --git a/src/apm_cli/marketplace/client.py b/src/apm_cli/marketplace/client.py index a8a8c622d4..b0336eadfc 100644 --- a/src/apm_cli/marketplace/client.py +++ b/src/apm_cli/marketplace/client.py @@ -1199,11 +1199,17 @@ def _auto_detect_path( # --------------------------------------------------------------------------- +def is_local_authoring_source(source: MarketplaceSource) -> bool: + """Only explicitly selected local apm.yml files use authoring semantics.""" + return source.kind == "local" and Path(source.local_path).name == "apm.yml" + + def fetch_marketplace( source: MarketplaceSource, *, force_refresh: bool = False, auth_resolver: object | None = None, + offline: bool = False, ) -> MarketplaceManifest: """Fetch and parse a marketplace manifest. @@ -1215,6 +1221,8 @@ def fetch_marketplace( source: Marketplace source to fetch. force_refresh: Skip cache and re-fetch from network. auth_resolver: Optional ``AuthResolver`` instance (created if None). + offline: For an explicitly selected local apm.yml, forbid network ref + resolution. Other source kinds reject this option. Returns: MarketplaceManifest: Parsed manifest. @@ -1222,6 +1230,17 @@ def fetch_marketplace( Raises: MarketplaceFetchError: If fetch fails and no cache is available. """ + if is_local_authoring_source(source): + from .authoring_source import load_authoring_catalog + + data = load_authoring_catalog(source, offline=offline, auth_resolver=auth_resolver) + return parse_marketplace_json(data, source.name) + if offline: + raise MarketplaceFetchError( + source.name, + "--offline is supported here only for explicitly selected local apm.yml catalogs", + retry_hint="Omit --offline to use the existing JSON or repository workflow.", + ) cache_name = _cache_key(source) use_sidecar_cache = source.kind in ("github", "gitlab", "ado", "url") diff --git a/tests/integration/test_architecture_authoring_source.py b/tests/integration/test_architecture_authoring_source.py new file mode 100644 index 0000000000..a768e76ae3 --- /dev/null +++ b/tests/integration/test_architecture_authoring_source.py @@ -0,0 +1,25 @@ +"""A read-only authoring adapter must not become a second pack pipeline.""" + +from pathlib import Path + +import pytest + +from scripts.architecture_linter.runner import run_selected_rules + +pytestmark = pytest.mark.component + + +def test_authoring_source_guard_rejects_packing(): + root = Path(__file__).parents[2] + path = "src/apm_cli/marketplace/authoring_source.py" + original = (root / path).read_text(encoding="utf-8") + rule = "marketplace-integrations-authoring-source" + clean = run_selected_rules(root, (rule,)) + assert clean.failures == () + assert clean.violations == () + mutated = original.replace( + "resolved = builder.resolve()", "builder.build()\n resolved = builder.resolve()" + ) + report = run_selected_rules(root, (rule,), source_overrides={path: mutated}) + assert report.failures == () + assert any(item.rule_id == rule for item in report.violations) diff --git a/tests/integration/test_marketplace_authoring_lifecycle.py b/tests/integration/test_marketplace_authoring_lifecycle.py new file mode 100644 index 0000000000..3016db83e8 --- /dev/null +++ b/tests/integration/test_marketplace_authoring_lifecycle.py @@ -0,0 +1,258 @@ +"""Installed-CLI contracts for explicitly registered live authoring files.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +import yaml + +from apm_cli.models.dependency.reference import DependencyReference +from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner +from tests.utils.artifact_snapshot import ArtifactSnapshot, assert_unchanged +from tests.utils.isolated_apm_environment import IsolatedApmEnvironment +from tests.utils.local_git_repository import LocalGitRepositoryFactory +from tests.utils.local_package import LocalPackageFactory + +pytestmark = [pytest.mark.e2e, pytest.mark.requires_apm_binary, pytest.mark.lifecycle_smoke] + + +def _catalog(path: Path, packages: list[dict], *, plugin_root: str = "") -> None: + path.write_text( + yaml.safe_dump( + { + "name": "alice-dev", + "version": "1.0.0", + "marketplace": { + "owner": {"name": "Alice"}, + "packages": packages, + "metadata": {"pluginRoot": plugin_root} if plugin_root else {}, + }, + } + ), + encoding="utf-8", + ) + + +def test_live_local_catalog_register_browse_view_install_and_edit(tmp_path, apm_binary_path): + isolated = IsolatedApmEnvironment.create(tmp_path / "isolated", base_env=dict(os.environ)) + environment = isolated.subprocess_env(overrides={"COLUMNS": "240"}) + runner = ApmLifecycleRunner((str(apm_binary_path),)) + author = isolated.work_root / "author" + packages = LocalPackageFactory(author / "tools/group") + tool = packages.create("one", targets=("copilot",)) + packages.add_skill( + tool, "chosen", "---\nname: chosen\ndescription: Selected skill\n---\n# Chosen\n" + ) + unused = packages.create("two", targets=("copilot",)) + packages.add_skill( + unused, "unused", "---\nname: unused\ndescription: Unselected\n---\n# Unused\n" + ) + packages.add_hook( + unused, + "unselected", + {"hooks": {"preToolUse": [{"bash": "echo must-not-run > hook-executed.txt"}]}}, + ) + path = author / "apm.yml" + entries = [{"name": name, "source": f"./tools/group/{name}"} for name in ("one", "two")] + _catalog(path, entries, plugin_root="tools") + stale_catalog = author / ".claude-plugin/marketplace.json" + stale_catalog.parent.mkdir() + stale_catalog.write_text('{"name":"stale-generated-catalog","plugins":[]}', encoding="utf-8") + source_files = ArtifactSnapshot.capture(author) + consumer = LocalPackageFactory(isolated.work_root).create("consumer", targets=("copilot",)) + consumer_before = ArtifactSnapshot.capture(consumer.root) + + results = runner.run_sequence( + ( + ("marketplace", "add", os.path.relpath(path, consumer.root), "--offline"), + ("marketplace", "browse", "alice-dev", "--offline"), + ("view", "one@alice-dev"), + ), + expected_returncodes=(0, 0, 0), + scenario_id="live-authoring-discovery", + cwd=consumer.root, + env=environment, + ) + assert "2 plugins" in results[0].stdout + assert "one" in results[1].stdout and "two" in results[1].stdout + assert "one" in results[2].stdout + assert_unchanged(consumer_before, ArtifactSnapshot.capture(consumer.root)) + assert_unchanged(source_files, ArtifactSnapshot.capture(author)) + _catalog( + path, + [entries[0], {"name": "new-entry", "source": "./tools/group/two"}], + plugin_root="tools", + ) + edited = runner.run( + ("marketplace", "browse", "alice-dev", "--offline"), cwd=consumer.root, env=environment + ) + assert edited.returncode == 0, edited.stdout + edited.stderr + assert "new-entry" in edited.stdout + installed = runner.run(("install", "one@alice-dev"), cwd=consumer.root, env=environment) + assert installed.returncode == 0, installed.stdout + installed.stderr + skills = list(consumer.root.rglob("SKILL.md")) + assert any("chosen" in file.as_posix() for file in skills) + assert all("unused" not in file.as_posix() for file in skills) + lock = yaml.safe_load((consumer.root / "apm.lock.yaml").read_text(encoding="utf-8")) + assert len(lock["dependencies"]) == 1 + assert lock["dependencies"][0]["source"] == "local" + assert Path(lock["dependencies"][0]["local_path"]) == tool.root + manifest = yaml.safe_load(consumer.manifest_path.read_text(encoding="utf-8")) + assert "one" in str(manifest["dependencies"]["apm"]) + + +def test_remote_entry_records_real_source_and_replays_without_preview(tmp_path, apm_binary_path): + isolated = IsolatedApmEnvironment.create(tmp_path / "isolated", base_env=dict(os.environ)) + packages = LocalPackageFactory(isolated.package_root) + tool = packages.create("remote-tool", targets=("copilot",)) + packages.add_skill( + tool, "portable", "---\nname: portable\ndescription: Remote skill\n---\n# Portable\n" + ) + repos = LocalGitRepositoryFactory(isolated.repository_root, env=isolated.subprocess_env()) + repository = repos.create("remote-tool", source_tree=tool.root) + commit = repos.commit(repository, message="seed remote tool") + repos.tag(repository, "v0.1.0", commit) + remote_url = "https://git.example.test/alice/remote-tool" + environment = repos.url_rewrite_subprocess_env(repository, remote_url) + runner = ApmLifecycleRunner((str(apm_binary_path),)) + author = isolated.work_root / "author" + author.mkdir() + path = author / "apm.yml" + _catalog(path, [{"name": "remote-tool", "source": remote_url, "ref": "v0.1.0"}]) + consumer = LocalPackageFactory(isolated.work_root).create("consumer", targets=("copilot",)) + # Cached-only resolution cannot invent a tag pin. Failure must not create + # a registration, and the explicit online operation uses only owned Git. + failed = runner.run( + ("marketplace", "add", str(path), "--offline"), cwd=consumer.root, env=environment + ) + assert failed.returncode == 1 + assert "offline" in (failed.stdout + failed.stderr).lower() + runner.run_sequence( + (("marketplace", "add", str(path)), ("install", "remote-tool@alice-dev")), + expected_returncodes=(0, 0), + scenario_id="authoring-remote-install", + cwd=consumer.root, + env=environment, + ) + manifest_text = consumer.manifest_path.read_text(encoding="utf-8") + lock_text = (consumer.root / "apm.lock.yaml").read_text(encoding="utf-8") + entry = yaml.safe_load(manifest_text)["dependencies"]["apm"][0] + dependency = ( + DependencyReference.parse_from_dict(entry) + if isinstance(entry, dict) + else DependencyReference.parse(entry) + ) + assert dependency.repo_url == "alice/remote-tool" + assert dependency.host == "git.example.test" + assert str(author) not in manifest_text + locked_dependency = yaml.safe_load(lock_text)["dependencies"][0] + assert locked_dependency["repo_url"] == "alice/remote-tool" + assert locked_dependency["resolved_commit"] == commit.sha + assert "local_path" not in locked_dependency + # A second consumer has no registered discovery source and no author file. + replay_env = IsolatedApmEnvironment.create(tmp_path / "replay", base_env=dict(os.environ)) + replay = LocalPackageFactory(replay_env.work_root).create("replay", targets=("copilot",)) + replay.manifest_path.write_text(manifest_text, encoding="utf-8") + (replay.root / "apm.lock.yaml").write_text(lock_text, encoding="utf-8") + path.unlink() + author.rmdir() + # Reuse the owned Git rewrite while pointing home/config at a fresh scope. + replay_environment = replay_env.subprocess_env() + for key, value in environment.items(): + if ( + key.startswith("GIT_CONFIG_KEY_") + or key.startswith("GIT_CONFIG_VALUE_") + or key == "GIT_CONFIG_COUNT" + ): + replay_environment[key] = value + result = runner.run(("install", "--frozen"), cwd=replay.root, env=replay_environment) + assert result.returncode == 0, result.stdout + result.stderr + assert any("portable" in file.as_posix() for file in replay.root.rglob("SKILL.md")) + + +@pytest.mark.parametrize( + "invalid", [None, "marketplace: [", "name: missing\n", "marketplace: []\n"] +) +def test_failed_reregistration_preserves_registered_source(tmp_path, apm_binary_path, invalid): + isolated = IsolatedApmEnvironment.create(tmp_path / "isolated", base_env=dict(os.environ)) + environment = isolated.subprocess_env() + runner = ApmLifecycleRunner((str(apm_binary_path),)) + valid = isolated.work_root / "valid" + valid.mkdir() + path = valid / "apm.yml" + _catalog(path, []) + registered = runner.run( + ("marketplace", "add", str(path)), cwd=isolated.work_root, env=environment + ) + assert registered.returncode == 0, registered.stdout + registered.stderr + registry = isolated.home / ".apm" / "marketplaces.json" + before = registry.read_bytes() + invalid_root = isolated.work_root / "invalid" + invalid_root.mkdir() + bad_path = invalid_root / "apm.yml" + if invalid is not None: + bad_path.write_text(invalid, encoding="utf-8") + result = runner.run( + ("marketplace", "add", str(bad_path), "--name", "alice-dev"), + cwd=isolated.work_root, + env=environment, + ) + assert result.returncode == 1 + assert "apm.yml" in result.stdout + result.stderr + assert registry.read_bytes() == before + # A registered source is live on reads too: a later edit or deletion must + # fail actionably rather than serving the previous successful snapshot. + if invalid is None: + path.unlink() + else: + path.write_text(invalid, encoding="utf-8") + read = runner.run( + ("marketplace", "browse", "alice-dev"), cwd=isolated.work_root, env=environment + ) + assert read.returncode == 1 + assert "apm.yml" in read.stdout + read.stderr + assert registry.read_bytes() == before + + +@pytest.mark.parametrize("source_kind", ["json", "git"]) +def test_existing_catalog_sources_still_register_without_authoring_resolution( + tmp_path, apm_binary_path, source_kind +): + isolated = IsolatedApmEnvironment.create(tmp_path / "isolated", base_env=dict(os.environ)) + environment = isolated.subprocess_env() + catalog_root = isolated.package_root / "existing" + catalog_root.mkdir() + catalog = catalog_root / "marketplace.json" + catalog.write_text( + json.dumps( + { + "name": "existing", + "owner": {"name": "Alice"}, + "plugins": [ + {"name": "legacy-tool", "source": {"source": "github", "repo": "alice/tool"}} + ], + } + ), + encoding="utf-8", + ) + if source_kind == "git": + repositories = LocalGitRepositoryFactory(isolated.repository_root, env=environment) + repository = repositories.create("existing", source_tree=catalog_root) + repositories.commit(repository, message="seed existing catalog") + source = str(repository.origin) + else: + source = str(catalog) + before = ArtifactSnapshot.capture(catalog_root) + runner = ApmLifecycleRunner((str(apm_binary_path),)) + results = runner.run_sequence( + (("marketplace", "add", source), ("marketplace", "browse", "existing")), + expected_returncodes=(0, 0), + scenario_id=f"existing-{source_kind}-catalog", + cwd=isolated.work_root, + env=environment, + ) + assert "legacy-tool" in results[1].stdout + assert_unchanged(before, ArtifactSnapshot.capture(catalog_root)) diff --git a/tests/unit/marketplace/test_authoring_source.py b/tests/unit/marketplace/test_authoring_source.py new file mode 100644 index 0000000000..d7af76f774 --- /dev/null +++ b/tests/unit/marketplace/test_authoring_source.py @@ -0,0 +1,195 @@ +"""Live authoring catalogs share native interpretation and filesystem safety.""" + +from pathlib import Path +from unittest.mock import patch + +import click +import pytest +from click.testing import CliRunner + +from apm_cli.commands.marketplace import marketplace +from apm_cli.marketplace import registry +from apm_cli.marketplace.builder import BuildOptions, MarketplaceBuilder +from apm_cli.marketplace.client import fetch_marketplace +from apm_cli.marketplace.errors import MarketplaceFetchError +from apm_cli.marketplace.models import MarketplaceSource +from apm_cli.marketplace.output_profiles import DEFAULT_MARKETPLACE_OUTPUT +from apm_cli.marketplace.resolver import resolve_local_plugin_path +from apm_cli.marketplace.yml_schema import load_marketplace_from_apm_yml + +pytestmark = pytest.mark.component + + +def write_catalog(path: Path, *, name: str = "first", source: str = "./tools/one") -> None: + path.write_text( + f"name: preview\nversion: 1.0.0\nmarketplace:\n owner:\n name: Alice\n" + f" packages:\n - name: {name}\n source: {source}\n", + encoding="utf-8", + ) + + +def test_invocation_snapshot_is_shared_but_next_invocation_rereads(tmp_path): + path = tmp_path / "apm.yml" + source = MarketplaceSource(name="preview", url=path.as_uri()) + write_catalog(path) + with click.Context(click.Command("test")): + first = fetch_marketplace(source, offline=True) + write_catalog(path, name="second") + second = fetch_marketplace(source, offline=True) + assert first.plugins == second.plugins + with click.Context(click.Command("test")): + assert fetch_marketplace(source, offline=True).plugins[0].name == "second" + + +@pytest.mark.parametrize( + "content", ["name: no-marketplace\n", "marketplace: [", "marketplace: []\n"] +) +def test_invalid_declaration_keeps_existing_registration(tmp_path, monkeypatch, content): + monkeypatch.setattr("apm_cli.config.CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr("apm_cli.config.CONFIG_FILE", str(tmp_path / "config/config.json")) + monkeypatch.setattr(registry, "_registry_cache", None) + original = MarketplaceSource(name="preview", url="https://github.com/alice/original") + registry.add_marketplace(original) + path = tmp_path / "apm.yml" + path.write_text(content, encoding="utf-8") + result = CliRunner().invoke(marketplace, ["add", str(path), "--name", "preview"]) + assert result.exit_code == 1 + assert "marketplace declaration" in result.output + assert registry.get_marketplace_by_name("preview") == original + assert path.read_text(encoding="utf-8") == content + + +@pytest.mark.parametrize("escape", ["./../outside", "./tools/../../../outside"]) +def test_paths_cannot_escape_authoring_root(tmp_path, escape): + path = tmp_path / "apm.yml" + write_catalog(path, source=escape) + with pytest.raises(MarketplaceFetchError): + fetch_marketplace(MarketplaceSource(name="preview", url=path.as_uri()), offline=True) + + +def test_symlink_cannot_escape_authoring_root(tmp_path): + root = tmp_path / "author" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + try: + (root / "escape").symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("Creating symlinks requires privileges on this host") + path = root / "apm.yml" + write_catalog(path, source="./escape") + with pytest.raises(MarketplaceFetchError, match=r"outside|escape"): + fetch_marketplace(MarketplaceSource(name="preview", url=path.as_uri()), offline=True) + + +def test_catalog_read_never_invokes_writing_pipeline(tmp_path): + path = tmp_path / "apm.yml" + write_catalog(path) + before = path.read_bytes() + with ( + patch("apm_cli.marketplace.builder.MarketplaceBuilder.build", side_effect=AssertionError), + patch( + "apm_cli.marketplace.builder.MarketplaceBuilder.write_output", + side_effect=AssertionError, + ), + ): + result = fetch_marketplace( + MarketplaceSource(name="preview", url=path.as_uri()), offline=True + ) + assert result.name == "preview" + assert result.plugins[0].source == "./tools/one" + assert path.read_bytes() == before + assert not (tmp_path / ".claude-plugin").exists() + + +def test_offline_remote_range_reports_missing_refs(tmp_path): + path = tmp_path / "apm.yml" + write_catalog(path, source="alice/no-such-repo") + path.write_text(path.read_text() + " version: ^1.0.0\n", encoding="utf-8") + with pytest.raises(MarketplaceFetchError, match=r"offline|Offline"): + fetch_marketplace(MarketplaceSource(name="preview", url=path.as_uri()), offline=True) + + +def test_local_metadata_and_plugin_root_match_native_composition(tmp_path): + path = tmp_path / "apm.yml" + write_catalog(path) + path.write_text(path.read_text() + " metadata:\n pluginRoot: tools\n", encoding="utf-8") + package = tmp_path / "tools/one" + package.mkdir(parents=True) + (package / "apm.yml").write_text( + "name: one\nversion: 1.2.3\ndescription: Local tool\n", encoding="utf-8" + ) + source = MarketplaceSource(name="preview", url=path.as_uri()) + manifest = fetch_marketplace(source, offline=True) + plugin = manifest.plugins[0] + assert manifest.owner_name == "Alice" + assert plugin.version == "1.2.3" + assert plugin.description == "Local tool" + assert resolve_local_plugin_path(plugin.source, source, manifest.plugin_root) == package + + +def test_pinned_remote_commit_needs_no_ref_or_metadata_network(tmp_path): + path = tmp_path / "apm.yml" + write_catalog(path, source="alice/tool") + sha = "a" * 40 + path.write_text(path.read_text() + f" ref: {sha}\n", encoding="utf-8") + with ( + patch( + "apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs", + side_effect=AssertionError, + ), + patch( + "apm_cli.marketplace.builder.MarketplaceBuilder._fetch_remote_metadata", + side_effect=AssertionError, + ), + ): + manifest = fetch_marketplace( + MarketplaceSource(name="preview", url=path.as_uri()), offline=True + ) + assert manifest.plugins[0].source["sha"] == sha + + +def test_offline_repository_registration_fails_before_any_probe(): + with patch("apm_cli.marketplace.client._auto_detect_path", side_effect=AssertionError): + result = CliRunner().invoke(marketplace, ["add", "alice/catalog", "--offline"]) + assert result.exit_code == 1 + assert "requires an explicitly selected local apm.yml" in result.output + + +@pytest.mark.parametrize("relative_source", ["./tools/nested/one", "./one"]) +def test_plugin_root_never_rebases_authored_package_paths(tmp_path, relative_source): + path = tmp_path / "apm.yml" + write_catalog(path, source=relative_source) + path.write_text(path.read_text() + " metadata:\n pluginRoot: tools\n", encoding="utf-8") + source = MarketplaceSource(name="preview", url=path.as_uri()) + manifest = fetch_marketplace(source, offline=True) + actual = resolve_local_plugin_path(manifest.plugins[0].source, source, manifest.plugin_root) + assert actual == (tmp_path / relative_source).resolve() + + +def test_local_catalog_does_not_resolve_remote_credentials(tmp_path): + path = tmp_path / "apm.yml" + write_catalog(path) + with patch( + "apm_cli.marketplace.builder.MarketplaceBuilder._ensure_auth", side_effect=AssertionError + ): + manifest = fetch_marketplace(MarketplaceSource(name="preview", url=path.as_uri())) + assert manifest.plugins[0].name == "first" + + +def test_discovery_composition_does_not_change_native_output(tmp_path): + path = tmp_path / "apm.yml" + write_catalog(path, source="./tools/nested/one") + path.write_text(path.read_text() + " metadata:\n pluginRoot: tools\n", encoding="utf-8") + config = load_marketplace_from_apm_yml(path) + builder = MarketplaceBuilder.from_config(config, tmp_path, options=BuildOptions(offline=True)) + resolved = builder.resolve().entries + before, _, _ = builder.compose_output(DEFAULT_MARKETPLACE_OUTPUT, resolved) + preview = builder.compose_discovery_catalog(resolved) + after, _, _ = builder.compose_output(DEFAULT_MARKETPLACE_OUTPUT, resolved) + assert before == after + assert after["metadata"]["pluginRoot"] == "tools" + assert after["plugins"][0]["source"] == "./nested/one" + assert preview["plugins"][0]["source"] == "./tools/nested/one" + assert "pluginRoot" not in preview.get("metadata", {}) + assert config.metadata == {"pluginRoot": "tools"}