"""CLI for license management. Four commands: - ``activate BLOB --name NAME --email EMAIL`` First-time activation. Verifies the signed blob, ensures the name + email match, writes ``~/.datatools/license.json``. - ``renew BLOB`` Apply a renewal blob to the currently-active license. The blob's embedded name + email must match the active license; tier may differ (upgrade path, e.g., Lite → Core). - ``status [--json]`` Print the current license state. Human-readable by default; ``--json`` emits the same payload as a JSON document for piping into shell scripts / monitoring. - ``deactivate`` Remove the local license file. This CLI is exempt from the guard that protects every tool CLI — otherwise a user with no license couldn't run ``activate``. There is **no free-trial subcommand**: every license requires a paid blob from the seller. The internal ``LicenseManager._mint`` API is used by tests and by the seller's ``scripts/generate_license.py``; end users have no way to self-issue a license. """ from __future__ import annotations import json import sys from typing import Optional import typer from src.license import ( LicenseError, Tier, get_manager, ) from src.license.manager import LicenseManager app = typer.Typer( name="license", help=( "Manage your DataTools license: activate a paid blob, renew, " "check status, or self-issue a 1-year trial.\n\n" "All operations are local — no internet calls. The signed " "license file lives at ~/.datatools/license.json (override " "with $DATATOOLS_LICENSE_PATH)." ), add_completion=False, no_args_is_help=True, ) @app.command() def activate( blob: str = typer.Argument(..., help="License blob from the delivery email (starts with DTLIC1:)."), name: str = typer.Option(..., "--name", "-n", help="Buyer name (must match the blob)."), email: str = typer.Option(..., "--email", "-e", help="Buyer email (must match the blob)."), ) -> None: """Verify and install a license blob.""" mgr = get_manager() try: lic = mgr.activate_from_blob(blob, name=name, email=email) except LicenseError as e: typer.echo(f"Activation failed: {e}", err=True) raise typer.Exit(code=2) typer.echo( f"Activated. Tier: {lic.tier.value} · " f"Key: {lic.license_key} · " f"Expires: {lic.expires_at[:10]}" ) @app.command() def renew( blob: str = typer.Argument(..., help="Renewal blob from the renewal email."), ) -> None: """Apply a renewal blob to the currently active license.""" mgr = get_manager() try: lic = mgr.renew(blob) except LicenseError as e: typer.echo(f"Renewal failed: {e}", err=True) raise typer.Exit(code=2) typer.echo( f"Renewed. New expiry: {lic.expires_at[:10]} " f"({lic.days_remaining()} days)" ) @app.command() def status( json_output: bool = typer.Option(False, "--json", help="Emit JSON instead of human-readable text."), ) -> None: """Print the current license state.""" state = get_manager().current_state() if json_output: typer.echo(json.dumps(state.as_dict(), indent=2)) return if not state.activated: typer.echo("Status: not activated.") typer.echo( "Run: python -m src.license_cli activate " "--name 'Your Name' --email you@example.com" ) raise typer.Exit(code=1) if state.error_kind == "invalid": typer.echo(f"Status: invalid. {state.error_message}") raise typer.Exit(code=2) if state.error_kind == "expired": typer.echo( f"Status: expired on {state.expires_at[:10]}. " f"Renew with: python -m src.license_cli renew " ) raise typer.Exit(code=2) typer.echo( f"Status: active.\n" f" Name: {state.name}\n" f" Email: {state.email}\n" f" Tier: {state.tier}\n" f" Key: {state.license_key}\n" f" Issued: {state.issued_at[:10]}\n" f" Expires: {state.expires_at[:10]} ({state.days_remaining} days)\n" f" Features: {', '.join(state.features)}" ) @app.command() def deactivate( confirm: bool = typer.Option( False, "--yes", "-y", help="Skip the interactive confirmation.", ), ) -> None: """Remove the local license file (does NOT contact a server).""" if not confirm: if not typer.confirm( "This removes the license file at ~/.datatools/license.json. Continue?", default=False, ): typer.echo("Aborted.") raise typer.Exit(code=1) removed = get_manager().deactivate() if removed: typer.echo("Deactivated. The license file has been removed.") else: typer.echo("No license was active; nothing to deactivate.") def main() -> None: app() if __name__ == "__main__": main()