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
57 changes: 55 additions & 2 deletions src/apm_cli/marketplace/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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 @@ -227,6 +228,9 @@ 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 for entries that override the host via
# the ``host.tld/owner/repo`` source form in apm.yml.
self._host_resolvers: dict[str, RefResolver] = {}
Comment on lines +231 to +233

@classmethod
def from_config(
Expand Down Expand Up @@ -280,6 +284,31 @@ def _get_resolver(self) -> RefResolver:
)
return self._resolver

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

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()
cached = self._host_resolvers.get(host)
if cached is not None:
return cached
self._ensure_auth()
# Reuse the resolved token only when the override host matches the
# default host class; otherwise leave token unset and rely on
# ambient git credentials (SSH key / git credential helper).
token = self._github_token if host == self._host else None
resolver = RefResolver(
timeout_seconds=self._options.timeout_seconds,
offline=self._options.offline,
host=host,
token=token,
Comment on lines +299 to +307
)
self._host_resolvers[host] = resolver
return resolver

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

Expand Down Expand Up @@ -325,7 +354,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 @@ -355,6 +384,7 @@ def _resolve_explicit_ref(
requested_version=entry.version,
tags=entry.tags,
is_prerelease=sv.is_prerelease if sv else False,
host=entry.host,
)

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

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

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

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

# -- concurrent resolution ----------------------------------------------
Expand Down Expand Up @@ -508,6 +542,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 @@ -858,11 +898,24 @@ def compose_marketplace_json(self, resolved: list[ResolvedPackage]) -> dict[str,
# Subdirs use the ``git-subdir`` form; everything else uses
# ``github`` shorthand. Field names: ``source``/``repo``/``sha``
# (NOT ``type``/``repository``/``commit``).
#
# 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).
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
Comment on lines +908 to 912
elif pkg.host:
# Non-default host without subdir: ``github`` shorthand
# only resolves to github.com, so emit a ``url`` source
# with a full clone URL instead.
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
8 changes: 4 additions & 4 deletions src/apm_cli/marketplace/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
from .resolver import parse_marketplace_ref
from .semver import parse_semver
from .tag_pattern import render_tag
from .yml_schema import load_marketplace_yml
from .migration import load_marketplace_config
from .yml_schema import load_marketplace_yml # noqa: F401 — kept for back-compat

Comment on lines +56 to 58
logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -314,10 +315,9 @@ def __init__(
self._yml = None

def _load_yml(self):
"""Lazy-load marketplace.yml."""
"""Lazy-load marketplace config (apm.yml or legacy marketplace.yml)."""
if self._yml is None:
yml_path = self._root / "marketplace.yml"
self._yml = load_marketplace_yml(yml_path)
self._yml = load_marketplace_config(self._root)
return self._yml
Comment on lines 317 to 321

# -- plan ---------------------------------------------------------------
Expand Down
34 changes: 30 additions & 4 deletions src/apm_cli/marketplace/yml_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,27 @@
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)

# Source field accepts either ``owner/repo`` (remote) or ``./...`` (local
# path within the same repo). Used by both yml_schema and yml_editor for
# source field validation.
SOURCE_RE = re.compile(r"^(?:[^/]+/[^/]+|\./.*)$")
# Source field accepts:
# - ``owner/repo`` (remote, default host)
# - ``host.tld/owner/repo`` (remote on a non-default host -- the first
# segment must look like a hostname, i.e. contain a dot)
# - ``./...`` (local path within the same repo)
Comment on lines +68 to +72
# Used by both yml_schema and yml_editor for source field validation.
SOURCE_RE = re.compile(r"^(?:[^/\s]+\.[^/\s]+/[^/\s]+/[^/\s]+|[^/\s]+/[^/\s]+|\./.*)$")
LOCAL_SOURCE_RE = re.compile(r"^\./")
# Matches ``host.tld/owner/repo`` (3 segments, first is FQDN-ish).
_HOST_PREFIXED_SOURCE_RE = re.compile(r"^([^/\s]+\.[^/\s]+)/([^/\s]+/[^/\s]+)$")
Comment on lines +71 to +77


def split_host_from_source(source: str) -> tuple[str | None, str]:
"""Split ``host.tld/owner/repo`` into ``(host, owner/repo)``.

Returns ``(None, source)`` for ``owner/repo`` or local ``./...`` forms.
"""
m = _HOST_PREFIXED_SOURCE_RE.match(source)
if m:
return m.group(1), m.group(2)
return None, source

# Placeholder tokens accepted in ``tag_pattern`` / ``build.tagPattern``.
_TAG_PLACEHOLDERS = ("{version}", "{name}")
Expand Down Expand Up @@ -224,6 +240,10 @@ class PackageEntry:
repository: str | None = None
# Derived (set by loader, not by user)
is_local: bool = False
# Optional non-default git host parsed from ``source`` of the form
# ``host.tld/owner/repo``. ``None`` means use the default host
# (``GITHUB_HOST`` env or ``github.com``).
host: str | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -385,6 +405,11 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry:
source = _require_str(raw, "source", context=f"packages[{index}]")
_validate_source(source, index=index)
is_local = bool(LOCAL_SOURCE_RE.match(source))
# Detect host-prefixed source (e.g. ``host.tld/owner/repo``) and split
# the host off so downstream consumers continue to see ``owner/repo``.
host: str | None = None
if not is_local:
host, source = split_host_from_source(source)

# APM-only: subdir (irrelevant for local packages but harmless)
subdir: str | None = raw.get("subdir")
Expand Down Expand Up @@ -527,6 +552,7 @@ def _parse_package_entry(raw: Any, index: int) -> PackageEntry:
license=license_val,
repository=repository,
is_local=is_local,
host=host,
)


