"""m2-market CLI entrypoint (contracts/cli.md).""" from __future__ import annotations import socket import sys import tarfile import tempfile import uuid from pathlib import Path import click from m2_market import __version__ from m2_market.apply import RailsNotLanded, get_adapter from m2_market.apply.base import InstallState from m2_market.catalog import CatalogClient from m2_market.config import Config, ConfigError, load_config from m2_market.ledger import InsufficientFunds, LedgerClient from m2_market.output import emit_json, format_price, render_kv, render_table, search_results_table from m2_market.publish import PublishValidationError, publish_bundle, validate_bundle from m2_market.registry import RegistryClient, RegistryError # Exit-code map (contracts/cli.md). EXIT_OK = 0 # ok / no-op EXIT_USAGE = 1 # usage (also: missing/invalid config) EXIT_NOT_FOUND = 2 # not found EXIT_INSUFFICIENT_FUNDS = 3 # insufficient funds EXIT_APPLY_FAILED = 4 # apply failed (refunded) EXIT_VALIDATION = 5 # validation / firewall rejection EXIT_RAILS_NOT_LANDED = 6 # rails-not-landed (m2core_sync stub invoked) @click.group(name="m2-market") @click.option("--json", "json_output", is_flag=True, default=False, help="Emit machine-readable JSON output.") @click.version_option(version=__version__, prog_name="m2-market") @click.pass_context def main(ctx: click.Context, json_output: bool) -> None: """M2 Marketplace CLI (search | show | install | wallet | publish | reindex).""" ctx.ensure_object(dict) ctx.obj["json"] = json_output try: ctx.obj["config"] = load_config() except ConfigError as exc: click.echo(f"m2-market: config error: {exc}", err=True) ctx.exit(EXIT_USAGE) def _not_implemented(ctx: click.Context, command: str) -> None: click.echo(f"m2-market {command}: not implemented", err=True) ctx.exit(EXIT_USAGE) @main.command() @click.argument("query") @click.option("--type", "listing_type", default=None, help="Filter by listing type (e.g. solution).") @click.option("--limit", type=int, default=None, help="Maximum number of results.") @click.pass_context def search(ctx: click.Context, query: str, listing_type: str | None, limit: int | None) -> None: """Semantic catalog query, tenant-filtered.""" config: Config = ctx.obj["config"] with CatalogClient(config.memory_api_url, config.memory_api_key, config.tenant) as catalog: items = catalog.search(query, limit=limit or 10) if listing_type: items = [ item for item in items if (item.get("listing") or item).get("inventory_type") == listing_type ] if ctx.obj["json"]: emit_json(items) else: search_results_table(items) def _fetch_solution(registry: RegistryClient, listing_id: str) -> dict: """Best-effort solution lookup — permissions/entrypoint are a nice-to-have, not fatal.""" try: return registry.get_solution(listing_id) except Exception: return {} @main.command() @click.argument("listing_id") @click.pass_context def show(ctx: click.Context, listing_id: str) -> None: """Full listing detail: evidence, price, permissions diff, seller, version.""" config: Config = ctx.obj["config"] json_output = ctx.obj["json"] with CatalogClient(config.memory_api_url, config.memory_api_key, config.tenant) as catalog: listing = catalog.get(listing_id) if listing is None: click.echo(f"m2-market show: listing not found: {listing_id}", err=True) ctx.exit(EXIT_NOT_FOUND) return with RegistryClient(config.forgejo_url, config.forgejo_token) as registry: solution = _fetch_solution(registry, listing_id) permissions = solution.get("permissions") or [] if json_output: emit_json({"listing": listing, "solution": solution, "permissions": permissions}) return click.echo(f"{listing.get('name', listing_id)} ({listing_id})") click.echo(listing.get("summary", "")) click.echo() render_kv( [ ("evidence_summary", listing.get("evidence_summary", "-")), ("price", format_price(listing.get("price"))), ("seller", listing.get("seller", "-")), ("version", listing.get("solution_version", "-")), ("install_ref", listing.get("install_ref", "-")), ] ) if permissions: click.echo("\nPermissions:") for perm in permissions: click.echo(f" - {perm}") def _extract_bundle(bundle_path: Path, extract_dir: Path) -> Path: with tarfile.open(bundle_path) as tar: tar.extractall(extract_dir, filter="data") entries = list(extract_dir.iterdir()) if len(entries) == 1 and entries[0].is_dir(): return entries[0] return extract_dir def _print_entrypoint_hint(json_output: bool, solution: dict, extra: dict | None = None) -> None: entrypoint = (solution.get("deployment") or {}).get("entrypoint") if json_output: payload = {"status": "ok", "entrypoint": entrypoint} if extra: payload.update(extra) emit_json(payload) elif entrypoint: click.echo(f"Installed. Run: {entrypoint}") else: click.echo("Installed.") @main.command() @click.argument("listing_id") @click.option("--yes", is_flag=True, default=False, help="Skip interactive confirmation.") @click.option("--adapter", "adapter_name", default=None, help="Override the configured apply adapter.") @click.pass_context def install(ctx: click.Context, listing_id: str, yes: bool, adapter_name: str | None) -> None: """Resolve, pay, fetch, apply, and record a listing install (contracts/cli.md FR-005 sequence).""" config: Config = ctx.obj["config"] json_output = ctx.obj["json"] state = InstallState() with LedgerClient(config.ledger_url, config.ledger_api_key) as ledger: # Idempotent re-run guard (Story 1 AC-3): if we've already applied this # listing and a license grant still exists, no-op BEFORE debiting. cached = state.get(listing_id) if cached is not None and cached.get("status") == "applied": grant = ledger.license(config.operator_id, listing_id) if grant is not None: with RegistryClient(config.forgejo_url, config.forgejo_token) as registry: solution = _fetch_solution(registry, listing_id) if not json_output: click.echo(f"{listing_id} already installed — no-op.") _print_entrypoint_hint(json_output, solution, {"listing_id": listing_id, "noop": True}) return with CatalogClient(config.memory_api_url, config.memory_api_key, config.tenant) as catalog: listing = catalog.get(listing_id) if listing is None: click.echo(f"m2-market install: listing not found: {listing_id}", err=True) ctx.exit(EXIT_NOT_FOUND) return with RegistryClient(config.forgejo_url, config.forgejo_token) as registry: solution = _fetch_solution(registry, listing_id) price = listing.get("price") or {} permissions = solution.get("permissions") or [] if not json_output: click.echo(f"{listing.get('name', listing_id)} — {format_price(price)}") if permissions: click.echo("Permissions requested:") for perm in permissions: click.echo(f" - {perm}") if not yes and not click.confirm("Proceed with install?", default=False): return ref = f"{listing_id}:{socket.gethostname()}:{uuid.uuid4()}" seller = listing.get("seller") or solution.get("seller") amount = price.get("amount") try: grant = ledger.install(buyer=config.operator_id, seller=seller, amount=amount, ref=ref) except InsufficientFunds as exc: click.echo(f"m2-market install: insufficient funds: {exc}", err=True) ctx.exit(EXIT_INSUFFICIENT_FUNDS) return install_ref = listing.get("install_ref") content_hash = solution.get("content_hash") with tempfile.TemporaryDirectory(prefix="m2-market-bundle-") as tmp_dir_str: tmp_dir = Path(tmp_dir_str) try: bundle_path = registry.download_bundle(install_ref, tmp_dir / "download") if content_hash and not RegistryClient.verify_bundle(bundle_path, content_hash): raise RegistryError(f"content hash mismatch for {install_ref}") bundle_root = _extract_bundle(bundle_path, tmp_dir / "extracted") except (RegistryError, OSError, tarfile.TarError) as exc: ledger.refund(ref) click.echo( f"m2-market install: bundle fetch/verify failed, refunded: {exc}", err=True, ) ctx.exit(EXIT_APPLY_FAILED) return adapter_choice = adapter_name or config.apply_adapter try: adapter = get_adapter(adapter_choice) changeset = adapter.plan(bundle_root, state) apply_result = adapter.apply(changeset) if not apply_result.ok: raise RuntimeError("; ".join(apply_result.errors) or "apply reported failure") if not adapter.verify(bundle_root, state): raise RuntimeError("post-install verification failed") except RailsNotLanded as exc: ledger.refund(ref) click.echo(f"m2-market install: {exc}", err=True) ctx.exit(EXIT_RAILS_NOT_LANDED) return except Exception as exc: ledger.refund(ref) click.echo( f"m2-market install: apply failed, refunded: {exc}\n" "Remediation: inspect the recipe's post-install checks, fix the " "underlying issue, then retry `m2-market install`.", err=True, ) ctx.exit(EXIT_APPLY_FAILED) return state.record_install( listing_id, listing.get("solution_version") or changeset.version, grant.get("grant_id", ""), changeset.changeset_hash, "applied", ) _print_entrypoint_hint(json_output, solution, {"listing_id": listing_id, "noop": False}) @main.command() @click.option("--operator", "operator", default=None, help="Operator id to query (defaults to config).") @click.pass_context def wallet(ctx: click.Context, operator: str | None) -> None: """Balance + recent transactions from the ledger.""" config: Config = ctx.obj["config"] json_output = ctx.obj["json"] operator_id = operator or config.operator_id with LedgerClient(config.ledger_url, config.ledger_api_key) as ledger: balance = ledger.balance(operator_id) txs = ledger.transactions(operator_id=operator_id) recent = list(reversed(txs))[:10] if json_output: emit_json({"balance": balance, "transactions": recent}) return click.echo(f"Operator: {operator_id}") click.echo(f"Balance: {balance.get('balance')} (as of {balance.get('as_of')})") click.echo() headers = ["ts", "from", "to", "amount", "reason", "ref"] rows = [ [ str(tx.get("ts", "-")), str(tx.get("from", "-")), str(tx.get("to", "-")), str(tx.get("amount", "-")), str(tx.get("reason", "-")), str(tx.get("ref", "-")), ] for tx in recent ] render_table(headers, rows) @main.command() @click.argument("bundle_dir") @click.pass_context def publish(ctx: click.Context, bundle_dir: str) -> None: """Validate a bundle and open a listing PR on the registry (Story 2 step 1-2).""" config: Config = ctx.obj["config"] json_output = ctx.obj["json"] try: solution, listing = validate_bundle(Path(bundle_dir)) except PublishValidationError as exc: for line in exc.errors: click.echo(f"m2-market publish: {line}", err=True) ctx.exit(EXIT_VALIDATION) return with RegistryClient(config.forgejo_url, config.forgejo_token) as registry: pr_url = publish_bundle(Path(bundle_dir), solution, listing, registry) if json_output: emit_json({"status": "ok", "pr_url": pr_url}) else: click.echo(pr_url) @main.command() @click.pass_context def reindex(ctx: click.Context) -> None: """Invoke the catalog indexer full rebuild (admin).""" _not_implemented(ctx, "reindex") if __name__ == "__main__": sys.exit(main())