337 lines
13 KiB
Python
337 lines
13 KiB
Python
"""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())
|