Expand Down
162 changes: 162 additions & 0 deletions tests/unit/marketplace/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,28 @@ def _build_with_mock(
return builder.build()


def _build_with_host_mock(
tmp_path: Path,
yml_content: str,
refs_by_remote: dict[str, list[RemoteRef]],
options: BuildOptions | None = None,
) -> BuildReport:
"""Build with a mock resolver that handles default *and* host-prefixed entries.

The standard ``_build_with_mock`` only patches ``_resolver`` (the default
resolver). Host-prefixed entries trigger ``_get_resolver_for_host`` which
constructs a real ``RefResolver`` bound to the override host. For unit
tests we want every host to resolve through the same in-memory mock.
"""
yml_path = _write_yml(tmp_path, yml_content)
opts = options or BuildOptions(offline=True)
builder = MarketplaceBuilder(yml_path, opts)
mock = _MockRefResolver(refs_by_remote)
builder._resolver = mock # type: ignore[assignment]
builder._get_resolver_for_host = lambda _host: mock # type: ignore[assignment]
return builder.build()


# ---------------------------------------------------------------------------
# parse_semver
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -910,6 +932,146 @@ def test_no_subdir_no_path(self, tmp_path: Path) -> None:
cr = data["plugins"][0]
assert "path" not in cr["source"]

# -- default-host (``owner/repo``) ------------------------------------

def test_default_host_emits_github_shorthand(self, tmp_path: Path) -> None:
"""A plain ``owner/repo`` source emits the ``github`` shorthand form."""
refs = {"acme/code-reviewer": _make_refs("v2.0.0")}
yml = """\
name: acme-tools
description: Test
version: 1.0.0
owner:
name: Acme
email: t@acme.example.com
url: https://acme.example.com
packages:
- name: code-reviewer
source: acme/code-reviewer
version: "^2.0.0"
"""
report = _build_with_mock(tmp_path, yml, refs)
data = json.loads(report.output_path.read_text("utf-8"))
src = data["plugins"][0]["source"]
assert src["source"] == "github"
assert src["repo"] == "acme/code-reviewer"
assert "url" not in src
# The github shorthand never carries a ``path`` key.
assert "path" not in src

# -- host-prefixed sources --------------------------------------------

def test_host_prefixed_without_subdir_emits_url_source(self, tmp_path: Path) -> None:
"""``host.tld/owner/repo`` (no subdir) emits a full URL via ``source: url``."""
refs = {"acme/agents": _make_refs("v0.3.0")}
yml = """\
name: ghe-tools
description: Test
version: 1.0.0
owner:
name: Acme
email: t@acme.example.com
url: https://acme.example.com
packages:
- name: ch-baseline
source: ghe.example.com/acme/agents
ref: v0.3.0
"""
report = _build_with_host_mock(tmp_path, yml, refs)
data = json.loads(report.output_path.read_text("utf-8"))
src = data["plugins"][0]["source"]
assert src["source"] == "url"
assert src["url"] == "https://ghe.example.com/acme/agents"
assert "repo" not in src
assert "path" not in src

def test_host_prefixed_with_subdir_emits_git_subdir_url(self, tmp_path: Path) -> None:
"""``host.tld/owner/repo`` + subdir emits ``git-subdir`` with a full https URL."""
refs = {"acme/agents": _make_refs("v0.3.0")}
yml = """\
name: ghe-tools
description: Test
version: 1.0.0
owner:
name: Acme
email: t@acme.example.com
url: https://acme.example.com
packages:
- name: ch-baseline
source: ghe.example.com/acme/agents
subdir: packages/ch-baseline
ref: v0.3.0
"""
report = _build_with_host_mock(tmp_path, yml, refs)
data = json.loads(report.output_path.read_text("utf-8"))
src = data["plugins"][0]["source"]
assert src["source"] == "git-subdir"
assert src["url"] == "https://ghe.example.com/acme/agents"
assert src["path"] == "packages/ch-baseline"

def test_default_host_with_subdir_emits_shorthand_url(self, tmp_path: Path) -> None:
"""``owner/repo`` + subdir keeps the historical ``url: owner/repo`` shape."""
refs = {"acme/test-generator": _make_refs("v1.0.0")}
yml = """\
name: acme-tools
description: Test
version: 1.0.0
owner:
name: Acme
email: t@acme.example.com
url: https://acme.example.com
packages:
- name: test-generator
source: acme/test-generator
version: "~1.0.0"
subdir: src/plugin
"""
report = _build_with_mock(tmp_path, yml, refs)
data = json.loads(report.output_path.read_text("utf-8"))
src = data["plugins"][0]["source"]
assert src["source"] == "git-subdir"
# Default host: keep the shorthand "owner/repo" (no scheme), preserving
# backwards compatibility with marketplaces emitted before host-prefix
# support landed.
assert src["url"] == "acme/test-generator"
assert src["path"] == "src/plugin"

def test_per_host_resolvers_isolated(self, tmp_path: Path) -> None:
"""Two entries on different hosts each get their own resolver instance."""
Comment on lines +1039 to +1040
refs = {
"acme/code-reviewer": _make_refs("v2.0.0"),
"team/repo": _make_refs("v1.0.0"),
}
yml = """\
name: mixed-tools
description: Test
version: 1.0.0
owner:
name: Mixed
email: t@mixed.example.com
url: https://mixed.example.com
packages:
- name: code-reviewer
source: acme/code-reviewer
version: "^2.0.0"
- name: gitlab-tool
source: gitlab.example.org/team/repo
ref: v1.0.0
"""
yml_path = _write_yml(tmp_path, yml)
builder = MarketplaceBuilder(yml_path, BuildOptions(offline=True))
mock = _MockRefResolver(refs)
builder._resolver = mock # type: ignore[assignment]
# Force per-host resolver lookups to reuse the same mock so the
# builder never tries to talk to a real remote.
builder._get_resolver_for_host = lambda _host: mock # type: ignore[assignment]
builder.build()
# Calls to _get_resolver_for_host return the mock for every host,
# but the cache (_host_resolvers) is only written by the original
# implementation -- assert the mock was used by checking refs were
# resolved successfully (build() succeeded without OfflineMissError).
assert mock is builder._get_resolver_for_host("gitlab.example.org")


# ---------------------------------------------------------------------------
# Deterministic output (round-trip)
Expand Down
Loading
Loading