Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0ef3b97
refactor: split marketplace commands into package modules
shreejaykurhade Apr 28, 2026
138a8a5
updated tests/integration/marketplace/README.md
shreejaykurhade Apr 28, 2026
8494369
Merge branch 'main' into refactor/marketplace
danielmeppiel Apr 28, 2026
2744897
Merge branch 'main' into refactor/marketplace
shreejaykurhade Apr 29, 2026
305e070
Fix(marketplace): doctor subprocess import boundary
shreejaykurhade Apr 29, 2026
622e771
Merge branch 'refactor/marketplace' of https://github.com/shreejaykur…
shreejaykurhade Apr 29, 2026
98fae0f
Replace Unicode comment dashes with ASCII hyphens
shreejaykurhade Apr 29, 2026
8f8a130
Fix: marketplace command imports, help, and logging polish
shreejaykurhade Apr 29, 2026
13beec4
Merge branch 'main' into refactor/marketplace
shreejaykurhade Apr 29, 2026
e5ddccb
Merge remote-tracking branch 'origin/main' into refactor/marketplace
danielmeppiel Apr 29, 2026
7299f55
fix(marketplace): revert search alias regression + clean import disci…
danielmeppiel Apr 29, 2026
54d3658
Merge branch 'main' into refactor/marketplace
danielmeppiel Apr 29, 2026
607e729
Merge branch 'main' of https://github.com/microsoft/apm into refactor…
shreejaykurhade Apr 29, 2026
71c69ce
Merge remote-tracking branch 'origin/main' into refactor/marketplace
shreejaykurhade Apr 29, 2026
649f147
Merge branch 'main' into refactor/marketplace
danielmeppiel Apr 29, 2026
ddb74e1
fix(marketplace): address review panel - delete no-op guard, restore …
Copilot Apr 29, 2026
a4cfd8c
Merge branch 'main' into refactor/marketplace
shreejaykurhade Apr 30, 2026
afc07e6
Merge branch 'main' into refactor/marketplace
danielmeppiel Apr 30, 2026
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

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions src/apm_cli/commands/marketplace/check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""``apm marketplace check`` command."""

from __future__ import annotations

import sys
import traceback

import click

from ...core.command_logger import CommandLogger
from ...marketplace.errors import GitLsRemoteError, OfflineMissError
from ...marketplace.ref_resolver import RefResolver
from ...marketplace.semver import satisfies_range
from . import (
marketplace,
_CheckResult,
_extract_tag_versions,
_load_config_or_exit,
_render_check_table,
_warn_duplicate_names,
)


@marketplace.command(help="Validate marketplace entries are resolvable")
@click.option("--offline", is_flag=True, help="Schema + cached-ref checks only (no network)")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
def check(offline, verbose):
"""Validate marketplace.yml and check each entry is resolvable."""
logger = CommandLogger("marketplace-check", verbose=verbose)

_, yml = _load_config_or_exit(logger)

# Defence-in-depth: flag duplicate package names (yml_schema
# also rejects them, but an extra check keeps diagnostics visible).
_warn_duplicate_names(logger, yml)

if offline:
logger.progress(
"Offline mode -- only schema and cached-ref checks",
symbol="info",
)

resolver = RefResolver(offline=offline)
results = []
failure_count = 0

try:
for entry in yml.packages:
try:
# Attempt to resolve each entry
refs = resolver.list_remote_refs(entry.source)

# Check version/ref resolution
ref_ok = False
if entry.ref is not None:
# Check the explicit ref exists
for r in refs:
tag_name = r.name
if tag_name.startswith("refs/tags/"):
tag_name = tag_name[len("refs/tags/"):]
elif tag_name.startswith("refs/heads/"):
tag_name = tag_name[len("refs/heads/"):]
if tag_name == entry.ref or r.name == entry.ref:
ref_ok = True
break
if not ref_ok:
results.append(_CheckResult(
name=entry.name, reachable=True,
version_found=False, ref_ok=False,
error=f"Ref '{entry.ref}' not found",
))
failure_count += 1
continue
else:
# Version range -- check at least one tag satisfies
tag_versions = _extract_tag_versions(
refs, entry, yml, False
)
version_range = entry.version or ""
matching = [
(sv, tag) for sv, tag in tag_versions
if satisfies_range(sv, version_range)
]
if matching:
ref_ok = True
else:
results.append(_CheckResult(
name=entry.name, reachable=True,
version_found=len(tag_versions) > 0,
ref_ok=False,
error=f"No tag matching '{version_range}'",
))
failure_count += 1
continue

results.append(_CheckResult(
name=entry.name, reachable=True,
version_found=True, ref_ok=True, error="",
))

except OfflineMissError:
results.append(_CheckResult(
name=entry.name, reachable=False,
version_found=False, ref_ok=False,
error="No cached refs (offline)",
))
failure_count += 1
except GitLsRemoteError as exc:
results.append(_CheckResult(
name=entry.name, reachable=False,
version_found=False, ref_ok=False,
error=exc.summary_text[:60],
))
failure_count += 1
except Exception as exc: # noqa: BLE001 -- per-entry diagnostic catch-all
results.append(_CheckResult(
name=entry.name, reachable=False,
version_found=False, ref_ok=False,
error=str(exc)[:60],
))
failure_count += 1
logger.verbose_detail(traceback.format_exc())

_render_check_table(logger, results)

total = len(results)
if failure_count > 0:
logger.error(
f"{failure_count} entries have issues", symbol="error"
)
sys.exit(1)
else:
logger.success(
f"All {total} entries OK", symbol="check"
)

finally:
resolver.close()
199 changes: 199 additions & 0 deletions src/apm_cli/commands/marketplace/doctor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""``apm marketplace doctor`` command."""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import click

