Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [Unreleased]

### Changed

- `apm marketplace browse/search/add/update` now route through the registry proxy when `PROXY_REGISTRY_URL` is set; `PROXY_REGISTRY_ONLY=1` blocks direct GitHub API calls (#506)

## [0.8.11] - 2026-04-06

### Added
Expand Down
19 changes: 19 additions & 0 deletions docs/src/content/docs/guides/marketplaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,15 @@ This registers the marketplace and fetches its `marketplace.json`. By default AP
**Options:**
- `--name/-n` -- Custom display name for the marketplace
- `--branch/-b` -- Branch to track (default: `main`)
- `--host` -- Git host FQDN for non-github.com hosts (default: `github.com` or `GITHUB_HOST` env var)

```bash
# Register with a custom name on a specific branch
apm marketplace add acme/plugin-marketplace --name acme-plugins --branch release

# Register from a GitHub Enterprise host (two equivalent forms)
apm marketplace add acme/plugin-marketplace --host ghes.corp.example.com
apm marketplace add ghes.corp.example.com/acme/plugin-marketplace
```

## List registered marketplaces
Expand Down Expand Up @@ -156,6 +161,20 @@ apm marketplace update acme-plugins
apm marketplace update
```

## Registry proxy support

When `PROXY_REGISTRY_URL` is set, marketplace commands (`add`, `browse`, `search`, `update`) fetch `marketplace.json` through the registry proxy (Artifactory Archive Entry Download) before falling back to the GitHub Contents API. When `PROXY_REGISTRY_ONLY=1` is also set, the GitHub API fallback is blocked entirely, enabling fully air-gapped marketplace discovery.

```bash
export PROXY_REGISTRY_URL="https://art.corp.example.com/artifactory/github"
export PROXY_REGISTRY_ONLY=1 # optional: block direct GitHub access

apm marketplace add anthropics/skills # fetches via Artifactory
apm marketplace browse skills # fetches via Artifactory
```

This builds on the same proxy infrastructure used by `apm install`. See the [Registry Proxy guide](../registry-proxy/) for full configuration details.

## Manage marketplaces

Remove a registered marketplace:
Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -872,14 +872,17 @@ Register a GitHub repository as a plugin marketplace.

```bash
apm marketplace add OWNER/REPO [OPTIONS]
apm marketplace add HOST/OWNER/REPO [OPTIONS]
```

**Arguments:**
- `OWNER/REPO` - GitHub repository containing `marketplace.json`
- `HOST/OWNER/REPO` - Repository on a non-github.com host (e.g., GitHub Enterprise)

**Options:**
- `-n, --name TEXT` - Custom display name for the marketplace
- `-b, --branch TEXT` - Branch to track (default: main)
- `--host TEXT` - Git host FQDN (default: github.com or `GITHUB_HOST` env var)
- `-v, --verbose` - Show detailed output

**Examples:**
Expand All @@ -889,6 +892,10 @@ apm marketplace add acme/plugin-marketplace

# Register with a custom name and branch
apm marketplace add acme/plugin-marketplace --name acme-plugins --branch release

# Register from a GitHub Enterprise host
apm marketplace add acme/plugin-marketplace --host ghes.corp.example.com
apm marketplace add ghes.corp.example.com/acme/plugin-marketplace
```

#### `apm marketplace list` - List registered marketplaces
Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm marketplace add OWNER/REPO` | Register a marketplace | `-n NAME`, `-b BRANCH` |
| `apm marketplace add OWNER/REPO` | Register a marketplace | `-n NAME`, `-b BRANCH`, `--host HOST` |
| `apm marketplace list` | List registered marketplaces | -- |
| `apm marketplace browse NAME` | Browse marketplace packages | -- |
| `apm marketplace update [NAME]` | Update marketplace index | -- |
Expand Down
53 changes: 41 additions & 12 deletions src/apm_cli/commands/marketplace.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,30 +31,58 @@ def marketplace():
@click.argument("repo", required=True)
@click.option("--name", "-n", default=None, help="Display name (defaults to repo name)")
@click.option("--branch", "-b", default="main", show_default=True, help="Branch to use")
@click.option("--host", default=None, help="Git host FQDN (default: github.com)")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
def add(repo, name, branch, verbose):
"""Register a marketplace from OWNER/REPO."""
def add(repo, name, branch, host, verbose):
"""Register a marketplace from OWNER/REPO or HOST/OWNER/REPO."""
logger = CommandLogger("marketplace-add", verbose=verbose)
try:
from ..marketplace.client import _auto_detect_path, fetch_marketplace
from ..marketplace.models import MarketplaceSource
from ..marketplace.registry import add_marketplace
from ..utils.github_host import default_host

# Parse OWNER/REPO
# Parse OWNER/REPO or HOST/OWNER/REPO
if "/" not in repo:
logger.error(
f"Invalid format: '{repo}'. Use 'OWNER/REPO' "
f"(e.g., 'acme-org/plugin-marketplace')"
)
sys.exit(1)

from ..utils.github_host import default_host, is_valid_fqdn

parts = repo.split("/")
if len(parts) != 2 or not parts[0] or not parts[1]:
if len(parts) == 3 and parts[0] and parts[1] and parts[2]:
if not is_valid_fqdn(parts[0]):
logger.error(
f"Invalid host: '{parts[0]}'. "
f"Use 'OWNER/REPO' or 'HOST/OWNER/REPO' format."
)
sys.exit(1)
if host and host != parts[0]:
logger.error(
f"Conflicting host: --host '{host}' vs '{parts[0]}' in argument."
)
sys.exit(1)
host = parts[0]
owner, repo_name = parts[1], parts[2]
elif len(parts) == 2 and parts[0] and parts[1]:
owner, repo_name = parts[0], parts[1]
else:
logger.error(f"Invalid format: '{repo}'. Expected 'OWNER/REPO'")
sys.exit(1)

owner, repo_name = parts[0], parts[1]
if host is not None:
normalized_host = host.strip().lower()
if not is_valid_fqdn(normalized_host):
logger.error(
f"Invalid host: '{host}'. Expected a valid host FQDN "
f"(for example, 'github.com')."
)
sys.exit(1)
resolved_host = normalized_host
else:
resolved_host = default_host()
display_name = name or repo_name

# Validate name is identifier-compatible for NAME@MARKETPLACE syntax
Expand All @@ -71,15 +99,16 @@ def add(repo, name, branch, verbose):
logger.start(f"Registering marketplace '{display_name}'...", symbol="gear")
logger.verbose_detail(f" Repository: {owner}/{repo_name}")
logger.verbose_detail(f" Branch: {branch}")
if resolved_host != "github.com":
logger.verbose_detail(f" Host: {resolved_host}")

# Auto-detect marketplace.json location
host = default_host()
probe_source = MarketplaceSource(
name=display_name,
owner=owner,
repo=repo_name,
branch=branch,
host=host,
host=resolved_host,
)
detected_path = _auto_detect_path(probe_source)

Expand All @@ -99,7 +128,7 @@ def add(repo, name, branch, verbose):
owner=owner,
repo=repo_name,
branch=branch,
host=host,
host=resolved_host,
path=detected_path,
)

Expand Down Expand Up @@ -270,7 +299,7 @@ def update(name, verbose):
if name:
source = get_marketplace_by_name(name)
logger.start(f"Refreshing marketplace '{name}'...", symbol="gear")
clear_marketplace_cache(name)
clear_marketplace_cache(name, host=source.host)
manifest = fetch_marketplace(source, force_refresh=True)
logger.success(
f"Marketplace '{name}' updated ({len(manifest.plugins)} plugins)",
Expand All @@ -288,7 +317,7 @@ def update(name, verbose):
)
for s in sources:
try:
clear_marketplace_cache(s.name)
clear_marketplace_cache(s.name, host=s.host)
manifest = fetch_marketplace(s, force_refresh=True)
logger.tree_item(
f" {s.name} ({len(manifest.plugins)} plugins)"
Expand Down Expand Up @@ -331,7 +360,7 @@ def remove(name, yes, verbose):
return

remove_marketplace(name)
clear_marketplace_cache(name)
clear_marketplace_cache(name, host=source.host)
logger.success(f"Marketplace '{name}' removed", symbol="check")

except Exception as e:
Expand Down
94 changes: 87 additions & 7 deletions src/apm_cli/marketplace/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Uses ``AuthResolver.try_with_fallback(unauth_first=True)`` for public-first
access with automatic credential fallback for private marketplace repos.
When ``PROXY_REGISTRY_URL`` is set, fetches are routed through the registry
proxy (Artifactory Archive Entry Download) before falling back to the
GitHub Contents API. When ``PROXY_REGISTRY_ONLY=1``, the GitHub fallback
is blocked entirely.
Cache lives at ``~/.apm/cache/marketplace/`` with a 1-hour TTL.
"""

Expand Down Expand Up @@ -56,6 +60,14 @@ def _sanitize_cache_name(name: str) -> str:
return safe


def _cache_key(source: MarketplaceSource) -> str:
"""Cache key that includes host to avoid collisions across hosts."""
normalized_host = source.host.lower()
if normalized_host == "github.com":
return source.name
return f"{_sanitize_cache_name(normalized_host)}__{source.name}"


def _cache_data_path(name: str) -> str:
return os.path.join(_cache_dir(), f"{_sanitize_cache_name(name)}.json")

Expand Down Expand Up @@ -126,6 +138,46 @@ def _clear_cache(name: str) -> None:
# ---------------------------------------------------------------------------


def _try_proxy_fetch(
source: MarketplaceSource,
file_path: str,
) -> Optional[Dict]:
"""Try to fetch marketplace JSON via the registry proxy.

Returns parsed JSON dict on success, ``None`` when no proxy is
configured or the entry download fails.
"""
from ..deps.registry_proxy import RegistryConfig

cfg = RegistryConfig.from_env()
if cfg is None:
return None

from ..deps.artifactory_entry import fetch_entry_from_archive

content = fetch_entry_from_archive(
host=cfg.host,
prefix=cfg.prefix,
owner=source.owner,
repo=source.repo,
file_path=file_path,
ref=source.branch,
scheme=cfg.scheme,
headers=cfg.get_headers(),
)
if content is None:
return None

try:
return json.loads(content)
except (json.JSONDecodeError, ValueError):
logger.debug(
"Proxy returned non-JSON for %s/%s %s",
source.owner, source.repo, file_path,
)
return None


def _github_contents_url(source: MarketplaceSource, file_path: str) -> str:
"""Build the GitHub Contents API URL for a file."""
from ..core.auth import AuthResolver
Expand All @@ -140,11 +192,32 @@ def _fetch_file(
file_path: str,
auth_resolver: Optional[object] = None,
) -> Optional[Dict]:
"""Fetch a JSON file from a GitHub repo via the Contents API.
"""Fetch a JSON file from a GitHub repo.

When ``PROXY_REGISTRY_URL`` is set, tries the registry proxy first via
Artifactory Archive Entry Download. Falls back to the GitHub Contents
API unless ``PROXY_REGISTRY_ONLY=1`` blocks direct access.

Returns parsed JSON or ``None`` if the file does not exist (404).
Raises ``MarketplaceFetchError`` on unexpected failures.
"""
# Proxy-first: try Artifactory Archive Entry Download
proxy_result = _try_proxy_fetch(source, file_path)
if proxy_result is not None:
return proxy_result

# When registry-only mode is active, block direct GitHub API access
from ..deps.registry_proxy import RegistryConfig

cfg = RegistryConfig.from_env()
if cfg is not None and cfg.enforce_only:
logger.debug(
"PROXY_REGISTRY_ONLY blocks direct GitHub fetch for %s/%s %s",
source.owner, source.repo, file_path,
)
return None

# Fallback: GitHub Contents API
url = _github_contents_url(source, file_path)

def _do_fetch(token, _git_env):
Expand Down Expand Up @@ -219,9 +292,11 @@ def fetch_marketplace(
Raises:
MarketplaceFetchError: If fetch fails and no cache is available.
"""
cache_name = _cache_key(source)

# Try fresh cache first
if not force_refresh:
cached = _read_cache(source.name)
cached = _read_cache(cache_name)
if cached is not None:
logger.debug("Using cached marketplace data for '%s'", source.name)
return parse_marketplace_json(cached, source.name)
Expand All @@ -235,11 +310,11 @@ def fetch_marketplace(
f"marketplace.json not found at '{source.path}' "
f"in {source.owner}/{source.repo}",
)
_write_cache(source.name, data)
_write_cache(cache_name, data)
return parse_marketplace_json(data, source.name)
except MarketplaceFetchError:
# Stale-while-revalidate: serve expired cache on network error
stale = _read_stale_cache(source.name)
stale = _read_stale_cache(cache_name)
if stale is not None:
logger.warning(
"Network error fetching '%s'; using stale cache", source.name
Expand Down Expand Up @@ -287,16 +362,21 @@ def search_all_marketplaces(
return results


def clear_marketplace_cache(name: Optional[str] = None) -> int:
def clear_marketplace_cache(
name: Optional[str] = None,
host: str = "github.com",
) -> int:
"""Clear cached data for one or all marketplaces.

Returns the number of caches cleared.
"""
if name:
_clear_cache(name)
# Build a minimal source to derive the cache key
_src = MarketplaceSource(name=name, owner="", repo="", host=host)
_clear_cache(_cache_key(_src))
return 1
count = 0
for source in get_registered_marketplaces():
_clear_cache(source.name)
_clear_cache(_cache_key(source))
count += 1
return count
Loading
Loading