Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `marketplace.packages[].source` in `apm.yml` now accepts non-default git hosts via the `host.tld/owner/repo` shorthand or the full `https://host.tld/owner/repo[.git]` URL; per-host auth flows through the standard APM token chain. Unlocks GitHub Enterprise and self-hosted GitLab as first-class marketplace package sources. (#1288)

### Fixed

- Copilot, Codex, Cursor, Claude, Windsurf, OpenCode, and Gemini adapters handle MCP v0.1 `runtimeArguments`/`packageArguments` with `variables` (no `type` key), matching the VS Code fix from #1444. (#1461, closes #1452, thanks @sergio-sisternes-epam)
Expand Down
14 changes: 13 additions & 1 deletion docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ Each entry MUST be a mapping. Unknown keys are rejected.
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | REQUIRED | Package identifier as it appears in the marketplace. |
| `source` | `string` | REQUIRED | `<owner>/<repo>` (remote) or `./<path>` (local). Must match the source pattern; path traversal (`..`) is refused. |
| `source` | `string` | REQUIRED | One of: `<owner>/<repo>` (remote on the default host), `<host.tld>/<owner>/<repo>` (remote on a non-default host such as GitHub Enterprise or self-hosted GitLab -- shorthand), `https://<host.tld>/<owner>/<repo>[.git]` (same, full URL form -- a trailing `.git` is stripped), or `./<path>` (local). Must match the source pattern; path traversal (`..`) is refused, and URL forms with userinfo (`user@host`), ports, query strings, or non-`https` schemes are rejected. |
| `subdir` | `string` | OPTIONAL | Subdirectory inside the source repo. Path-traversal-validated. Ignored for local sources. |
| `version` | `string` | Conditional | Semver range (e.g. `^1.0.0`, `~2.1.0`, `>=3.0`). Stored as a string; resolution happens at pack time. REQUIRED for remote packages unless `ref` is given. |
| `ref` | `string` | Conditional | Explicit git ref (SHA, tag, or branch). Overrides `version` range when both are present. REQUIRED for remote packages unless `version` is given. |
Expand All @@ -621,6 +621,10 @@ Each entry MUST be a mapping. Unknown keys are rejected.

Remote packages MUST declare at least one of `version` or `ref`. Local packages (sources beginning with `./`) skip git resolution and have no version requirement.

The first three `source` forms target a remote git host; the second and third name a non-default host (e.g. GitHub Enterprise, self-hosted GitLab) as either a shorthand or a full HTTPS URL with an optional `.git` suffix that is normalized away. Path traversal (`..`) in local paths, userinfo (`user@host`), ports, query strings, and non-`https` URL schemes are rejected at parse time.

Non-default hosts authenticate via the standard APM token chain -- see the [authentication guide](../../getting-started/authentication/) for the per-host-class lookup order. A token resolved for the default host is never forwarded to a non-default host.

### 7.6. Complete Marketplace Block

```yaml
Expand Down Expand Up @@ -653,6 +657,14 @@ marketplace:
- name: local-tool # local-path package
source: ./packages/local-tool
description: Vendored tool

- name: enterprise-agents # GHE shorthand
source: ghe.corp.example.com/platform/agents
version: "^0.3.0"

- name: gitlab-helper # full URL form
source: https://gitlab.corp.example.com/team/helper.git
ref: v1.2.0
```

The legacy standalone `marketplace.yml` (top-level keys, no `marketplace:` wrapper) is still loadable but deprecated; new repositories SHOULD use the in-`apm.yml` form scaffolded by `apm marketplace init`.
Expand Down
13 changes: 13 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/package-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,13 @@ marketplace:
description: Plugin shipped alongside this repo
source: ./plugins/local-tool # local path (no remote fetch)
version: 0.1.0

- name: enterprise-plugin
description: Hosted on GitHub Enterprise
source: ghe.corp.example.com/platform/agents # host.tld/owner/repo
version: "^0.3.0"
# Equivalent full URL form (trailing .git is stripped):
# source: https://ghe.corp.example.com/platform/agents.git
```

Schema rules:
Expand All @@ -342,6 +349,12 @@ Schema rules:
- `ref` takes precedence over `version`.
- `source: ./...` marks a local-path entry: skips git resolution,
emits the path verbatim into `marketplace.json`.
- `source` accepts three remote forms: `owner/repo` (default host),
`host.tld/owner/repo` (non-default host shorthand), or
`https://host.tld/owner/repo[.git]` (full URL). Non-default hosts
resolve auth via the standard APM token chain
(`docs/getting-started/authentication.md`); the default-host token is
never forwarded.
- `versioning.strategy` is optional. When present, it is consumed by
the `apm pack --check-versions` release gate to enforce alignment
between each local package's `version:` field and the marketplace
Expand Down
154 changes: 133 additions & 21 deletions src/apm_cli/marketplace/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import json
import logging
import re
import threading
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
Expand Down Expand Up @@ -92,6 +93,7 @@ class ResolvedPackage:
requested_version: str | None # original APM-only range (for diagnostics)
tags: tuple[str, ...]
is_prerelease: bool # True if the resolved ref was a prerelease semver
host: str | None = None # non-default git host parsed from apm.yml source


@dataclass(frozen=True)
Expand Down Expand Up @@ -310,6 +312,11 @@ def __init__(
self._host: str = default_host() or "github.com"
self._host_info: HostInfo | None = None
self._auth_resolved: bool = False
# Per-host RefResolver cache, keyed by host override on PackageEntry.
# Pre-warmed on the main thread before workers spawn; lock guards
# against future refactors that allow worker-side cache misses.
self._host_resolvers: dict[str, RefResolver] = {}
self._host_resolvers_lock = threading.Lock()

@classmethod
def from_config(
Expand Down Expand Up @@ -363,6 +370,75 @@ def _get_resolver(self) -> RefResolver:
)
return self._resolver

def _effective_host(self, host: str | None) -> str | None:
"""Normalize ``host`` for marketplace.json emission.

Returns ``None`` when ``host`` matches the active default host so
an explicit ``github.com/owner/repo`` source in apm.yml emits the
same shorthand (``source: github``, ``repo: owner/repo``) shape as
the bare ``owner/repo`` form. Non-default hosts pass through
unchanged and downstream mappers emit ``source: url`` /
``source: git-subdir`` with the full HTTPS URL.
"""
if host is None or host == self._host:
return None
return host

def _get_resolver_for_host(self, host: str | None) -> RefResolver:
"""Return a RefResolver bound to *host* (default when ``None``).

Non-default hosts go through ``AuthResolver.resolve(host)`` so that
``GITHUB_APM_PAT``, ``GITHUB_APM_PAT_{ORG}``, ``GITHUB_TOKEN`` and
``GH_TOKEN`` are consulted before falling back to ambient git
credentials (SSH key / credential helper). Per-host resolvers are
cached for the lifetime of the build so each unique host pays the
auth-resolution cost only once.
"""
if host is None or host == self._host:
return self._get_resolver()
with self._host_resolvers_lock:
cached = self._host_resolvers.get(host)
if cached is not None:
return cached
token = self._resolve_token_for_host(host)
logger.debug(
"Creating per-host RefResolver for %s (token=%s)",
host,
"set" if token else "unset",
)
resolver = RefResolver(
timeout_seconds=self._options.timeout_seconds,
offline=self._options.offline,
host=host,
token=token,
)
self._host_resolvers[host] = resolver
return resolver

def _resolve_token_for_host(self, host: str) -> str | None:
"""Resolve an auth token for a non-default *host* via ``AuthResolver``.

Returns ``None`` -- letting ``git`` fall back to ambient credentials
-- when offline, when no token is configured for the host, or when
``AuthResolver`` raises. Never raises.
"""
if self._options.offline:
return None
try:
from ..core.auth import AuthResolver # lazy import

resolver = self._auth_resolver
if resolver is None:
resolver = AuthResolver()
self._auth_resolver = resolver
ctx = resolver.resolve(host) # type: ignore[union-attr]
if ctx.token:
logger.debug("Resolved token for host %s (source=%s)", host, ctx.source)
return ctx.token
except Exception:
logger.debug("Could not resolve token for host %s", host, exc_info=True)
return None

def _ensure_auth(self) -> None:
"""Lazily resolve host classification and GitHub token.

Expand Down Expand Up @@ -441,7 +517,7 @@ def _resolve_entry(self, entry: PackageEntry) -> ResolvedPackage:
is_prerelease=False,
)
yml = self._load_yml()
resolver = self._get_resolver()
resolver = self._get_resolver_for_host(entry.host)
owner_repo = entry.source

if entry.ref is not None:
Expand Down Expand Up @@ -471,6 +547,7 @@ def _resolve_explicit_ref(
requested_version=entry.version,
tags=entry.tags,
is_prerelease=sv.is_prerelease if sv else False,
host=self._effective_host(entry.host),
)

refs = resolver.list_remote_refs(owner_repo)
Expand All @@ -491,6 +568,7 @@ def _resolve_explicit_ref(
requested_version=entry.version,
tags=entry.tags,
is_prerelease=sv.is_prerelease if sv else False,
host=self._effective_host(entry.host),
)

# Try as full refname
Expand All @@ -510,6 +588,7 @@ def _resolve_explicit_ref(
requested_version=entry.version,
tags=entry.tags,
is_prerelease=sv.is_prerelease if sv else False,
host=self._effective_host(entry.host),
)

# Try as branch name
Expand All @@ -526,6 +605,7 @@ def _resolve_explicit_ref(
requested_version=entry.version,
tags=entry.tags,
is_prerelease=False,
host=self._effective_host(entry.host),
)

# HEAD special case
Expand Down Expand Up @@ -584,6 +664,7 @@ def _resolve_version_range(
requested_version=version_range,
tags=entry.tags,
is_prerelease=best_sv.is_prerelease,
host=self._effective_host(entry.host),
)

# -- concurrent resolution ----------------------------------------------
Expand Down Expand Up @@ -613,6 +694,12 @@ def resolve(self) -> ResolveResult:
# spawning workers -- avoids a race on _ensure_auth() and
# matches the pattern used in _prefetch_metadata().
self._get_resolver()
# Pre-warm any per-host resolvers needed by entries that override the
# default host via the ``host.tld/owner/repo`` source form. Done on
# the main thread so workers never race to create the same resolver.
for entry in entries:
if entry.host:
self._get_resolver_for_host(entry.host)

with ThreadPoolExecutor(max_workers=min(self._options.concurrency, len(entries))) as pool:
future_to_index = {
Expand Down Expand Up @@ -656,54 +743,71 @@ def _fetch_remote_metadata(self, pkg: ResolvedPackage) -> dict[str, str] | None:
``None`` on any error. This is purely cosmetic enrichment --
failures are silently logged at debug level and never propagate.

When a GitHub token is available (via ``self._github_token``), it
is included as an ``Authorization`` header so private repos can be
accessed.
When a token is available for the package's host, it is included
as an ``Authorization`` header so private repos can be accessed.
A token resolved for the builder's default host is never sent to
another host.

For non-github.com GitHub-family hosts (GHES, GHE Cloud), uses the
GitHub REST API instead of raw.githubusercontent.com (which is only
available for github.com). For non-GitHub hosts, metadata
enrichment is skipped.
Each package is fetched from its own host: ``github.com``
packages use the fast ``raw.githubusercontent.com`` CDN; GHES
and GHE Cloud packages use the GitHub REST API on the package's
host. For non-GitHub-class hosts, metadata enrichment is
skipped.
"""
try:
path_prefix = f"{pkg.subdir}/" if pkg.subdir else ""
file_path = f"{path_prefix}apm.yml"

# Determine URL strategy based on host kind
host_kind = self._host_info.kind if self._host_info else "github"
# Resolve the effective host for this package and its
# classification. Falls back to the builder default when the
# package did not carry an explicit host override.
effective_host = pkg.host or self._host
if pkg.host is None or pkg.host == self._host:
host_info = self._host_info
token = self._github_token
else:
from ..core.auth import AuthResolver # lazy import

try:
host_info = AuthResolver.classify_host(effective_host)
except Exception:
host_info = None
token = self._resolve_token_for_host(effective_host)

host_kind = host_info.kind if host_info else "github"

if host_kind not in ("github", "ghe_cloud", "ghes"):
# Non-GitHub hosts -- skip metadata enrichment
logger.debug(
"Skipping metadata fetch for %s (non-GitHub host: %s)",
pkg.name,
self._host,
effective_host,
)
return None

if host_kind == "ghe_cloud" and not self._github_token:
if host_kind == "ghe_cloud" and not token:
logger.debug(
"Skipping metadata fetch for %s (GHE Cloud requires auth)",
pkg.name,
)
return None

if self._host == "github.com":
if effective_host == "github.com":
# github.com -- use fast raw.githubusercontent.com CDN
url = f"https://raw.githubusercontent.com/{pkg.source_repo}/{pkg.sha}/{file_path}"
req = urllib.request.Request(url) # noqa: S310
if self._github_token:
req.add_header("Authorization", f"token {self._github_token}")
if token:
req.add_header("Authorization", f"token {token}")
else:
# GHES / GHE Cloud -- use REST API
# GHES / GHE Cloud -- use REST API on the package's host
api_base = (
self._host_info.api_base if self._host_info else None
) or f"https://{self._host}/api/v3"
host_info.api_base if host_info else None
) or f"https://{effective_host}/api/v3"
url = f"{api_base}/repos/{pkg.source_repo}/contents/{file_path}?ref={pkg.sha}"
req = urllib.request.Request(url) # noqa: S310
req.add_header("Accept", "application/vnd.github.raw")
if self._github_token:
req.add_header("Authorization", f"token {self._github_token}")
if token:
req.add_header("Authorization", f"token {token}")

with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
raw = resp.read().decode("utf-8")
Expand Down Expand Up @@ -999,9 +1103,17 @@ def build(self) -> BuildReport:
),
)

