diff --git a/cli/manifest_integrity.php b/cli/manifest_integrity.php new file mode 100644 index 0000000..be93418 --- /dev/null +++ b/cli/manifest_integrity.php @@ -0,0 +1,564 @@ +#!/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/manifest_integrity.php + * VERSION: 09.26.00 + * BRIEF: Cross-check manifest API fields against repo contents across the org + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class ManifestIntegrityCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Cross-check manifest fields against repo contents across the org'); + $this->addArgument('--path', 'Single repo path (local mode)', ''); + $this->addArgument('--org', 'Gitea org (bulk mode)', 'MokoConsulting'); + $this->addArgument('--repo', 'Single repo name (remote mode)', ''); + $this->addArgument('--token', 'Gitea API token (or GITEA_TOKEN env)', ''); + $this->addArgument('--api-base', 'Gitea API base URL', 'https://git.mokoconsulting.tech/api/v1'); + $this->addArgument('--fix', 'Push fixes for detected drift', false); + $this->addArgument('--json', 'Output as JSON', false); + $this->addArgument('--quiet', 'Only show repos with issues', false); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $org = $this->getArgument('--org'); + $repoName = $this->getArgument('--repo'); + $token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: ''; + $apiBase = rtrim($this->getArgument('--api-base'), '/'); + $fixMode = (bool) $this->getArgument('--fix'); + $jsonMode = (bool) $this->getArgument('--json'); + $quiet = (bool) $this->getArgument('--quiet'); + + if ($token === '') { + $this->log('ERROR', 'API token required (use --token or GITEA_TOKEN env)'); + return 1; + } + + // ── Mode selection ────────────────────────────────────────── + if ($path !== '') { + // Local mode: detect from source + compare to API + return $this->checkLocal($path, $org, $repoName, $token, $apiBase, $fixMode, $jsonMode); + } + + if ($repoName !== '') { + // Single remote repo + return $this->checkRemoteRepo($org, $repoName, $token, $apiBase, $fixMode, $jsonMode); + } + + // Bulk mode: all repos in org + return $this->checkOrg($org, $token, $apiBase, $fixMode, $jsonMode, $quiet); + } + + // ===================================================================== + // Local mode — detect from source, compare to API + // ===================================================================== + + private function checkLocal(string $path, string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int + { + $root = realpath($path) ?: $path; + if (!is_dir($root)) { + $this->log('ERROR', "Path does not exist: {$path}"); + return 1; + } + + if ($repoName === '') { + $repoName = $this->detectRepoName($root); + } + + // Run manifest_detect logic + $detected = $this->runDetect($root, $repoName); + $current = $this->fetchManifest($apiBase, $org, $repoName, $token); + + if ($current === null) { + $this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}"); + return 1; + } + + $issues = $this->validate($current, $detected, $repoName); + + if ($json) { + echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + $this->printIssues($repoName, $issues); + } + + if ($fix && !empty($issues)) { + return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues); + } + + return empty($issues) ? 0 : 1; + } + + // ===================================================================== + // Remote single repo mode — fetch source files via API + // ===================================================================== + + private function checkRemoteRepo(string $org, string $repoName, string $token, string $apiBase, bool $fix, bool $json): int + { + $current = $this->fetchManifest($apiBase, $org, $repoName, $token); + if ($current === null) { + $this->log('ERROR', "Failed to fetch manifest for {$org}/{$repoName}"); + return 1; + } + + $issues = $this->validateManifestOnly($current, $repoName); + + if ($json) { + echo json_encode(['repo' => $repoName, 'issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + $this->printIssues($repoName, $issues); + } + + if ($fix && !empty($issues)) { + return $this->applyFixes($apiBase, $org, $repoName, $token, $current, $issues); + } + + return empty($issues) ? 0 : 1; + } + + // ===================================================================== + // Bulk org mode — check all repos + // ===================================================================== + + private function checkOrg(string $org, string $token, string $apiBase, bool $fix, bool $json, bool $quiet): int + { + $repos = $this->fetchOrgRepos($apiBase, $org, $token); + if ($repos === null) { + $this->log('ERROR', "Failed to fetch repos for org {$org}"); + return 1; + } + + $this->log('INFO', "Manifest Integrity Check — {$org} (" . count($repos) . " repos)"); + + $allResults = []; + $totalIssues = 0; + $reposWithIssues = 0; + + foreach ($repos as $repo) { + $name = $repo['name']; + $manifest = $this->fetchManifest($apiBase, $org, $name, $token); + + if ($manifest === null) { + if (!$quiet) { + $this->log('WARN', "{$name}: no manifest"); + } + continue; + } + + $issues = $this->validateManifestOnly($manifest, $name); + + if (!empty($issues)) { + $reposWithIssues++; + $totalIssues += count($issues); + + if ($json) { + $allResults[] = ['repo' => $name, 'issues' => $issues]; + } else { + $this->printIssues($name, $issues); + } + + if ($fix) { + $this->applyFixes($apiBase, $org, $name, $token, $manifest, $issues); + } + } elseif (!$quiet && !$json) { + $this->log('OK', "{$name}: clean"); + } + } + + if ($json) { + echo json_encode($allResults, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + echo "\n"; + $level = $reposWithIssues > 0 ? 'WARN' : 'OK'; + $this->log($level, sprintf( + 'Summary: %d repos checked, %d with issues (%d total issues)', + count($repos), + $reposWithIssues, + $totalIssues + )); + } + + return $reposWithIssues > 0 ? 1 : 0; + } + + // ===================================================================== + // Validation rules + // ===================================================================== + + /** + * Full validation: compare API manifest against locally-detected fields. + */ + private function validate(array $current, array $detected, string $repoName): array + { + $issues = []; + + // Required fields that should never be empty + $required = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point']; + foreach ($required as $field) { + if (empty($current[$field])) { + $fix = $detected[$field] ?? null; + $issues[] = [ + 'field' => $field, + 'severity' => 'error', + 'message' => 'Missing required field', + 'current' => '', + 'fix' => $fix, + ]; + } + } + + // Drift detection: detected value differs from API + foreach ($detected as $field => $detectedValue) { + $currentValue = $current[$field] ?? ''; + if ($detectedValue !== '' && $currentValue !== '' && $detectedValue !== $currentValue) { + // Version drift is expected on dev branches (suffix) + if ($field === 'version' && strpos($detectedValue, $currentValue) === 0) { + continue; // e.g., detected "02.34.50-dev" vs API "02.34.50" + } + if ($field === 'version' && strpos($currentValue, $detectedValue) === 0) { + continue; + } + + $issues[] = [ + 'field' => $field, + 'severity' => 'warn', + 'message' => 'Drift: source differs from manifest', + 'current' => $currentValue, + 'fix' => $detectedValue, + ]; + } + } + + // Platform-specific structure validation + $platform = $current['platform'] ?? ''; + $issues = array_merge($issues, $this->validatePlatformStructure($platform, $current, $repoName)); + + return $issues; + } + + /** + * API-only validation: check manifest fields for completeness and consistency + * without access to source files. + */ + private function validateManifestOnly(array $manifest, string $repoName): array + { + $issues = []; + + // Required fields + $required = ['platform', 'name', 'version', 'language']; + foreach ($required as $field) { + if (empty($manifest[$field])) { + $issues[] = [ + 'field' => $field, + 'severity' => 'error', + 'message' => 'Missing required field', + 'current' => '', + 'fix' => null, + ]; + } + } + + // Recommended fields + $recommended = ['package_type', 'entry_point', 'license_spdx', 'description']; + foreach ($recommended as $field) { + if (empty($manifest[$field])) { + $issues[] = [ + 'field' => $field, + 'severity' => 'info', + 'message' => 'Recommended field is empty', + 'current' => '', + 'fix' => null, + ]; + } + } + + // Platform-specific checks + $platform = $manifest['platform'] ?? ''; + $issues = array_merge($issues, $this->validatePlatformStructure($platform, $manifest, $repoName)); + + return $issues; + } + + /** + * Platform-specific validation rules. + */ + private function validatePlatformStructure(string $platform, array $manifest, string $repoName): array + { + $issues = []; + + switch ($platform) { + case 'joomla': + case 'waas-component': + // Joomla repos must have element_name + if (empty($manifest['element_name'])) { + $issues[] = [ + 'field' => 'element_name', + 'severity' => 'error', + 'message' => 'Joomla repos require element_name', + 'current' => '', + 'fix' => null, + ]; + } + // Language should be PHP + if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') { + $issues[] = [ + 'field' => 'language', + 'severity' => 'warn', + 'message' => 'Joomla repos should have language=PHP', + 'current' => $manifest['language'], + 'fix' => 'PHP', + ]; + } + break; + + case 'dolibarr': + case 'crm-module': + if (!empty($manifest['language']) && $manifest['language'] !== 'PHP') { + $issues[] = [ + 'field' => 'language', + 'severity' => 'warn', + 'message' => 'Dolibarr repos should have language=PHP', + 'current' => $manifest['language'], + 'fix' => 'PHP', + ]; + } + break; + + case 'go': + if (!empty($manifest['language']) && $manifest['language'] !== 'Go') { + $issues[] = [ + 'field' => 'language', + 'severity' => 'warn', + 'message' => 'Go repos should have language=Go', + 'current' => $manifest['language'], + 'fix' => 'Go', + ]; + } + break; + + case 'mcp': + if (!empty($manifest['language']) && !in_array($manifest['language'], ['TypeScript', 'JavaScript'], true)) { + $issues[] = [ + 'field' => 'language', + 'severity' => 'warn', + 'message' => 'MCP repos should have language=TypeScript or JavaScript', + 'current' => $manifest['language'], + 'fix' => null, + ]; + } + break; + } + + // Version format check: should be XX.YY.ZZ + $version = $manifest['version'] ?? ''; + if ($version !== '' && !preg_match('/^\d{2}\.\d{2}\.\d{2}/', $version)) { + // Allow semver for node/go repos + if (!in_array($platform, ['mcp', 'node', 'go'], true)) { + $issues[] = [ + 'field' => 'version', + 'severity' => 'info', + 'message' => 'Version does not match XX.YY.ZZ format', + 'current' => $version, + 'fix' => null, + ]; + } + } + + return $issues; + } + + // ===================================================================== + // Output + // ===================================================================== + + private function printIssues(string $repoName, array $issues): void + { + if (empty($issues)) { + return; + } + + $errors = count(array_filter($issues, fn($i) => $i['severity'] === 'error')); + $warns = count(array_filter($issues, fn($i) => $i['severity'] === 'warn')); + $infos = count($issues) - $errors - $warns; + + echo "\n"; + $summary = []; + if ($errors > 0) $summary[] = "{$errors} error(s)"; + if ($warns > 0) $summary[] = "{$warns} warning(s)"; + if ($infos > 0) $summary[] = "{$infos} info"; + $this->log($errors > 0 ? 'ERROR' : 'WARN', "{$repoName} — " . implode(', ', $summary)); + + foreach ($issues as $issue) { + $icon = match ($issue['severity']) { + 'error' => 'ERROR', + 'warn' => 'WARN', + default => 'INFO', + }; + $msg = sprintf(' %-18s %s', $issue['field'], $issue['message']); + if ($issue['current'] !== '') { + $msg .= " (current: {$issue['current']})"; + } + if ($issue['fix'] !== null) { + $msg .= " → fix: {$issue['fix']}"; + } + $this->log($icon, $msg); + } + } + + // ===================================================================== + // Fix application + // ===================================================================== + + private function applyFixes(string $apiBase, string $org, string $repo, string $token, array $current, array $issues): int + { + $fixes = []; + foreach ($issues as $issue) { + if ($issue['fix'] !== null && $issue['fix'] !== '') { + $fixes[$issue['field']] = $issue['fix']; + } + } + + if (empty($fixes)) { + $this->log('INFO', "{$repo}: no auto-fixable issues"); + return 0; + } + + $merged = array_merge($current, $fixes); + $url = "{$apiBase}/repos/{$org}/{$repo}/manifest"; + $payload = json_encode($merged); + + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'header' => "Authorization: token {$token}\r\nContent-Type: application/json\r\nAccept: application/json\r\n", + 'content' => $payload, + 'timeout' => 10, + ], + ]); + + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { + $this->log('ERROR', "{$repo}: failed to push fixes"); + return 1; + } + + $this->log('OK', "{$repo}: fixed " . implode(', ', array_keys($fixes))); + return 0; + } + + // ===================================================================== + // API helpers + // ===================================================================== + + private function fetchManifest(string $apiBase, string $org, string $repo, string $token): ?array + { + $url = "{$apiBase}/repos/{$org}/{$repo}/manifest"; + $ctx = stream_context_create([ + 'http' => [ + 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n", + 'timeout' => 10, + ], + ]); + + $body = @file_get_contents($url, false, $ctx); + if ($body === false) return null; + + $data = json_decode($body, true); + return is_array($data) ? $data : null; + } + + private function fetchOrgRepos(string $apiBase, string $org, string $token): ?array + { + $allRepos = []; + $page = 1; + $limit = 50; + + while (true) { + $url = "{$apiBase}/orgs/{$org}/repos?page={$page}&limit={$limit}"; + $ctx = stream_context_create([ + 'http' => [ + 'header' => "Authorization: token {$token}\r\nAccept: application/json\r\n", + 'timeout' => 15, + ], + ]); + + $body = @file_get_contents($url, false, $ctx); + if ($body === false) return null; + + $repos = json_decode($body, true); + if (!is_array($repos) || empty($repos)) break; + + $allRepos = array_merge($allRepos, $repos); + + if (count($repos) < $limit) break; + $page++; + } + + // Filter out archived and empty repos + return array_filter($allRepos, fn($r) => !($r['archived'] ?? false) && !($r['empty'] ?? false)); + } + + // ===================================================================== + // Detection (delegates to manifest_detect logic) + // ===================================================================== + + private function runDetect(string $root, string $repoName): array + { + $script = __DIR__ . '/manifest_detect.php'; + $redirect = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null'; + $cmd = sprintf( + 'php %s --path %s --repo %s --json --quiet %s', + escapeshellarg($script), + escapeshellarg($root), + escapeshellarg($repoName), + $redirect + ); + + $output = shell_exec($cmd) ?? ''; + + // Extract JSON object from output (skip banner/log lines) + if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $output, $m)) { + $data = json_decode($m[0], true); + if (is_array($data)) { + return $data; + } + } + + 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); + } +} + +$app = new ManifestIntegrityCli(); +exit($app->execute());