Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -942,14 +942,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 @@ -959,6 +962,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 @@ -52,7 +52,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