# Cleanup resolver
# Cleanup default + per-host resolvers so long-lived builder
# instances do not leak caches or thread locks across builds.
if self._resolver is not None:
self._resolver.close()
with self._host_resolvers_lock:
for host_resolver in self._host_resolvers.values():
try:
host_resolver.close()
except Exception: # pragma: no cover - close is best-effort
logger.debug("Failed to close per-host RefResolver", exc_info=True)
self._host_resolvers.clear()

return BuildReport(
outputs=report.outputs,
Expand Down
23 changes: 20 additions & 3 deletions src/apm_cli/marketplace/output_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,22 @@ def compose(
)
plugin["source"] = source_value
else:
# Remote source: emit per the official Claude Code marketplace
# schema. When the package was authored with a host-prefixed
# source (``host.tld/owner/repo``), emit a real ``https://``
# URL so Claude Code can clone from a non-default host (e.g.
# GHE) -- the ``github`` shorthand only resolves to github.com.
source_obj: dict[str, Any] = OrderedDict()
if pkg.subdir:
source_obj["source"] = "git-subdir"
source_obj["url"] = pkg.source_repo
if pkg.host:
source_obj["url"] = f"https://{pkg.host}/{pkg.source_repo}"
else:
source_obj["url"] = pkg.source_repo
source_obj["path"] = pkg.subdir
elif pkg.host:
source_obj["source"] = "url"
source_obj["url"] = f"https://{pkg.host}/{pkg.source_repo}"
else:
source_obj["source"] = "github"
source_obj["repo"] = pkg.source_repo
Expand Down Expand Up @@ -267,7 +278,10 @@ def _codex_source(entry: PackageEntry, pkg: ResolvedPackage) -> dict[str, Any]:
if pkg.subdir:
source_obj: dict[str, Any] = OrderedDict()
source_obj["source"] = "git-subdir"
source_obj["url"] = pkg.source_repo
if pkg.host:
source_obj["url"] = f"https://{pkg.host}/{pkg.source_repo}"
else:
source_obj["url"] = pkg.source_repo
source_obj["path"] = pkg.subdir
if pkg.ref:
source_obj["ref"] = pkg.ref
Expand All @@ -277,7 +291,10 @@ def _codex_source(entry: PackageEntry, pkg: ResolvedPackage) -> dict[str, Any]:

source_obj = OrderedDict()
source_obj["source"] = "url"
source_obj["url"] = pkg.source_repo
if pkg.host:
source_obj["url"] = f"https://{pkg.host}/{pkg.source_repo}"
else:
source_obj["url"] = pkg.source_repo
if pkg.ref:
source_obj["ref"] = pkg.ref
if pkg.sha:
Expand Down
Loading
Loading