from ...core.command_logger import CommandLogger
from ...marketplace.errors import MarketplaceYmlError
from ...marketplace.git_stderr import translate_git_stderr
from ...marketplace.migration import ConfigSource, detect_config_source
from ...marketplace.yml_schema import (
load_marketplace_from_apm_yml,
load_marketplace_yml,
)
from . import (
marketplace,
_DoctorCheck,
_find_duplicate_names,
_render_doctor_table,
)


@marketplace.command(help="Run environment diagnostics for marketplace publishing")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
def doctor(verbose):
"""Check git, network, auth, and marketplace config readiness."""
logger = CommandLogger("marketplace-doctor", verbose=verbose)
checks = []

# Check 1: git on PATH
git_ok = False
git_detail = ""
try:
result = subprocess.run(
["git", "--version"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
git_ok = True
git_detail = result.stdout.strip()
else:
git_detail = "git returned non-zero exit code"
except FileNotFoundError:
git_detail = "git not found on PATH"
except subprocess.TimeoutExpired:
git_detail = "git --version timed out"
except (subprocess.SubprocessError, OSError) as exc:
git_detail = str(exc)[:60]

checks.append(_DoctorCheck(
name="git",
passed=git_ok,
detail=git_detail,
))

# Check 2: network reachability
net_ok = False
net_detail = ""
try:
result = subprocess.run(
["git", "ls-remote", "https://github.com/git/git.git", "HEAD"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
net_ok = True
net_detail = "github.com reachable"
else:
translated = translate_git_stderr(
result.stderr,
exit_code=result.returncode,
operation="ls-remote",
remote="github.com",
)
net_detail = translated.hint[:80]
except subprocess.TimeoutExpired:
net_detail = "Network check timed out (5s)"
except FileNotFoundError:
net_detail = "git not found; cannot test network"
except (subprocess.SubprocessError, OSError) as exc:
net_detail = str(exc)[:60]

checks.append(_DoctorCheck(
name="network",
passed=net_ok,
detail=net_detail,
))

# Check 3: auth tokens (delegate to AuthResolver for full coverage)
try:
from ...core.auth import AuthResolver
resolver = AuthResolver()
# Try to get a token for github.com as a representative check
token = resolver.resolve("github.com").token
has_token = bool(token)
except Exception: # noqa: BLE001 -- best-effort auth probe
has_token = False
auth_detail = "Token detected" if has_token else "No token; unauthenticated rate limits apply"
checks.append(_DoctorCheck(
name="auth",
passed=True, # informational; never fails
detail=auth_detail,
informational=True,
))

# Check 4: gh CLI availability (informational; only needed for publish)
gh_ok = False
gh_detail = ""
try:
result = subprocess.run(
["gh", "--version"],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
gh_ok = True
gh_detail = result.stdout.strip().split("\n")[0]
else:
gh_detail = "gh CLI returned non-zero exit code"
except FileNotFoundError:
gh_detail = "gh CLI not found (install: https://cli.github.com/)"
except subprocess.TimeoutExpired:
gh_detail = "gh --version timed out"
except (subprocess.SubprocessError, OSError) as exc:
gh_detail = str(exc)[:60]

checks.append(_DoctorCheck(
name="gh CLI",
passed=gh_ok,
detail=gh_detail,
informational=True,
))

# Check 5: marketplace config presence + parsability
project_root = Path.cwd()
apm_path = project_root / "apm.yml"
legacy_path = project_root / "marketplace.yml"
yml_obj = None
config_passed = True
config_detail = ""

try:
source = detect_config_source(project_root)
if source == ConfigSource.APM_YML:
try:
yml_obj = load_marketplace_from_apm_yml(apm_path)
config_detail = "apm.yml 'marketplace:' block found and valid"
except MarketplaceYmlError as exc:
config_passed = False
config_detail = f"apm.yml marketplace block has errors: {str(exc)[:60]}"
elif source == ConfigSource.LEGACY_YML:
try:
yml_obj = load_marketplace_yml(legacy_path)
config_detail = (
"marketplace.yml found (legacy). Run 'apm marketplace "
"migrate' to fold it into apm.yml."
)
except MarketplaceYmlError as exc:
config_passed = False
config_detail = f"marketplace.yml has errors: {str(exc)[:60]}"
else:
config_detail = "No marketplace authoring config in current directory"
except MarketplaceYmlError as exc:
config_passed = False
config_detail = str(exc)[:120]

checks.append(_DoctorCheck(
name="marketplace config",
passed=config_passed,
detail=config_detail,
informational=True,
))

# Check 6: duplicate package names (defence-in-depth)
if yml_obj is not None:
dup_detail = _find_duplicate_names(yml_obj)
if dup_detail:
checks.append(_DoctorCheck(
name="duplicate names",
passed=False,
detail=dup_detail,
informational=True,
))
else:
checks.append(_DoctorCheck(
name="duplicate names",
passed=True,
detail="No duplicate package names",
informational=True,
))

_render_doctor_table(logger, checks)

# Exit: 0 if checks 1-2 pass; config checks are informational
critical_checks = [c for c in checks if not c.informational]
if any(not c.passed for c in critical_checks):
sys.exit(1)
Loading
Loading