"""Operator CLI for the DataTools License Server. Talks to the server's ``/internal/*`` endpoints over a local SSH tunnel. Default base URL is ``http://127.0.0.1:8090`` — open the tunnel with:: ssh -L 8090:127.0.0.1:8090 michael@46.225.166.142 -N Auth: ``$DATATOOLS_ADMIN_TOKEN`` (matching the token in ``/srv/datatools-license/secrets/admin_token`` on the server). Commands:: datatools-admin ping datatools-admin mint --name "Jane Doe" --email jane@example.com --tier core datatools-admin list [--email STR] [--tier STR] [--source STR] [--include-revoked] datatools-admin revoke DT1-CORE-xxxx-yyyy --reason "refund" This supersedes ``scripts/generate_license.py``, which now exists only as a break-glass tool for when the server is unreachable. """ from __future__ import annotations import json import os import sys from typing import Any, Optional import typer from urllib import request as urlrequest from urllib.error import HTTPError, URLError from urllib.parse import urlencode app = typer.Typer(no_args_is_help=True, add_completion=False) # --------------------------------------------------------------------------- # Transport # --------------------------------------------------------------------------- def _base_url() -> str: return os.environ.get("DATATOOLS_ADMIN_URL", "http://127.0.0.1:8090").rstrip("/") def _token() -> str: tok = os.environ.get("DATATOOLS_ADMIN_TOKEN") if not tok: typer.echo( "ERROR: DATATOOLS_ADMIN_TOKEN is not set. Read the token from\n" " /srv/datatools-license/secrets/admin_token on the server\n" " and export it locally.", err=True, ) raise typer.Exit(code=2) return tok def _request(method: str, path: str, *, body: Optional[dict] = None, query: Optional[dict] = None) -> Any: url = _base_url() + path if query: url += "?" + urlencode({k: v for k, v in query.items() if v is not None}) data = json.dumps(body).encode("utf-8") if body is not None else None req = urlrequest.Request( url, data=data, method=method, headers={ "Authorization": f"Bearer {_token()}", "Content-Type": "application/json" if data else "application/octet-stream", "Accept": "application/json", }, ) try: with urlrequest.urlopen(req, timeout=30) as resp: raw = resp.read() return json.loads(raw) if raw else None except HTTPError as e: try: err_body = json.loads(e.read()) except Exception: err_body = {"detail": e.reason} typer.echo(f"ERROR {e.code}: {err_body.get('detail', err_body)}", err=True) raise typer.Exit(code=1) except URLError as e: typer.echo( f"ERROR: could not reach {url}: {e.reason}\n" " Is the SSH tunnel open? See the docstring.", err=True, ) raise typer.Exit(code=1) # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- @app.command() def ping() -> None: """Sanity-check the tunnel + token.""" result = _request("GET", "/internal/ping") typer.echo(json.dumps(result, indent=2)) @app.command() def mint( name: str = typer.Option(..., help="Buyer's full name."), email: str = typer.Option(..., help="Buyer's email."), tier: str = typer.Option(..., help="lite | core | pro | enterprise."), years: int = typer.Option(1, help="License lifetime in years."), source: str = typer.Option("manual", help="Origin: manual / gumroad / ..."), promotion: Optional[str] = typer.Option(None), amount_paid: Optional[str] = typer.Option(None, "--amount-paid", help="e.g. 79.00"), currency: str = typer.Option("USD"), notes: Optional[str] = typer.Option(None), ) -> None: """Mint a license + persist a DB row + return the blob. PR 1 only accepts ``source=manual`` here. Storefront sales arrive via webhook in PR 2. """ body = { "name": name, "email": email, "tier": tier, "years": years, "source": source, "promotion": promotion, "amount_paid": amount_paid, "currency": currency, "notes": notes, } result = _request("POST", "/internal/mint", body=body) typer.echo(result["blob"]) typer.echo( f" key: {result['license_key']}\n" f" tier: {result['tier']}\n" f" expires: {result['expires_at']}", err=True, ) @app.command(name="list") def list_cmd( email: Optional[str] = typer.Option(None), tier: Optional[str] = typer.Option(None), source: Optional[str] = typer.Option(None), include_revoked: bool = typer.Option(False, "--include-revoked"), limit: int = typer.Option(50), offset: int = typer.Option(0), ) -> None: """List licenses. Filters AND together; default excludes revoked.""" rows = _request( "GET", "/internal/licenses", query={ "email": email, "tier": tier, "source": source, "include_revoked": "true" if include_revoked else "false", "limit": limit, "offset": offset, }, ) if not rows: typer.echo("(no rows)") return for r in rows: revoked = " [REVOKED]" if r.get("revoked_at") else "" typer.echo( f"{r['license_key']:36} {r['tier']:10} {r['email']:40} " f"exp {r['expires_at'][:10]} src={r['source']}{revoked}" ) @app.command() def revoke( license_key: str = typer.Argument(..., help="DT1-... key from `list`."), reason: Optional[str] = typer.Option(None, help="Stored in notes for audit."), ) -> None: """Mark a license revoked. Desktop activations stay working until expiry (no online check); revocation gates future renewals only.""" result = _request( "POST", "/internal/revoke", body={"license_key": license_key, "reason": reason}, ) typer.echo(f"Revoked {result['license_key']} at {result['revoked_at']}") if __name__ == "__main__": app()