Files
mokoplatform/cli/metadata_detect.php
2026-06-11 23:25:41 +00:00

750 lines
27 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: 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, '<extension') !== false) {
return 'joomla';
}
}
// Dolibarr: mod*.class.php with DolibarrModules
$modFiles = array_merge(
SourceResolver::globSource($root, 'core/modules/mod*.class.php'),
glob("{$root}/core/modules/mod*.class.php") ?: []
);
foreach ($modFiles as $file) {
if (strpos(file_get_contents($file), 'DolibarrModules') !== false) {
return 'dolibarr';
}
}
// Go
if (file_exists("{$root}/go.mod")) {
return 'go';
}
// MCP: package.json with mcp-related content
if (file_exists("{$root}/package.json")) {
$pkg = json_decode(file_get_contents("{$root}/package.json"), true) ?? [];
$deps = array_merge(
array_keys($pkg['dependencies'] ?? []),
array_keys($pkg['devDependencies'] ?? [])
);
foreach ($deps as $dep) {
if (strpos($dep, '@modelcontextprotocol/') === 0 || $dep === '@anthropic/mcp-sdk') {
return 'mcp';
}
}
return 'node';
}
// Python
if (file_exists("{$root}/pyproject.toml") || file_exists("{$root}/setup.py")) {
return 'python';
}
return 'generic';
}
// ── Joomla ──────────────────────────────────────────────────────
private function detectJoomla(string $root, string $repoName, array &$fields): void
{
$fields['language'] = 'PHP';
// Find the primary extension manifest XML
$extManifest = $this->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>([^<]+)<\/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>([^<]+)<\/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>([^<]+)<\/name>/', $xml, $m)) {
$fields['name'] = trim($m[1]);
}
// Version
if (preg_match('/<version>([^<]+)<\/version>/', $xml, $m)) {
$fields['version'] = trim($m[1]);
}
// Description
if (preg_match('/<description>([^<]+)<\/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('/<targetplatform\s[^>]*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>([^<]+)<\/php_minimum>/', $xml, $m)) {
$fields['php_minimum'] = trim($m[1]);
}
// License
if (preg_match('/<license>([^<]+)<\/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, '<extension') !== false) {
return $file;
}
}
// Root level
foreach (glob("{$root}/*.xml") ?: [] as $file) {
$content = file_get_contents($file);
if (strpos($content, '<extension') !== false) {
return $file;
}
}
return null;
}
// ── Dolibarr ────────────────────────────────────────────────────
private function detectDolibarr(string $root, string $repoName, array &$fields): void
{
$fields['language'] = 'PHP';
$fields['package_type'] = 'dolibarr-module';
$modFile = $this->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());