#!/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_detect.php * VERSION: 09.26.01 * BRIEF: Auto-detect manifest fields from source files and optionally push to API */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\{CliFramework, SourceResolver}; class ManifestDetectCli extends CliFramework { protected function configure(): void { $this->setDescription('Auto-detect manifest fields from source files'); $this->addArgument('--path', 'Repository root path', '.'); $this->addArgument('--json', 'Output as JSON', false); $this->addArgument('--diff', 'Show diff against current manifest API values', false); $this->addArgument('--update', 'Push detected fields to manifest API', false); $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('--org', 'Gitea org', 'MokoConsulting'); $this->addArgument('--repo', 'Gitea repo name (auto-detected from remote if empty)', ''); $this->addArgument('--github-output', 'Append fields to $GITHUB_OUTPUT', false); } protected function run(): int { $path = $this->getArgument('--path'); $jsonMode = (bool) $this->getArgument('--json'); $diffMode = (bool) $this->getArgument('--diff'); $updateMode = (bool) $this->getArgument('--update'); $ghOutput = (bool) $this->getArgument('--github-output'); $token = $this->getArgument('--token') ?: getenv('GITEA_TOKEN') ?: ''; $apiBase = rtrim($this->getArgument('--api-base'), '/'); $org = $this->getArgument('--org'); $repoName = $this->getArgument('--repo'); $root = realpath($path) ?: $path; if (!is_dir($root)) { $this->log('ERROR', "Path does not exist: {$path}"); return 1; } // Auto-detect repo name from git remote if ($repoName === '') { $repoName = $this->detectRepoName($root); } // ── Detect all fields ─────────────────────────────────────── $detected = $this->detectAll($root, $repoName); // ── Warn about missing fields ──────────────────────────────── $expected = ['platform', 'name', 'version', 'package_type', 'language', 'entry_point']; foreach ($expected as $field) { if (!isset($detected[$field]) || $detected[$field] === '') { $this->log('WARN', "Could not detect: {$field}"); } } // ── Output ────────────────────────────────────────────────── if ($diffMode || $updateMode) { if ($token === '') { $this->log('ERROR', 'API token required for --diff/--update (use --token or GITEA_TOKEN env)'); return 1; } if ($repoName === '') { $this->log('ERROR', 'Could not determine repo name (use --repo)'); return 1; } $current = $this->fetchManifest($apiBase, $org, $repoName, $token); if ($current === null) { $this->log('ERROR', 'Failed to fetch current manifest from API'); return 1; } $changes = $this->computeDiff($current, $detected); if ($diffMode) { if (empty($changes)) { $this->log('INFO', 'No differences — manifest matches source'); } else { $this->sectionHeader('Manifest Drift'); foreach ($changes as $field => $info) { $this->log('WARN', sprintf( '%-20s API: %-30s Detected: %s', $field, $info['current'] === '' ? '(empty)' : $info['current'], $info['detected'] )); } } } if ($updateMode) { if (empty($changes)) { $this->log('INFO', 'Nothing to update'); } else { $update = array_map(fn($i) => $i['detected'], $changes); $ok = $this->pushManifest($apiBase, $org, $repoName, $token, $current, $update); if ($ok) { $this->log('OK', 'Updated ' . count($update) . ' field(s): ' . implode(', ', array_keys($update))); } else { $this->log('ERROR', 'Failed to push manifest update'); return 1; } } } return 0; } if ($ghOutput) { $outputFile = getenv('GITHUB_OUTPUT'); $lines = []; foreach ($detected as $k => $v) { $envKey = str_replace('-', '_', $k); $lines[] = "{$envKey}={$v}"; } if ($outputFile !== false && $outputFile !== '') { file_put_contents($outputFile, implode("\n", $lines) . "\n", FILE_APPEND); $this->log('INFO', 'Wrote ' . count($detected) . ' fields to GITHUB_OUTPUT'); } else { $this->log('WARN', 'GITHUB_OUTPUT not set — printing to stdout instead'); echo implode("\n", $lines) . "\n"; } return 0; } if ($jsonMode) { echo json_encode($detected, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; } else { foreach ($detected as $k => $v) { echo "{$k}={$v}\n"; } } return 0; } // ===================================================================== // Detection engine // ===================================================================== private function detectAll(string $root, string $repoName): array { $platform = $this->detectPlatform($root); $fields = [ 'platform' => $platform, 'name' => '', 'description' => '', 'version' => '', 'element_name' => '', 'package_type' => '', 'language' => '', 'entry_point' => '', 'license_spdx' => '', 'display_name' => '', 'target_version' => '', 'php_minimum' => '', ]; switch ($platform) { case 'joomla': $this->detectJoomla($root, $repoName, $fields); break; case 'dolibarr': $this->detectDolibarr($root, $repoName, $fields); break; case 'go': $this->detectGo($root, $repoName, $fields); break; case 'mcp': $this->detectNode($root, $repoName, $fields); break; case 'node': $this->detectNode($root, $repoName, $fields); $fields['platform'] = 'node'; break; default: $this->detectGeneric($root, $repoName, $fields); break; } // Fallbacks if ($fields['name'] === '') { $fields['name'] = $repoName ?: basename($root); } if ($fields['entry_point'] === '') { $fields['entry_point'] = $this->detectEntryPoint($root); } if ($fields['license_spdx'] === '') { $fields['license_spdx'] = $this->detectLicense($root); } // description: only from platform-specific source, never guessed // Strip empty values return array_filter($fields, fn($v) => $v !== ''); } // ── Platform detection ────────────────────────────────────────── private function detectPlatform(string $root): string { // Joomla: look for pkg_*.xml or extension XML in source dirs $joomlaXmls = array_merge( SourceResolver::globSource($root, 'pkg_*.xml'), glob("{$root}/pkg_*.xml") ?: [] ); if (!empty($joomlaXmls)) { return 'joomla'; } // Check source dirs for any Joomla extension XML foreach (SourceResolver::globSource($root, '*.xml') as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, 'findJoomlaManifest($root); if ($extManifest === null) { return; } $xml = file_get_contents($extManifest); // Type $extType = ''; if (preg_match('/type="([^"]*)"/', $xml, $m)) { $extType = $m[1]; } $fields['package_type'] = $extType; // Element name $element = ''; if (preg_match('/([^<]+)<\/element>/', $xml, $m)) { $element = $m[1]; } if ($element === '' && preg_match('/module="([^"]*)"/', $xml, $m)) { $element = $m[1]; } if ($element === '' && preg_match('/plugin="([^"]*)"/', $xml, $m)) { $element = $m[1]; } if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $m)) { $element = $m[1]; } if ($element === '') { $element = strtolower(basename($extManifest, '.xml')); } // Ensure element has type prefix (API stores full element_name like pkg_mokosuite) $prefixMap = [ 'package' => 'pkg_', 'component' => 'com_', 'module' => 'mod_', 'template' => 'tpl_', 'library' => 'lib_', 'file' => 'file_', ]; if (isset($prefixMap[$extType])) { $prefix = $prefixMap[$extType]; // Only add prefix if not already present (check all known prefixes) $hasPrefix = false; foreach ($prefixMap as $p) { if (strpos($element, $p) === 0) { $hasPrefix = true; break; } } if (strpos($element, 'plg_') === 0) { $hasPrefix = true; } if (!$hasPrefix) { $element = $prefix . $element; } } elseif ($extType === 'plugin') { $folder = ''; if (preg_match('/group="([^"]*)"/', $xml, $gm)) { $folder = $gm[1]; } if ($folder !== '' && strpos($element, 'plg_') !== 0) { $element = "plg_{$folder}_" . $element; } } $fields['element_name'] = $element; // Name if (preg_match('/([^<]+)<\/name>/', $xml, $m)) { $fields['name'] = trim($m[1]); } // Version if (preg_match('/([^<]+)<\/version>/', $xml, $m)) { $fields['version'] = trim($m[1]); } // Description if (preg_match('/([^<]+)<\/description>/', $xml, $m)) { $desc = trim($m[1]); // Skip language string keys like COM_MOKOSUITE_DESCRIPTION if (strpos($desc, '_') === false || strlen($desc) > 60) { $fields['description'] = $desc; } } // Display name for update feeds if (!empty($fields['name'])) { $name = $fields['name']; // If name already has "Type - " prefix, use as-is if (preg_match('/^(Package|Component|Module|Plugin|Template|Library)\s*-\s*/i', $name)) { $fields['display_name'] = $name; } elseif (!empty($extType)) { $fields['display_name'] = ucfirst($extType) . ' - ' . $name; } } // Target Joomla version if (preg_match('/]*version="([^"]+)"/', $xml, $m)) { $fields['target_version'] = trim($m[1]); } else { // Default for Joomla 5/6 $fields['target_version'] = '(5|6)\..*'; } // PHP minimum if (preg_match('/([^<]+)<\/php_minimum>/', $xml, $m)) { $fields['php_minimum'] = trim($m[1]); } // License if (preg_match('/([^<]+)<\/license>/', $xml, $m)) { $fields['license_spdx'] = $this->normalizeLicense(trim($m[1])); } } private function findJoomlaManifest(string $root): ?string { // Priority: pkg_*.xml (package manifest) $pkgXmls = array_merge( SourceResolver::globSource($root, 'pkg_*.xml'), glob("{$root}/pkg_*.xml") ?: [] ); if (!empty($pkgXmls)) { return $pkgXmls[0]; } // Any extension XML in source dir foreach (SourceResolver::globSource($root, '*.xml') as $file) { $content = file_get_contents($file); if (strpos($content, 'findDolibarrModule($root); if ($modFile === null) { return; } $content = file_get_contents($modFile); // Element name from class file $modBasename = basename($modFile, '.class.php'); $fields['element_name'] = strtolower(preg_replace('/^mod/', '', $modBasename)); // Name if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) { $fields['name'] = $m[1]; } // Version if (preg_match('/\$this->version\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) { $fields['version'] = $m[1]; } // Description if (preg_match('/\$this->description\s*=\s*[\'"]([^\'"]+)[\'"]/', $content, $m)) { $desc = $m[1]; if (strpos($desc, '$') === false) { $fields['description'] = $desc; } } // License if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) { $fields['license_spdx'] = $m[1]; } } private function findDolibarrModule(string $root): ?string { $candidates = array_merge( SourceResolver::globSource($root, 'core/modules/mod*.class.php'), glob("{$root}/core/modules/mod*.class.php") ?: [] ); foreach ($candidates as $file) { if (strpos(file_get_contents($file), 'DolibarrModules') !== false) { return $file; } } return null; } // ── Go ────────────────────────────────────────────────────────── private function detectGo(string $root, string $repoName, array &$fields): void { $fields['language'] = 'Go'; $fields['package_type'] = 'application'; $fields['entry_point'] = './'; $goMod = "{$root}/go.mod"; if (!file_exists($goMod)) { return; } $content = file_get_contents($goMod); // Module path → name if (preg_match('/^module\s+(\S+)/m', $content, $m)) { $modulePath = $m[1]; $parts = explode('/', $modulePath); $fields['name'] = end($parts); } // Go version if (preg_match('/^go\s+(\S+)/m', $content, $m)) { // This is Go language version, not the project version // Project version comes from git tags or source files } // License $fields['license_spdx'] = $this->detectLicense($root); } // ── Node / MCP ────────────────────────────────────────────────── private function detectNode(string $root, string $repoName, array &$fields): void { $pkgFile = "{$root}/package.json"; if (!file_exists($pkgFile)) { return; } $pkg = json_decode(file_get_contents($pkgFile), true) ?? []; $fields['name'] = $pkg['name'] ?? ''; // Strip npm scope if (strpos($fields['name'], '/') !== false) { $fields['name'] = explode('/', $fields['name'])[1]; } $fields['version'] = $pkg['version'] ?? ''; $fields['description'] = $pkg['description'] ?? ''; $fields['license_spdx'] = $pkg['license'] ?? ''; // Language detection if (file_exists("{$root}/tsconfig.json")) { $fields['language'] = 'TypeScript'; } else { $fields['language'] = 'JavaScript'; } // Package type $deps = array_merge( array_keys($pkg['dependencies'] ?? []), array_keys($pkg['devDependencies'] ?? []) ); $isMcp = false; foreach ($deps as $dep) { if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') { $isMcp = true; break; } } $fields['package_type'] = $isMcp ? 'mcp-server' : 'application'; // Entry point if (file_exists("{$root}/dist")) { $fields['entry_point'] = 'dist/'; } elseif (file_exists("{$root}/src")) { $fields['entry_point'] = 'src/'; } else { $fields['entry_point'] = './'; } } // ── Generic ───────────────────────────────────────────────────── private function detectGeneric(string $root, string $repoName, array &$fields): void { $fields['package_type'] = 'generic'; // Try to detect language from file extensions $fields['language'] = $this->detectLanguageFromFiles($root); $fields['license_spdx'] = $this->detectLicense($root); } // ===================================================================== // Shared detection helpers // ===================================================================== private function detectEntryPoint(string $root): string { $abs = SourceResolver::resolveAbsolute($root); if ($abs !== null) { return basename($abs) . '/'; } if (is_dir("{$root}/dist")) return 'dist/'; if (is_dir("{$root}/src")) return 'src/'; return './'; } private function detectLicense(string $root): string { // Check LICENSE file foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $name) { $file = "{$root}/{$name}"; if (!file_exists($file)) continue; $content = file_get_contents($file); // SPDX header if (preg_match('/SPDX-License-Identifier:\s*(\S+)/', $content, $m)) { return $m[1]; } // Common license patterns if (strpos($content, 'GNU GENERAL PUBLIC LICENSE') !== false) { if (strpos($content, 'Version 3') !== false) return 'GPL-3.0-or-later'; if (strpos($content, 'Version 2') !== false) return 'GPL-2.0-or-later'; } if (strpos($content, 'MIT License') !== false) return 'MIT'; if (strpos($content, 'Apache License') !== false && strpos($content, 'Version 2.0') !== false) return 'Apache-2.0'; } return ''; } private function detectLanguageFromFiles(string $root): string { $counts = ['PHP' => 0, 'Go' => 0, 'TypeScript' => 0, 'JavaScript' => 0, 'Python' => 0, 'Shell' => 0]; $extensions = [ 'php' => 'PHP', 'go' => 'Go', 'ts' => 'TypeScript', 'js' => 'JavaScript', 'py' => 'Python', 'sh' => 'Shell', ]; // Quick scan: only check top two levels foreach (glob("{$root}/*") ?: [] as $item) { $ext = pathinfo($item, PATHINFO_EXTENSION); if (isset($extensions[$ext])) { $counts[$extensions[$ext]]++; } if (is_dir($item) && basename($item)[0] !== '.') { foreach (glob("{$item}/*") ?: [] as $subItem) { $ext = pathinfo($subItem, PATHINFO_EXTENSION); if (isset($extensions[$ext])) { $counts[$extensions[$ext]]++; } } } } arsort($counts); $top = key($counts); return $counts[$top] > 0 ? $top : ''; } private function normalizeLicense(string $license): string { $lower = strtolower($license); $isGpl = strpos($lower, 'gpl') !== false || strpos($lower, 'general public license') !== false; if ($isGpl && strpos($lower, '3') !== false) return 'GPL-3.0-or-later'; if ($isGpl && strpos($lower, '2') !== false) return 'GPL-2.0-or-later'; if ($lower === 'mit' || strpos($lower, 'mit license') !== false) return 'MIT'; if (strpos($lower, 'apache') !== false) return 'Apache-2.0'; return $license; } 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); } // ===================================================================== // API interaction // ===================================================================== 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; return json_decode($body, true); } private function computeDiff(array $current, array $detected): array { // Map detected keys to API keys (underscores match) $changes = []; foreach ($detected as $key => $value) { $apiKey = $key; $currentVal = $current[$apiKey] ?? ''; // Only flag as changed if detected value is non-empty and differs if ($value !== '' && $value !== $currentVal) { // Don't overwrite a non-empty API value with a detected value // unless the API value is actually empty if ($currentVal === '' || $this->shouldOverride($key, $currentVal, $value)) { $changes[$key] = [ 'current' => $currentVal, 'detected' => $value, ]; } } } return $changes; } private function shouldOverride(string $field, string $current, string $detected): bool { // Version: detected from source is authoritative if ($field === 'version') return true; // These fields: source files are authoritative if (in_array($field, ['element_name', 'package_type', 'language', 'entry_point'], true)) { return true; } // For other fields, only fill empty — don't overwrite manual edits return false; } private function pushManifest(string $apiBase, string $org, string $repo, string $token, array $current, array $update): bool { $merged = array_merge($current, $update); $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); return $body !== false; } } $app = new ManifestDetectCli(); exit($app->execute());