#!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: mokoplatform.CLI * INGROUP: mokoplatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform * PATH: /cli/joomla_metadata_validate.php * VERSION: 09.33.00 * BRIEF: Validate MokoGitea repo metadata against Joomla extension manifest XML */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoCli\CliFramework; class JoomlaMetadataValidateCli extends CliFramework { /** Joomla element prefix map — must match MokoGitea's cleanJoomlaElement() */ private const JOOMLA_PREFIX = [ 'package' => 'pkg_', 'component' => 'com_', 'module' => 'mod_', 'template' => 'tpl_', 'library' => 'lib_', 'file' => 'file_', ]; protected function configure(): void { $this->setDescription('Validate MokoGitea repo metadata against Joomla extension manifest XML'); $this->addArgument('--path', 'Repo root path (default: current directory)', '.'); $this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', ''); $this->addArgument('--org', 'Gitea org', 'MokoConsulting'); $this->addArgument('--repo', 'Repo name (auto-detected from git if empty)', ''); $this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1'); $this->addArgument('--ci', 'CI mode: exit 1 on any error', false); $this->addArgument('--json', 'Output as JSON', false); } protected function run(): int { $path = realpath($this->getArgument('--path')) ?: $this->getArgument('--path'); $token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: ''; $org = $this->getArgument('--org'); $repoName = $this->getArgument('--repo'); $apiBase = rtrim($this->getArgument('--api-base'), '/'); $ciMode = (bool) $this->getArgument('--ci'); $jsonMode = (bool) $this->getArgument('--json'); if (!is_dir($path)) { $this->log('ERROR', "Path does not exist: {$path}"); return 1; } if ($repoName === '') { $repoName = $this->detectRepoName($path); } // ── Step 1: Find the Joomla extension manifest XML ────────── $joomlaXml = $this->findJoomlaManifest($path); if ($joomlaXml === null) { $this->log('ERROR', 'No Joomla extension manifest XML found'); return 1; } $this->log('INFO', "Joomla manifest: {$joomlaXml['path']}"); // ── Step 2: Load MokoGitea metadata ───────────────────────── $metadata = $this->loadMetadata($path, $org, $repoName, $token, $apiBase); if ($metadata === null) { $this->log('ERROR', 'Could not load MokoGitea metadata'); return 1; } // ── Step 3: Compare ───────────────────────────────────────── $results = $this->compare($metadata, $joomlaXml, $path); // ── Step 4: Output ────────────────────────────────────────── if ($jsonMode) { echo json_encode([ 'repo' => $repoName, 'results' => $results, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; } else { $this->printResults($repoName, $results); } $errors = count(array_filter($results, fn($r) => $r['status'] === 'error')); return ($ciMode && $errors > 0) ? 1 : 0; } // ================================================================= // Find Joomla manifest XML // ================================================================= private function findJoomlaManifest(string $root): ?array { // Search common locations for a Joomla extension manifest $candidates = []; // Package manifest: source/pkg_*.xml foreach (glob("{$root}/source/pkg_*.xml") as $file) { $candidates[] = $file; } // Component manifest: source/packages/com_*/[name].xml foreach (glob("{$root}/source/packages/com_*/*.xml") as $file) { $basename = basename($file); // Skip access.xml, config.xml, etc. if (in_array($basename, ['access.xml', 'config.xml'], true)) { continue; } $candidates[] = $file; } // Direct source/*.xml foreach (glob("{$root}/source/*.xml") as $file) { if (basename($file) !== 'pkg_mokosuitebackup.xml') { // Already caught above } $candidates[] = $file; } // src/ fallback foreach (glob("{$root}/src/pkg_*.xml") as $file) { $candidates[] = $file; } // Find the first one that has foreach (array_unique($candidates) as $file) { $content = file_get_contents($file); if ($content === false) { continue; } if (preg_match('/]*type=["\']([^"\']+)["\']/', $content, $typeMatch)) { $xml = @simplexml_load_string($content); if ($xml === false) { $relPath = str_replace($root . '/', '', $file); $relPath = str_replace($root . '\\', '', $relPath); $this->log('WARN', "Skipping {$relPath}: malformed XML"); continue; } $type = strtolower($typeMatch[1]); $relPath = str_replace($root . '/', '', $file); $relPath = str_replace($root . '\\', '', $relPath); return [ 'path' => $relPath, 'type' => $type, 'xml' => $xml, ]; } } return null; } // ================================================================= // Load metadata (from API) // ================================================================= private function loadMetadata(string $root, string $org, string $repoName, string $token, string $apiBase): ?array { if ($token === '') { $this->log('ERROR', 'No API token provided (use --token or set GITEA_TOKEN env var)'); return null; } $url = "{$apiBase}/repos/{$org}/{$repoName}/metadata"; $ctx = stream_context_create([ 'http' => [ 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n", 'timeout' => 10, 'ignore_errors' => true, ], ]); $body = file_get_contents($url, false, $ctx); // Extract HTTP status from response headers $httpCode = 0; if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) { $httpCode = (int) $m[0]; } if ($body === false) { $this->log('ERROR', "Failed to connect to {$url} — check network or TLS configuration"); return null; } if ($httpCode === 404) { $this->log('ERROR', "API endpoint not found: {$url}"); $this->log('ERROR', 'Server may need MokoGitea-Fork >= #650 (metadata endpoint rename)'); return null; } if ($httpCode === 401 || $httpCode === 403) { $this->log('ERROR', "Authentication failed (HTTP {$httpCode}) — check your API token"); return null; } if ($httpCode >= 400) { $this->log('ERROR', "API returned HTTP {$httpCode}: " . substr($body, 0, 200)); return null; } $data = json_decode($body, true); if (!is_array($data)) { $this->log('ERROR', "API returned invalid JSON from {$url}"); return null; } $data['source'] = 'api'; return $data; } // ================================================================= // Compare metadata against Joomla manifest // ================================================================= private function compare(array $metadata, array $joomlaXml, string $root): array { $results = []; $xml = $joomlaXml['xml']; $type = $joomlaXml['type']; // 1. Extension type $metaType = $this->normalizeExtensionType( $metadata['extension_type'] ?? $metadata['package_type'] ?? '' ); $results[] = [ 'field' => 'extension_type', 'metadata' => $metaType, 'joomla' => $type, 'status' => ($metaType === $type) ? 'ok' : 'error', 'message' => ($metaType === $type) ? "matches " : "metadata has \"{$metaType}\" but Joomla manifest has \"{$type}\"", ]; // 2. Element name $metaName = strtolower($metadata['name'] ?? ''); $metaElement = $this->deriveElement($metaType, $metaName); $joomlaElement = $this->extractJoomlaElement($xml, $type); $elementMatch = ($metaElement === $joomlaElement); $results[] = [ 'field' => 'element', 'metadata' => $metaElement, 'joomla' => $joomlaElement, 'status' => $elementMatch ? 'ok' : 'error', 'message' => $elementMatch ? "derived correctly" : "metadata derives \"{$metaElement}\" but Joomla uses \"{$joomlaElement}\"", ]; // 3. Version $metaVersion = $metadata['version'] ?? ''; $joomlaVersion = (string) ($xml->version ?? ''); if ($metaVersion !== '' && $joomlaVersion !== '') { // Strip dev/rc suffixes for comparison (CI bumps these) $metaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $metaVersion); $joomlaBase = preg_replace('/-(dev|rc|alpha|beta)\d*$/', '', $joomlaVersion); $versionMatch = ($metaBase === $joomlaBase); $results[] = [ 'field' => 'version', 'metadata' => $metaVersion, 'joomla' => $joomlaVersion, 'status' => $versionMatch ? 'ok' : 'warn', 'message' => $versionMatch ? 'matches (base version)' : "metadata has \"{$metaVersion}\" but Joomla has \"{$joomlaVersion}\"", ]; } // 4. PHP minimum (from composer.json) $composerPhp = $this->readComposerPhpRequirement($root); $metaPhp = $metadata['php_minimum'] ?? ''; if ($composerPhp !== '' && $metaPhp !== '') { $phpMatch = ($metaPhp === $composerPhp); $results[] = [ 'field' => 'php_minimum', 'metadata' => $metaPhp, 'joomla' => $composerPhp . ' (composer.json)', 'status' => $phpMatch ? 'ok' : 'warn', 'message' => $phpMatch ? 'matches composer.json' : "metadata has \"{$metaPhp}\" but composer.json requires \"{$composerPhp}\"", ]; } // 5. Description $metaDesc = $metadata['description'] ?? ''; $joomlaDesc = (string) ($xml->description ?? ''); // Joomla descriptions are often language keys, skip those if ($metaDesc !== '' && $joomlaDesc !== '' && !str_starts_with($joomlaDesc, 'COM_') && !str_starts_with($joomlaDesc, 'PKG_')) { $descMatch = ($metaDesc === $joomlaDesc); $results[] = [ 'field' => 'description', 'metadata' => substr($metaDesc, 0, 60) . (strlen($metaDesc) > 60 ? '...' : ''), 'joomla' => substr($joomlaDesc, 0, 60) . (strlen($joomlaDesc) > 60 ? '...' : ''), 'status' => $descMatch ? 'ok' : 'info', 'message' => $descMatch ? 'matches' : 'descriptions differ (informational)', ]; } return $results; } // ================================================================= // Helpers // ================================================================= /** * Normalize extension_type — map MokoGitea types to Joomla types. */ private function normalizeExtensionType(string $type): string { return match (strtolower($type)) { 'joomla-extension' => 'package', // legacy mapping default => strtolower($type), }; } /** * Derive the Joomla element name from type + name. * Replicates MokoGitea's cleanJoomlaElement() + prefix logic. */ private function deriveElement(string $type, string $name): string { // Clean: lowercase, strip non-alphanumeric except . _ - $clean = strtolower($name); $clean = preg_replace('/[^a-z0-9._-]/', '', $clean); $prefix = self::JOOMLA_PREFIX[$type] ?? ''; return $prefix . $clean; } /** * Extract the element name from a Joomla manifest XML. * Follows the same logic as Joomla's InstallerAdapter::getElement(). */ private function extractJoomlaElement(\SimpleXMLElement $xml, string $type): string { switch ($type) { case 'package': $packagename = (string) ($xml->packagename ?? ''); if ($packagename !== '') { return 'pkg_' . strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $packagename)); } break; case 'component': $element = (string) ($xml->element ?? ''); if ($element !== '') { $element = strtolower($element); return str_starts_with($element, 'com_') ? $element : 'com_' . $element; } $name = (string) ($xml->name ?? ''); $name = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name)); return str_starts_with($name, 'com_') ? $name : 'com_' . $name; case 'module': $element = (string) ($xml->element ?? ''); if ($element !== '') { return strtolower($element); } break; case 'plugin': // Plugins derive element from the file attribute if (isset($xml->files)) { foreach ($xml->files->children() as $file) { $plugin = (string) ($file->attributes()->plugin ?? ''); if ($plugin !== '') { return strtolower($plugin); } } } break; case 'library': $libname = (string) ($xml->libraryname ?? ''); if ($libname !== '') { return strtolower($libname); } break; } // Fallback: use tag $name = (string) ($xml->name ?? ''); return strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', $name)); } /** * Read PHP version requirement from composer.json. */ private function readComposerPhpRequirement(string $root): string { $composerFile = "{$root}/composer.json"; if (!is_file($composerFile)) { return ''; } $data = json_decode(file_get_contents($composerFile), true); if (!is_array($data)) { return ''; } $phpReq = $data['require']['php'] ?? ''; // Extract version number from constraint like ">=8.1" if (preg_match('/(\d+\.\d+)/', $phpReq, $m)) { return $m[1]; } return ''; } private function detectRepoName(string $root): string { $gitConfig = "{$root}/.git/config"; if (!file_exists($gitConfig)) { return basename($root); } $content = file_get_contents($gitConfig); if (preg_match('/url\s*=\s*.*\/([^\/\s]+?)(?:\.git)?\s*$/m', $content, $m)) { return $m[1]; } return basename($root); } // ================================================================= // Output // ================================================================= private function printResults(string $repoName, array $results): void { $errors = count(array_filter($results, fn($r) => $r['status'] === 'error')); $warns = count(array_filter($results, fn($r) => $r['status'] === 'warn')); $oks = count(array_filter($results, fn($r) => $r['status'] === 'ok')); $this->log('INFO', "Validating {$repoName} Joomla metadata...\n"); foreach ($results as $r) { $icon = match ($r['status']) { 'ok' => "\xE2\x9C\x93", // ✓ 'error' => "\xE2\x9C\x97", // ✗ 'warn' => "\xE2\x9A\xA0", // ⚠ default => "\xE2\x84\xB9", // ℹ }; $line = sprintf( " %s %-16s %s", $icon, $r['field'], $r['message'] ); $this->log( match ($r['status']) { 'error' => 'ERROR', 'warn' => 'WARN', 'ok' => 'OK', default => 'INFO', }, $line ); } echo "\n"; if ($errors > 0) { $this->log('ERROR', "{$errors} error(s) — update delivery will fail"); } elseif ($warns > 0) { $this->log('WARN', "All critical checks passed, {$warns} warning(s)"); } else { $this->log('OK', "All {$oks} checks passed"); } } } $app = new JoomlaMetadataValidateCli(); exit($app->execute());