Skip to content

Commit 246ac18

Browse files
committed
feat(marketplace): inherit description/version from local-path apm.yml
Local-path packages (`source: ./...`) now use the same fallback as remote sources when the curator entry under `marketplace.packages` omits `description` or `version`: `apm pack` reads the field from the package's own `apm.yml` and writes it to `marketplace.json`. A curator-side value still wins when set. Path resolution is constrained to the project root, and a source that resolves to the marketplace's own `apm.yml` is skipped. Follow-up to #1061, which added the same behavior for remote sources.
1 parent f670176 commit 246ac18

6 files changed

Lines changed: 382 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919

2020
### Fixed
2121

22+
- `apm pack` now reads `description` and `version` from a local-path package's own `apm.yml` and falls back to those values in the generated `marketplace.json` when the curator entry under `marketplace.packages` omits them. Mirrors the fallback that already runs for remote sources; a curator-side value still wins when set. The local read is constrained to the project root and skips a source that resolves to the marketplace's own `apm.yml`.
2223
- `apm install -g --target copilot` now deploys prompt primitives to `~/.copilot/prompts/` while continuing to filter unsupported user-scope instructions. (closes #1482, #1570)
2324
- Linux standalone `apm` binaries no longer fail git shared-cache clones with shared-library symbol lookup errors caused by PyInstaller dynamic-library paths leaking into child processes. (closes #1534)
2425
- Avoid 13-minute `apm install` hangs in large local projects by limiting synthetic `_local` discovery to `.apm/` and `.github/`, while preserving package metadata discovery. (closes #1507) -- by @ioannispoulios

docs/src/content/docs/reference/manifest-schema.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,8 @@ Each entry MUST be a mapping. Unknown keys are rejected.
685685

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

688+
When `description` or `version` is omitted from a `packages[]` entry, `apm pack` reads the matching field from the referenced package's own `apm.yml` and uses it in the generated `marketplace.json`. Remote packages are fetched over HTTPS (skipped under `--offline`); local packages are read from disk under the project root. A curator-side value still wins when both are set.
689+
688690
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.
689691

690692
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.

src/apm_cli/marketplace/builder.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,62 @@ def resolve(self) -> ResolveResult:
733733
ordered.append(results[idx])
734734
return ResolveResult(entries=tuple(ordered), errors=tuple(errors))
735735

736-
# -- remote description fetcher -----------------------------------------
736+
# -- description/version metadata fetchers ------------------------------
737+
738+
def _fetch_local_metadata(self, pkg: ResolvedPackage) -> dict[str, str] | None:
739+
"""Best-effort: read ``description`` and ``version`` from a
740+
local-path package's ``apm.yml`` on disk.
741+
742+
Local-path packages (``source: ./...``) record the curator's
743+
``source`` value on ``ResolvedPackage.subdir``; the package's
744+
own ``apm.yml`` lives at ``<project_root>/<subdir>/apm.yml``.
745+
Returns a dict with ``description`` and/or ``version`` keys, or
746+
``None`` when the file is missing or unreadable. Mirrors
747+
``_fetch_remote_metadata``: cosmetic enrichment only, failures
748+
are logged at debug level and never propagate.
749+
750+
The resolved path is constrained to ``self._project_root`` so a
751+
curator entry pointing outside the tree is skipped. A source
752+
that resolves to the project root itself is also skipped -- that
753+
file is the marketplace's own ``apm.yml``, not a package
754+
manifest.
755+
"""
756+
if not pkg.subdir:
757+
return None
758+
try:
759+
package_root = ensure_path_within(self._project_root / pkg.subdir, self._project_root)
760+
if package_root == self._project_root.resolve():
761+
return None
762+
file_path = package_root / "apm.yml"
763+
if not file_path.is_file():
764+
return None
765+
data = yaml.safe_load(file_path.read_text(encoding="utf-8"))
766+
if not isinstance(data, dict):
767+
return None
768+
result: dict[str, str] = {}
769+
desc = data.get("description")
770+
if isinstance(desc, str) and desc:
771+
result["description"] = desc
772+
ver = data.get("version")
773+
if ver is not None:
774+
ver_str = str(ver).strip()
775+
if ver_str:
776+
result["version"] = ver_str
777+
if result:
778+
logger.debug(
779+
"Read local metadata for %s from %s: %s",
780+
pkg.name,
781+
file_path,
782+
", ".join(result.keys()),
783+
)
784+
return result
785+
except Exception:
786+
logger.debug(
787+
"Could not read local metadata for %s",
788+
pkg.name,
789+
exc_info=True,
790+
)
791+
return None
737792

738793
def _fetch_remote_metadata(self, pkg: ResolvedPackage) -> dict[str, str] | None:
739794
"""Best-effort: fetch ``description`` and ``version`` from the
@@ -865,27 +920,39 @@ def _resolve_github_token(self) -> str | None:
865920
return None
866921

867922
def _prefetch_metadata(self, resolved: list[ResolvedPackage]) -> dict[str, dict[str, str]]:
868-
"""Concurrently fetch remote metadata for all packages.
923+
"""Fetch ``description``/``version`` metadata for resolved packages.
869924
870925
Returns a mapping of ``{package_name: {"description": ..., "version": ...}}``
871-
for successful fetches. Skipped entirely when ``--offline`` is set.
872-
Local-path packages are skipped (they carry their own metadata).
873-
874-
A GitHub token is resolved once before spawning worker threads and
875-
stored on ``self._github_token`` for the workers to read.
926+
for successful fetches. Both local-path and remote packages are
927+
read from each package's own ``apm.yml`` so the output mapper can
928+
apply one fallback rule regardless of source kind.
929+
930+
Local reads always run (filesystem only). Remote fetches are
931+
skipped when ``--offline`` is set. A GitHub token is resolved
932+
once before spawning worker threads and stored on
933+
``self._github_token`` for the workers to read.
876934
"""
935+
results: dict[str, dict[str, str]] = {}
936+
937+
# Local-path packages: read each apm.yml directly from disk.
938+
# Cheap and serial -- no network, no thread pool needed.
939+
for pkg in resolved:
940+
if pkg.source_repo:
941+
continue
942+
meta = self._fetch_local_metadata(pkg)
943+
if meta:
944+
results[pkg.name] = meta
945+
877946
if self._options.offline:
878-
return {}
947+
return results
879948

880-
# Filter out local-path entries -- they don't have a remote to fetch from.
881949
remote = [pkg for pkg in resolved if pkg.source_repo]
882950
if not remote:
883-
return {}
951+
return results
884952

885953
# Resolve token once -- threads read self._github_token (immutable).
886954
self._ensure_auth()
887955

888-
results: dict[str, dict[str, str]] = {}
889956
workers = min(self._options.concurrency, len(remote))
890957
with ThreadPoolExecutor(max_workers=workers) as pool:
891958
future_to_name = {

src/apm_cli/marketplace/output_mappers.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,41 @@ def compose(
8989
plugin["name"] = pkg.name
9090

9191
if is_local:
92+
meta = remote_metadata.get(pkg.name, {})
9293
if entry.description:
9394
plugin["description"] = entry.description
95+
local_desc = meta.get("description", "")
96+
if local_desc and local_desc != entry.description:
97+
override_count += 1
98+
diagnostics.append(
99+
BuildDiagnostic(
100+
level="verbose",
101+
message=(
102+
f"[i] Package '{pkg.name}': using curator "
103+
f"description (package: "
104+
f"'{local_desc[:40]}')"
105+
),
106+
)
107+
)
108+
elif meta.get("description"):
109+
plugin["description"] = meta["description"]
94110
if entry.version:
95111
plugin["version"] = entry.version
112+
local_ver = meta.get("version", "")
113+
if local_ver and local_ver != entry.version:
114+
override_count += 1
115+
diagnostics.append(
116+
BuildDiagnostic(
117+
level="verbose",
118+
message=(
119+
f"[i] Package '{pkg.name}': using curator "
120+
f"version '{entry.version}' "
121+
f"(package: '{local_ver}')"
122+
),
123+
)
124+
)
125+
elif meta.get("version"):
126+
plugin["version"] = meta["version"]
96127
else:
97128
meta = remote_metadata.get(pkg.name, {})
98129
if entry and entry.description:

tests/unit/marketplace/test_builder.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,6 +1786,113 @@ def test_no_auth_header_when_no_token(self, tmp_path: Path) -> None:
17861786
assert req.get_header("Authorization") is None
17871787

17881788

1789+
# ---------------------------------------------------------------------------
1790+
# _fetch_local_metadata tests
1791+
# ---------------------------------------------------------------------------
1792+
1793+
1794+
class TestFetchLocalMetadata:
1795+
"""Tests for best-effort local apm.yml metadata reads."""
1796+
1797+
def _make_pkg(
1798+
self,
1799+
*,
1800+
name: str = "local-tool",
1801+
subdir: str | None = "./packages/local-tool",
1802+
) -> ResolvedPackage:
1803+
return ResolvedPackage(
1804+
name=name,
1805+
source_repo="",
1806+
subdir=subdir,
1807+
ref="",
1808+
sha="",
1809+
requested_version=None,
1810+
tags=(),
1811+
is_prerelease=False,
1812+
)
1813+
1814+
def _make_builder(self, tmp_path: Path) -> MarketplaceBuilder:
1815+
yml_path = _write_yml(tmp_path, _BASIC_YML)
1816+
return MarketplaceBuilder(yml_path)
1817+
1818+
def test_reads_description_and_version_from_apm_yml(self, tmp_path: Path) -> None:
1819+
"""File on disk with both fields -> both returned."""
1820+
pkg_dir = tmp_path / "packages" / "local-tool"
1821+
pkg_dir.mkdir(parents=True)
1822+
(pkg_dir / "apm.yml").write_text(
1823+
"name: local-tool\ndescription: A local tool\nversion: 1.2.3\n",
1824+
encoding="utf-8",
1825+
)
1826+
builder = self._make_builder(tmp_path)
1827+
result = builder._fetch_local_metadata(self._make_pkg())
1828+
assert result is not None
1829+
assert result["description"] == "A local tool"
1830+
assert result["version"] == "1.2.3"
1831+
1832+
def test_description_only(self, tmp_path: Path) -> None:
1833+
"""File with description but no version -> only description returned."""
1834+
pkg_dir = tmp_path / "packages" / "local-tool"
1835+
pkg_dir.mkdir(parents=True)
1836+
(pkg_dir / "apm.yml").write_text(
1837+
"name: local-tool\ndescription: Only desc\n",
1838+
encoding="utf-8",
1839+
)
1840+
builder = self._make_builder(tmp_path)
1841+
result = builder._fetch_local_metadata(self._make_pkg())
1842+
assert result is not None
1843+
assert result["description"] == "Only desc"
1844+
assert "version" not in result
1845+
1846+
def test_missing_apm_yml_returns_none(self, tmp_path: Path) -> None:
1847+
"""Subdir exists but has no apm.yml -> None, no crash."""
1848+
(tmp_path / "packages" / "local-tool").mkdir(parents=True)
1849+
builder = self._make_builder(tmp_path)
1850+
result = builder._fetch_local_metadata(self._make_pkg())
1851+
assert result is None
1852+
1853+
def test_missing_subdir_returns_none(self, tmp_path: Path) -> None:
1854+
"""Subdir does not exist -> None, no crash."""
1855+
builder = self._make_builder(tmp_path)
1856+
result = builder._fetch_local_metadata(self._make_pkg())
1857+
assert result is None
1858+
1859+
def test_path_escapes_project_root_returns_none(self, tmp_path: Path) -> None:
1860+
"""A subdir that resolves outside the project root is skipped."""
1861+
builder = self._make_builder(tmp_path)
1862+
pkg = self._make_pkg(subdir="../escape")
1863+
result = builder._fetch_local_metadata(pkg)
1864+
assert result is None
1865+
1866+
def test_subdir_resolves_to_project_root_returns_none(self, tmp_path: Path) -> None:
1867+
"""Source that resolves to project root reads the marketplace's own
1868+
apm.yml, not a package manifest -- skip rather than emit the
1869+
marketplace description as a package description.
1870+
"""
1871+
builder = self._make_builder(tmp_path)
1872+
pkg = self._make_pkg(subdir="./")
1873+
result = builder._fetch_local_metadata(pkg)
1874+
assert result is None
1875+
1876+
def test_malformed_yaml_returns_none(self, tmp_path: Path) -> None:
1877+
"""Bad YAML -> None, no exception propagates."""
1878+
pkg_dir = tmp_path / "packages" / "local-tool"
1879+
pkg_dir.mkdir(parents=True)
1880+
(pkg_dir / "apm.yml").write_text(
1881+
"name: local-tool\ndescription: [unclosed",
1882+
encoding="utf-8",
1883+
)
1884+
builder = self._make_builder(tmp_path)
1885+
result = builder._fetch_local_metadata(self._make_pkg())
1886+
assert result is None
1887+
1888+
def test_empty_subdir_field_returns_none(self, tmp_path: Path) -> None:
1889+
"""Defensive: a ResolvedPackage with subdir=None is skipped."""
1890+
builder = self._make_builder(tmp_path)
1891+
pkg = self._make_pkg(subdir=None)
1892+
result = builder._fetch_local_metadata(pkg)
1893+
assert result is None
1894+
1895+
17891896
class _FakeHTTPResponse:
17901897
"""Minimal file-like mock for urllib.request.urlopen return value."""
17911898

0 commit comments

Comments
 (0)