Two coupled changes:
1. Lite tier
- New Tier.LITE in src/license/schema.py.
- FEATURES_BY_TIER[Tier.LITE] = {Deduplicator, Text Cleaner,
Format Standardizer}. The three universally-useful tools that
cover the most common bookkeeping / RevOps / Klaviyo prep
workflows. Other six tools require Core.
- i18n: license.tier_lite, license.feature_locked_title,
license.feature_locked_body, license.upgrade_link,
license.status_locked (en + es).
- Per-tool feature gate at every GUI tool page
(require_feature_or_render_upgrade) and every tool CLI
(guard(feature=...)). A locked tool renders an upgrade
prompt + Manage-license button (GUI) or exits with code 2
(CLI).
- Home grid: tool cards the user's tier doesn't unlock get a
red 🔒 Locked badge in place of green Ready.
2. Trial removed
- Activation form's "Start 1-year trial" button removed.
- license_cli's `trial` subcommand removed.
- activation.trial_button / activation.trial_help i18n keys
dropped (pack parity test stays green).
- Tier.TRIAL stays in the enum (back-compat with any field-
tested trial licenses); LicenseManager._mint stays internal
for tests and the seller's key generator.
- Decision logged in DECISIONS §9b: a 1-year all-features
trial undercuts paid Lite; paid-only keeps tier economics
clean.
Tests (+29 net): +17 Lite-tier unit/guard tests + 13 Lite-tier
GUI tests + 1 trial-absent assertion - 2 trial CLI tests - 1
trial GUI button test. Total: 1995 → 2024.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
4.9 KiB
Python
164 lines
4.9 KiB
Python
"""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 <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 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()
|