Files
moko-platform/cli/license_manage.php
T
Jonathan Miller 66e728b078
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 18s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 27s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 28s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m7s
style: fix PHPCS violations across migrated CLI scripts
Auto-fixed 5006 tab-indent and line-ending errors via phpcbf, then
manually broke 100 lines exceeding 150-char limit. All 74 files in
cli/, automation/, maintenance/, deploy/ now pass PHPCS PSR-12 clean.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 13:36:05 -05:00

687 lines
24 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: moko-platform.CLI
* INGROUP: moko-platform
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/license_manage.php
* BRIEF: Manage license packages and keys via MokoGitea licensing API
*
* Usage:
* php bin/moko license:list --org MokoConsulting
* php bin/moko license:create-package --org MokoConsulting --name "Pro Annual" --duration 365 --max-sites 5
* php bin/moko license:issue --org MokoConsulting --package-id 1 --licensee "Client Inc" --email client@example.com
* php bin/moko license:revoke --org MokoConsulting --key-id 42
* php bin/moko license:renew --org MokoConsulting --key-id 42 --days 365
* php bin/moko license:validate --key MOKO-ABCD-1234-EF56-7890 --domain example.com
* php bin/moko license:usage --org MokoConsulting --key-id 42
* php bin/moko license:master-key --org MokoConsulting
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
use MokoEnterprise\CliFramework;
class LicenseManage extends CliFramework
{
private string $apiBase = '';
private string $token = '';
private string $subcommand = '';
protected function configure(): void
{
$this->setDescription('Manage license packages and keys via MokoGitea licensing API');
$this->addArgument('--org', 'Organization name', '');
$this->addArgument('--api-base', 'Gitea API base URL', '');
$this->addArgument('--token', 'API token (or set GH_TOKEN env)', '');
// Package args
$this->addArgument('--name', 'Package name (for create-package)', '');
$this->addArgument('--description', 'Package description', '');
$this->addArgument('--duration', 'Duration in days (0 = lifetime)', '0');
$this->addArgument('--max-sites', 'Max sites per key (0 = unlimited)', '0');
$this->addArgument('--repo-scope', 'Repo scope: all or comma-separated repo IDs', 'all');
$this->addArgument('--channels', 'Allowed channels: JSON array or comma-separated', '');
// Key args
$this->addArgument('--package-id', 'License package ID', '');
$this->addArgument('--key-id', 'License key ID', '');
$this->addArgument('--key', 'Raw license key string (for validate)', '');
$this->addArgument('--licensee', 'Licensee name', '');
$this->addArgument('--email', 'Licensee email', '');
$this->addArgument('--domain', 'Domain restriction or validation domain', '');
$this->addArgument('--domains', 'Comma-separated allowed domains', '');
$this->addArgument('--payment-ref', 'Payment reference (idempotency key)', '');
$this->addArgument('--days', 'Days to extend (for renew)', '365');
$this->addArgument('--custom-key', 'Use a custom key string instead of auto-generated', '');
// Output
$this->addArgument('--json', 'Output as JSON', false);
}
protected function initialize(): void
{
// Resolve API base
$this->apiBase = $this->getArgument('--api-base')
?: getenv('GITEA_URL')
?: 'https://git.mokoconsulting.tech';
$this->apiBase = rtrim($this->apiBase, '/');
// Resolve token
$this->token = $this->getArgument('--token')
?: getenv('GH_TOKEN')
?: getenv('GITHUB_TOKEN')
?: '';
if (empty($this->token)) {
$ghToken = trim((string) @shell_exec('gh auth token 2>/dev/null'));
if (!empty($ghToken)) {
$this->token = $ghToken;
}
}
// Determine subcommand from argv
global $argv;
foreach ($argv as $arg) {
if (
in_array($arg, [
'list', 'create-package', 'update-package', 'delete-package',
'issue', 'revoke', 'activate', 'renew', 'validate',
'usage', 'master-key', 'keys', 'packages',
], true)
) {
$this->subcommand = $arg;
break;
}
}
}
protected function run(): int
{
if (empty($this->token)) {
$this->log('No API token found. Set GH_TOKEN or pass --token.', 'ERROR');
return 1;
}
return match ($this->subcommand) {
'packages', 'list' => $this->listPackages(),
'create-package' => $this->createPackage(),
'update-package' => $this->updatePackage(),
'delete-package' => $this->deletePackage(),
'keys' => $this->listKeys(),
'issue' => $this->issueKey(),
'revoke' => $this->revokeKey(),
'activate' => $this->activateKey(),
'renew' => $this->renewKey(),
'validate' => $this->validateKey(),
'usage' => $this->viewUsage(),
'master-key' => $this->ensureMasterKey(),
default => $this->showSubcommandHelp(),
};
}
// ── Subcommand help ──────────────────────────────────────────────────
private function showSubcommandHelp(): int
{
$this->section('License Management — Subcommands');
echo <<<HELP
Package Management:
packages List all license packages for an org
create-package Create a new license package
update-package Update a license package (--package-id required)
delete-package Delete a license package (--package-id required)
Key Management:
keys List all license keys for an org
issue Issue a new license key (--package-id required)
revoke Deactivate a license key (--key-id required)
activate Re-activate a revoked key (--key-id required)
renew Extend key expiration (--key-id, --days required)
validate Validate a raw key string (--key required)
master-key Ensure master key exists for org
Analytics:
usage View usage logs for a key (--key-id required)
Examples:
php bin/moko license packages --org MokoConsulting
php bin/moko license create-package --org MokoConsulting --name "Pro Annual" --duration 365
php bin/moko license issue --org MokoConsulting --package-id 1 --licensee "Client"
php bin/moko license validate --key MOKO-ABCD-1234-EF56-7890 --domain example.com
php bin/moko license renew --org MokoConsulting --key-id 42 --days 365
HELP;
return 0;
}
// ── Package operations ───────────────────────────────────────────────
private function listPackages(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$result = $this->apiGet("/orgs/{$org}/license-packages");
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("License Packages — {$org}");
if (empty($result)) {
$this->log('No packages found.', 'WARN');
return 0;
}
foreach ($result as $pkg) {
$duration = ($pkg['duration_days'] ?? 0) === 0 ? 'lifetime' : ($pkg['duration_days'] . ' days');
$sites = ($pkg['max_sites'] ?? 0) === 0 ? 'unlimited' : (string)$pkg['max_sites'];
$active = ($pkg['is_active'] ?? true) ? 'active' : 'inactive';
$this->status(
sprintf('#%d %s', $pkg['id'] ?? 0, $pkg['name'] ?? ''),
true,
sprintf('%s | %s sites | %s', $duration, $sites, $active)
);
}
return 0;
}
private function createPackage(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$name = $this->getArgument('--name');
if (empty($name)) {
$this->log('--name is required for create-package', 'ERROR');
return 1;
}
$channels = $this->getArgument('--channels');
if (!empty($channels) && $channels[0] !== '[') {
$channels = json_encode(explode(',', $channels));
}
$data = [
'name' => $name,
'description' => $this->getArgument('--description') ?: '',
'duration_days' => (int) $this->getArgument('--duration'),
'max_sites' => (int) $this->getArgument('--max-sites'),
'repo_scope' => $this->getArgument('--repo-scope'),
'allowed_channels' => $channels ?: '',
];
if ($this->isDryRun()) {
$this->log('Would create package: ' . json_encode($data, JSON_PRETTY_PRINT), 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-packages", $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$this->log(sprintf('Created package #%d: %s', $result['id'] ?? 0, $name), 'OK');
}
return 0;
}
private function updatePackage(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
$data = array_filter([
'name' => $this->getArgument('--name') ?: null,
'description' => $this->getArgument('--description') ?: null,
'duration_days' => $this->getArgument('--duration') !== '0' ? (int)$this->getArgument('--duration') : null,
'max_sites' => $this->getArgument('--max-sites') !== '0' ? (int)$this->getArgument('--max-sites') : null,
], fn($v) => $v !== null);
if (empty($data)) {
$this->log('No fields to update. Pass --name, --description, --duration, or --max-sites.', 'WARN');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would update package #{$pkgId}: " . json_encode($data), 'DRY-RUN');
return 0;
}
$result = $this->apiPatch("/orgs/{$org}/license-packages/{$pkgId}", $data);
if ($result === null) {
return 1;
}
$this->log("Updated package #{$pkgId}", 'OK');
return 0;
}
private function deletePackage(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would delete package #{$pkgId}", 'DRY-RUN');
return 0;
}
$result = $this->apiDelete("/orgs/{$org}/license-packages/{$pkgId}");
if ($result === null) {
return 1;
}
$this->log("Deleted package #{$pkgId}", 'OK');
return 0;
}
// ── Key operations ───────────────────────────────────────────────────
private function listKeys(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
$pkgId = $this->getArgument('--package-id');
$endpoint = $pkgId
? "/orgs/{$org}/license-packages/{$pkgId}/keys"
: "/orgs/{$org}/license-keys";
$result = $this->apiGet($endpoint);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("License Keys — {$org}" . ($pkgId ? " (Package #{$pkgId})" : ''));
if (empty($result)) {
$this->log('No keys found.', 'WARN');
return 0;
}
foreach ($result as $key) {
$prefix = $key['key_prefix'] ?? '???';
$licensee = $key['licensee_name'] ?? 'N/A';
$active = ($key['is_active'] ?? true) ? 'active' : 'revoked';
$internal = ($key['is_internal'] ?? false) ? ' [MASTER]' : '';
$domains = $key['domain_restriction'] ?? '';
$expires = ($key['expires_unix'] ?? 0) > 0
? date('Y-m-d', (int) $key['expires_unix'])
: 'never';
$this->status(
sprintf('#%d %s', $key['id'] ?? 0, $prefix),
$key['is_active'] ?? true,
sprintf('%s | %s | expires: %s | domains: %s%s', $licensee, $active, $expires, $domains ?: 'any', $internal)
);
}
return 0;
}
private function issueKey(): int
{
$org = $this->requireOrg();
$pkgId = $this->getArgument('--package-id');
if ($org === null || empty($pkgId)) {
$this->log('--org and --package-id are required', 'ERROR');
return 1;
}
$data = [
'package_id' => (int) $pkgId,
'licensee_name' => $this->getArgument('--licensee') ?: '',
'licensee_email' => $this->getArgument('--email') ?: '',
'domain_restriction' => $this->getArgument('--domains') ?: '',
'max_sites' => (int) $this->getArgument('--max-sites'),
'payment_ref' => $this->getArgument('--payment-ref') ?: '',
];
$customKey = $this->getArgument('--custom-key');
if (!empty($customKey)) {
$data['custom_key'] = $customKey;
}
if ($this->isDryRun()) {
$this->log('Would issue key: ' . json_encode($data, JSON_PRETTY_PRINT), 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys", $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
} else {
$rawKey = $result['raw_key'] ?? '';
$this->section('License Key Issued');
if (!empty($rawKey)) {
echo "\n";
$this->log("Raw Key: {$rawKey}", 'OK');
$this->log('This key will NOT be shown again. Save it now.', 'WARN');
echo "\n";
}
$this->log(sprintf('Key ID: #%d | Prefix: %s', $result['id'] ?? 0, $result['key_prefix'] ?? ''), 'INFO');
}
return 0;
}
private function revokeKey(): int
{
return $this->toggleKey(false);
}
private function activateKey(): int
{
return $this->toggleKey(true);
}
private function toggleKey(bool $activate): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
$action = $activate ? 'activate' : 'revoke';
if ($this->isDryRun()) {
$this->log("Would {$action} key #{$keyId}", 'DRY-RUN');
return 0;
}
$result = $this->apiPatch("/orgs/{$org}/license-keys/{$keyId}", [
'is_active' => $activate,
]);
if ($result === null) {
return 1;
}
$label = $activate ? 'Activated' : 'Revoked';
$this->log("{$label} key #{$keyId}", 'OK');
return 0;
}
private function renewKey(): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
$days = (int) $this->getArgument('--days');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
if ($this->isDryRun()) {
$this->log("Would renew key #{$keyId} by {$days} days", 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys/{$keyId}/renew", [
'days' => $days,
]);
if ($result === null) {
return 1;
}
$newExpiry = isset($result['expires_unix']) && $result['expires_unix'] > 0
? date('Y-m-d', (int) $result['expires_unix'])
: 'never';
$this->log("Renewed key #{$keyId} — new expiry: {$newExpiry}", 'OK');
return 0;
}
private function validateKey(): int
{
$rawKey = $this->getArgument('--key');
if (empty($rawKey)) {
$this->log('--key is required for validate', 'ERROR');
return 1;
}
$data = ['key' => $rawKey];
$domain = $this->getArgument('--domain');
if (!empty($domain)) {
$data['domain'] = $domain;
}
$result = $this->apiPost('/license-keys/validate', $data);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$valid = $result['valid'] ?? false;
if ($valid) {
$this->status('License Valid', true, sprintf(
'Package: %s | Expires: %s | Sites: %s',
$result['package_name'] ?? 'N/A',
isset($result['expires_unix']) && $result['expires_unix'] > 0
? date('Y-m-d', (int) $result['expires_unix']) : 'never',
$result['max_sites'] ?? 'unlimited'
));
return 0;
} else {
$this->status('License Invalid', false, $result['error'] ?? 'Unknown reason');
return 1;
}
}
private function viewUsage(): int
{
$org = $this->requireOrg();
$keyId = $this->getArgument('--key-id');
if ($org === null || empty($keyId)) {
$this->log('--org and --key-id are required', 'ERROR');
return 1;
}
$result = $this->apiGet("/orgs/{$org}/license-keys/{$keyId}/usage");
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$this->section("Usage — Key #{$keyId}");
$entries = $result['entries'] ?? $result;
if (empty($entries)) {
$this->log('No usage recorded.', 'WARN');
return 0;
}
foreach ($entries as $u) {
$date = isset($u['created_unix']) ? date('Y-m-d H:i', (int) $u['created_unix']) : 'N/A';
$domain = $u['domain'] ?? '';
$ip = $u['ip_address'] ?? '';
$from = $u['version_from'] ?? '';
$this->log(sprintf('%s | %s | %s | from %s', $date, $domain ?: 'no domain', $ip, $from ?: 'unknown'), 'INFO');
}
return 0;
}
private function ensureMasterKey(): int
{
$org = $this->requireOrg();
if ($org === null) {
return 1;
}
if ($this->isDryRun()) {
$this->log("Would ensure master key for {$org}", 'DRY-RUN');
return 0;
}
$result = $this->apiPost("/orgs/{$org}/license-keys/master", []);
if ($result === null) {
return 1;
}
if ($this->getArgument('--json')) {
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
return 0;
}
$rawKey = $result['raw_key'] ?? '';
if (!empty($rawKey)) {
$this->section('Master Key Created');
echo "\n";
$this->log("Raw Key: {$rawKey}", 'OK');
$this->log('This key will NOT be shown again. Save it now.', 'WARN');
echo "\n";
} else {
$this->log('Master key already exists.', 'INFO');
}
return 0;
}
// ── Helpers ──────────────────────────────────────────────────────────
private function requireOrg(): ?string
{
$org = $this->getArgument('--org');
if (empty($org)) {
// Try to detect from git remote
$remote = trim((string) @shell_exec('git remote get-url origin 2>/dev/null'));
if (preg_match('#[/:]([^/]+)/[^/]+?(?:\.git)?$#', $remote, $m)) {
$org = $m[1];
}
}
if (empty($org)) {
$this->log('--org is required (or must be detectable from git remote)', 'ERROR');
return null;
}
return $org;
}
private function apiGet(string $path): ?array
{
return $this->apiRequest('GET', $path);
}
private function apiPost(string $path, array $data): ?array
{
return $this->apiRequest('POST', $path, $data);
}
private function apiPatch(string $path, array $data): ?array
{
return $this->apiRequest('PATCH', $path, $data);
}
private function apiDelete(string $path): ?array
{
return $this->apiRequest('DELETE', $path);
}
private function apiRequest(string $method, string $path, ?array $data = null): ?array
{
$url = $this->apiBase . '/api/v1' . $path;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
'Authorization: token ' . $this->token,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_TIMEOUT => 30,
]);
if ($data !== null && in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
if ($this->getArgument('--verbose')) {
$this->log("{$method} {$url}", 'DEBUG');
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if (!empty($error)) {
$this->log("API error: {$error}", 'ERROR');
return null;
}
if ($httpCode === 404) {
$this->log("API endpoint not found: {$path}", 'ERROR');
$this->log('The licensing API may not be deployed yet. Check MokoGitea version.', 'WARN');
return null;
}
if ($httpCode === 204) {
return []; // success, no content
}
if ($httpCode >= 400) {
$body = json_decode((string) $response, true);
$msg = $body['message'] ?? $response;
$this->log("API error ({$httpCode}): {$msg}", 'ERROR');
return null;
}
$decoded = json_decode((string) $response, true);
if ($decoded === null && !empty($response)) {
$this->log('Failed to parse API response', 'ERROR');
return null;
}
return $decoded ?? [];
}
}
$app = new LicenseManage();
exit($app->execute());