feat(license): registration + 1-year licenses + tier scaffolding
A complete offline licensing layer (no internet at any step): Core - src/license/ — schema (License, Tier, FeatureFlag), HMAC crypto, JSON storage, LicenseManager singleton with activate/renew/ deactivate/issue_trial. Tier-scaffolded so future SKUs can carve per-tool feature sets without consumer-code edits. - scripts/generate_license.py — creator-only key generator. Mints a DTLIC1: blob the buyer pastes into the activation page. GUI - New activation form component (src/gui/components/activation.py). - hide_streamlit_chrome() now inline-renders the activation form when no valid license is present (every page short-circuits to the form until activated). - Sidebar shows tier + days remaining; renewal warning under 30 days. - New pages/_Activate.py for revisiting the form after activation. CLI - src/license_cli.py — activate / renew / status / trial / deactivate commands. Exempt from the guard. - src/cli_license_guard.py — drop-in guard call added to every tool CLI's main(). Lets --help through; respects DATATOOLS_DEV_MODE. i18n - New activation.* and license.* keys in en.json + es.json (page title, form labels, status badges, renewal warnings, error messages). Pack parity test stays green. Test infrastructure - tests/conftest.py autouse fixture sets DATATOOLS_DEV_MODE=1 so the existing 1916 tests continue to pass. - isolated_license_path / activated_license_manager / unactivated_license_manager fixtures for tests that want to drive the real check. Tests (+79) - tests/test_license.py (40): schema, crypto roundtrip, blob encode/decode, tier→feature mapping, activation flow, name/email mismatch rejection, tamper detection, expiration, renewal, dev-mode bypass. - tests/test_license_cli.py (26): every license_cli command + subprocess tests confirming every tool CLI refuses to run without a license, --help always works, DEV_MODE bypasses. - tests/gui/test_activation.py (13): gate blocks without license, passes with trial, activation form submission unlocks the gate, sidebar status, renewal warning, i18n. Total: 1916 → 1995 tests. All pass under the strict warning filter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
182
src/license_cli.py
Normal file
182
src/license_cli.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""CLI for license management.
|
||||
|
||||
Five 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).
|
||||
|
||||
- ``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.
|
||||
|
||||
- ``trial --name NAME --email EMAIL [--years N]``
|
||||
Self-issue a trial license without a paid blob. Useful for
|
||||
evaluating the product or for support to repro a buyer's issue
|
||||
locally without needing a real key.
|
||||
|
||||
- ``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``.
|
||||
"""
|
||||
|
||||
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 <blob> "
|
||||
"--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 <blob>"
|
||||
)
|
||||
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 trial(
|
||||
name: str = typer.Option(..., "--name", "-n"),
|
||||
email: str = typer.Option(..., "--email", "-e"),
|
||||
years: int = typer.Option(1, "--years", help="Trial length (default: 1 year)."),
|
||||
) -> None:
|
||||
"""Self-issue a trial license without a paid blob."""
|
||||
mgr = get_manager()
|
||||
try:
|
||||
lic = mgr.issue_trial(name=name, email=email, years=years)
|
||||
except LicenseError as e:
|
||||
typer.echo(f"Trial issuance failed: {e}", err=True)
|
||||
raise typer.Exit(code=2)
|
||||
typer.echo(
|
||||
f"Trial issued. Key: {lic.license_key} · "
|
||||
f"Expires: {lic.expires_at[:10]} ({lic.days_remaining()} days)"
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
Reference in New Issue
Block a user