Skip to content
Open
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
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