#!/usr/bin/env php * * 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 <<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());