diff --git a/automation/enrich_manifest_xml.php b/automation/enrich_manifest_xml.php new file mode 100644 index 0000000..0c0bece --- /dev/null +++ b/automation/enrich_manifest_xml.php @@ -0,0 +1,479 @@ +#!/usr/bin/env php + + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoPlatform.Automation + * INGROUP: MokoPlatform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /automation/enrich_manifest_xml.php + * BRIEF: Enrich XML manifests with repo-specific build and deploy details + * + * Note: This script uses proc_open for shell commands. All arguments are escaped + * via escapeshellarg(). No user-supplied input reaches the shell unescaped. + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; +use MokoEnterprise\MokoStandardsParser; + +class EnrichManifestXmlCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Enrich XML manifests with repo-specific build and deploy details'); + $this->addArgument('--repo', 'Filter to a single repo name', ''); + $this->addArgument('--skip', 'Comma-separated list of repos to skip', ''); + } + + protected function run(): int + { + $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); + $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; + $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; + + $repoFilter = $this->getArgument('--repo') ?: null; + $skipStr = $this->getArgument('--skip'); + $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : []; + + $parser = new MokoStandardsParser(); + $tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid(); + + echo "=== moko-platform XML Manifest Enrichment ===\n"; + echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n"; + if (!empty($skipRepos)) { + echo "Skipping: " . implode(', ', $skipRepos) . "\n"; + } + echo "\n"; + + if (empty($token)) { + $this->log('ERROR', 'GA_TOKEN required'); + return 1; + } + + $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token); + echo "Found " . count($repos) . " repositories\n\n"; + + $stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0]; + + foreach ($repos as $repo) { + $name = $repo['name']; + if ($repoFilter && $name !== $repoFilter) { + continue; + } + if (in_array($name, $skipRepos, true)) { + echo " {$name} ... SKIP (excluded)\n"; + $stats['skipped']++; + continue; + } + if ($repo['archived'] ?? false) { + $stats['skipped']++; + continue; + } + + $defaultBranch = $repo['default_branch'] ?? 'main'; + $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; + $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); + + echo " {$name} ... "; + + $workDir = "{$tmpBase}/{$name}"; + @mkdir($workDir, 0755, true); + [$ret] = $this->safeExec( + 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) + . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) + ); + if ($ret !== 0) { + echo "FAIL (clone)\n"; + $stats['failed']++; + continue; + } + + $manifestPath = "{$workDir}/.mokogitea/manifest.xml"; + if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), 'rmTree($workDir); + continue; + } + + $existingXml = file_get_contents($manifestPath); + $platform = $parser->extractPlatform($existingXml) ?? 'default-repository'; + $enrichment = $this->inspectRepo($workDir, $platform); + + if (!isset($enrichment['build'])) { + $enrichment['build'] = []; + } + $enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform); + $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform); + + $enrichedXml = $this->enrichManifestXml($existingXml, $enrichment); + $dc = count($enrichment['deploy'] ?? []); + $sc = count($enrichment['scripts'] ?? []); + $details = "deploy={$dc} scripts={$sc}"; + + if ($this->dryRun) { + echo "WOULD ENRICH [{$details}]\n"; + $stats['enriched']++; + $this->rmTree($workDir); + continue; + } + + file_put_contents($manifestPath, $enrichedXml); + $this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); + $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); + $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); + + [$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich manifest.xml with build/deploy/scripts\n\nAuto-detected: {$details}"); + if ($cr !== 0) { + echo "SKIP (no diff)\n"; + $stats['skipped']++; + $this->rmTree($workDir); + continue; + } + + [$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch); + if ($pr !== 0) { + echo "FAIL (push)\n"; + $stats['failed']++; + } else { + echo "ENRICHED [{$details}]\n"; + $stats['enriched']++; + } + + $this->rmTree($workDir); + } + + @rmdir($tmpBase); + echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n"; + + return 0; + } + + private function inspectRepo(string $workDir, string $platform): array + { + $enrichment = []; + $build = []; + + // Detect entry point + if (is_dir("{$workDir}/src")) { + foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) { + $c = file_get_contents($xf); + if (str_contains($c, ' $pd, 'version' => $composer['require'][$pd], 'type' => 'platform']; + } + } + if (isset($composer['require']['mokoconsulting-tech/enterprise'])) { + $deps[] = [ + 'name' => 'mokoconsulting-tech/enterprise', + 'version' => $composer['require']['mokoconsulting-tech/enterprise'], + 'type' => 'composer', + ]; + } + if (!empty($deps)) { + $build['dependencies'] = $deps; + } + } + + // Artifact from Makefile + if (file_exists("{$workDir}/Makefile")) { + $mk = file_get_contents("{$workDir}/Makefile"); + if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) { + $build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]]; + } + } + + if (!empty($build)) { + $enrichment['build'] = $build; + } + + // Deploy targets from workflows + $targets = []; + $wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows"; + if (is_dir($wfDir)) { + foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) { + $wf = "{$wfDir}/{$dn}.yml"; + if (!file_exists($wf)) { + continue; + } + $wc = file_get_contents($wf); + $t = ['name' => str_replace('deploy-', '', $dn)]; + if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) { + $t['method'] = 'sftp'; + } elseif (str_contains($wc, 'rsync')) { + $t['method'] = 'rsync'; + } + if (str_contains($wc, 'src/')) { + $t['src_dir'] = 'src/'; + } + if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) { + $t['branch'] = $m[1]; + } + $targets[] = $t; + } + } + if (!empty($targets)) { + $enrichment['deploy'] = $targets; + } + + // Scripts from Makefile + composer + $scripts = []; + if (file_exists("{$workDir}/Makefile")) { + $mk = file_get_contents("{$workDir}/Makefile"); + $known = [ + 'build' => 'build', 'test' => 'test', 'lint' => 'lint', + 'clean' => 'build', 'package' => 'build', + 'validate' => 'validate', 'release' => 'release', + ]; + if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) { + foreach ($matches[1] as $tgt) { + $tl = strtolower($tgt); + if (isset($known[$tl])) { + $scripts[] = [ + 'name' => $tl, 'phase' => $known[$tl], + 'command' => "make {$tgt}", + 'desc' => ucfirst($tl) . ' via make', + 'runner' => 'make', + ]; + } + } + } + } + if (file_exists("{$workDir}/composer.json")) { + $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; + $km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate']; + foreach ($composer['scripts'] ?? [] as $sn => $cmd) { + $sl = strtolower($sn); + foreach ($km as $match => $phase) { + if (str_contains($sl, $match)) { + $exists = false; + foreach ($scripts as $s) { + if ($s['name'] === $sl) { + $exists = true; + break; + } + } + if (!$exists) { + $scripts[] = [ + 'name' => $sn, 'phase' => $phase, + 'command' => "composer run {$sn}", + 'desc' => is_string($cmd) ? $cmd : "Run {$sn}", + 'runner' => 'composer', + ]; + } + break; + } + } + } + } + if (!empty($scripts)) { + $enrichment['scripts'] = $scripts; + } + + return $enrichment; + } + + private function enrichManifestXml(string $xml, array $enrichment): string + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->preserveWhiteSpace = false; + $dom->formatOutput = true; + if (!$dom->loadXML($xml)) { + return $xml; + } + + $ns = MokoStandardsParser::NAMESPACE_URI; + $root = $dom->documentElement; + + foreach (['build', 'deploy', 'scripts'] as $tag) { + $toRemove = []; + $existing = $root->getElementsByTagNameNS($ns, $tag); + for ($i = 0; $i < $existing->length; $i++) { + $toRemove[] = $existing->item($i); + } + foreach ($toRemove as $node) { + $root->removeChild($node); + } + } + + if (!empty($enrichment['build'])) { + $buildEl = $dom->createElementNS($ns, 'build'); + $b = $enrichment['build']; + foreach (['language', 'runtime'] as $f) { + if (isset($b[$f])) { + $buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1))); + } + } + if (isset($b['package_type'])) { + $buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1))); + } + if (isset($b['entry_point'])) { + $buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1))); + } + if (isset($b['artifact'])) { + $art = $dom->createElementNS($ns, 'artifact'); + foreach (['format','path','filename'] as $af) { + if (isset($b['artifact'][$af])) { + $art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); + } + } + $buildEl->appendChild($art); + } + if (isset($b['dependencies'])) { + $deps = $dom->createElementNS($ns, 'dependencies'); + foreach ($b['dependencies'] as $d) { + $req = $dom->createElementNS($ns, 'requires', ''); + $req->setAttribute('name', $d['name']); + if (isset($d['version'])) { + $req->setAttribute('version', $d['version']); + } + if (isset($d['type'])) { + $req->setAttribute('type', $d['type']); + } + $deps->appendChild($req); + } + $buildEl->appendChild($deps); + } + $root->appendChild($buildEl); + } + + if (!empty($enrichment['deploy'])) { + $deploy = $dom->createElementNS($ns, 'deploy'); + foreach ($enrichment['deploy'] as $t) { + $target = $dom->createElementNS($ns, 'target'); + $target->setAttribute('name', $t['name']); + $target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}')); + $target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}')); + if (isset($t['method'])) { + $target->appendChild($dom->createElementNS($ns, 'method', $t['method'])); + } + if (isset($t['branch'])) { + $target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1))); + } + if (isset($t['src_dir'])) { + $target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1))); + } + $deploy->appendChild($target); + } + $root->appendChild($deploy); + } + + if (!empty($enrichment['scripts'])) { + $scriptsEl = $dom->createElementNS($ns, 'scripts'); + foreach ($enrichment['scripts'] as $s) { + $script = $dom->createElementNS($ns, 'script'); + $script->setAttribute('name', $s['name']); + if (isset($s['phase'])) { + $script->setAttribute('phase', $s['phase']); + } + $script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1))); + if (isset($s['desc'])) { + $script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1))); + } + if (isset($s['runner'])) { + $script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1))); + } + $scriptsEl->appendChild($script); + } + $root->appendChild($scriptsEl); + } + + return $dom->saveXML(); + } + + /** @return array{int, string} */ + private function safeExec(string $command, string $cwd = '.'): array + { + $proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd); + if (!is_resource($proc)) { + return [1, "proc_open failed"]; + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [proc_close($proc), trim($stdout . "\n" . $stderr)]; + } + + private function rmTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); + $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @chmod($file->getPathname(), 0777); + @unlink($file->getPathname()); + } + } + @rmdir($dir); + } + + /** @return array{int, string} */ + private function gitCmd(string $workDir, string ...$args): array + { + $cmd = 'git'; + foreach ($args as $a) { + $cmd .= ' ' . escapeshellarg($a); + } + return $this->safeExec($cmd, $workDir); + } + + private function fetchRepos(string $url, string $org, string $token): array + { + $repos = []; + $page = 1; + do { + $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code !== 200) { + break; + } + $batch = json_decode($body, true); + if (empty($batch)) { + break; + } + $repos = array_merge($repos, $batch); + $page++; + } while (count($batch) >= 50); + return $repos; + } +} + +$app = new EnrichManifestXmlCli(); +exit($app->execute()); diff --git a/automation/enrich_mokostandards_xml.php b/automation/enrich_mokostandards_xml.php index dbe5aca..4b3c346 100644 --- a/automation/enrich_mokostandards_xml.php +++ b/automation/enrich_mokostandards_xml.php @@ -6,20 +6,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.Automation - * INGROUP: MokoStandards + * DEFGROUP: MokoPlatform.Automation + * INGROUP: MokoPlatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /automation/enrich_mokostandards_xml.php * BRIEF: Enrich XML manifests with repo-specific build and deploy details * - * Enrich XML .mokostandards manifests with repo-specific build, deploy, and script details. - * - * Runs AFTER push_mokostandards_xml.php. Clones each repo, inspects its contents, - * and updates the manifest with discovered build/deploy/scripts config. - * - * Usage: - * php automation/enrich_mokostandards_xml.php [--dry-run] [--repo NAME] [--skip NAME,NAME] - * * Note: This script uses proc_open for shell commands. All arguments are escaped * via escapeshellarg(). No user-supplied input reaches the shell unescaped. */ @@ -27,448 +19,456 @@ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; +use MokoEnterprise\CliFramework; use MokoEnterprise\MokoStandardsParser; -$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); -$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; -$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; - -$dryRun = in_array('--dry-run', $argv, true); -$repoFilter = null; -$skipRepos = []; -foreach ($argv as $i => $arg) { - if ($arg === '--repo' && isset($argv[$i + 1])) { - $repoFilter = $argv[$i + 1]; - } - if ($arg === '--skip' && isset($argv[$i + 1])) { - $skipRepos = array_map('trim', explode(',', $argv[$i + 1])); - } -} - -$parser = new MokoStandardsParser(); -$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid(); - -function safeExec(string $command, string $cwd = '.'): array +class EnrichMokostandardsXmlCli extends CliFramework { - $proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd); - if (!is_resource($proc)) { - return [1, "proc_open failed"]; - } - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - return [proc_close($proc), trim($stdout . "\n" . $stderr)]; -} - -function rmTree(string $dir): void -{ - if (!is_dir($dir)) { - return; - } - $it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); - $files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); - foreach ($files as $file) { - if ($file->isDir()) { - @rmdir($file->getPathname()); - } else { - @chmod($file->getPathname(), 0777); - @unlink($file->getPathname()); - } - } - @rmdir($dir); -} - -function gitCmd(string $workDir, string ...$args): array -{ - $cmd = 'git'; - foreach ($args as $a) { - $cmd .= ' ' . escapeshellarg($a); - } - return safeExec($cmd, $workDir); -} - -function fetchRepos(string $url, string $org, string $token): array -{ - $repos = []; - $page = 1; - do { - $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); - curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]); - $body = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($code !== 200) { - break; - } - $batch = json_decode($body, true); - if (empty($batch)) { - break; - } - $repos = array_merge($repos, $batch); - $page++; - } while (count($batch) >= 50); - return $repos; -} - -function inspectRepo(string $workDir, string $platform): array -{ - $enrichment = []; - $build = []; - - // Detect entry point - if (is_dir("{$workDir}/src")) { - foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) { - $c = file_get_contents($xf); - if (str_contains($c, 'setDescription('Enrich XML manifests with repo-specific build and deploy details'); + $this->addArgument('--repo', 'Filter to a single repo name', ''); + $this->addArgument('--skip', 'Comma-separated list of repos to skip', ''); } - // composer.json - if (file_exists("{$workDir}/composer.json")) { - $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; - $phpReq = $composer['require']['php'] ?? null; - if ($phpReq) { - $build['runtime'] = "php:{$phpReq}"; + protected function run(): int + { + $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); + $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; + $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; + + $repoFilter = $this->getArgument('--repo') ?: null; + $skipStr = $this->getArgument('--skip'); + $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : []; + + $parser = new MokoStandardsParser(); + $tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid(); + + echo "=== moko-platform XML Manifest Enrichment ===\n"; + echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n"; + if (!empty($skipRepos)) { + echo "Skipping: " . implode(', ', $skipRepos) . "\n"; + } + echo "\n"; + + if (empty($token)) { + $this->log('ERROR', 'GA_TOKEN required'); + return 1; } - $deps = []; - foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) { - if (isset($composer['require'][$pd])) { - $deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform']; - } - } - if (isset($composer['require']['mokoconsulting-tech/enterprise'])) { - $deps[] = [ - 'name' => 'mokoconsulting-tech/enterprise', - 'version' => $composer['require']['mokoconsulting-tech/enterprise'], - 'type' => 'composer', - ]; - } - if (!empty($deps)) { - $build['dependencies'] = $deps; - } - } + $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token); + echo "Found " . count($repos) . " repositories\n\n"; - // Artifact from Makefile - if (file_exists("{$workDir}/Makefile")) { - $mk = file_get_contents("{$workDir}/Makefile"); - if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) { - $build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]]; - } - } + $stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0]; - if (!empty($build)) { - $enrichment['build'] = $build; - } - - // Deploy targets from workflows - $targets = []; - $wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows"; - if (is_dir($wfDir)) { - foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) { - $wf = "{$wfDir}/{$dn}.yml"; - if (!file_exists($wf)) { + foreach ($repos as $repo) { + $name = $repo['name']; + if ($repoFilter && $name !== $repoFilter) { continue; } - $wc = file_get_contents($wf); - $t = ['name' => str_replace('deploy-', '', $dn)]; - if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) { - $t['method'] = 'sftp'; - } elseif (str_contains($wc, 'rsync')) { - $t['method'] = 'rsync'; + if (in_array($name, $skipRepos, true)) { + echo " {$name} ... SKIP (excluded)\n"; + $stats['skipped']++; + continue; } - if (str_contains($wc, 'src/')) { - $t['src_dir'] = 'src/'; + if ($repo['archived'] ?? false) { + $stats['skipped']++; + continue; } - if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) { - $t['branch'] = $m[1]; + + $defaultBranch = $repo['default_branch'] ?? 'main'; + $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; + $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); + + echo " {$name} ... "; + + $workDir = "{$tmpBase}/{$name}"; + @mkdir($workDir, 0755, true); + [$ret] = $this->safeExec( + 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) + . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) + ); + if ($ret !== 0) { + echo "FAIL (clone)\n"; + $stats['failed']++; + continue; } - $targets[] = $t; + + $manifestPath = "{$workDir}/.mokogitea/manifest.xml"; + if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), 'rmTree($workDir); + continue; + } + + $existingXml = file_get_contents($manifestPath); + $platform = $parser->extractPlatform($existingXml) ?? 'default-repository'; + $enrichment = $this->inspectRepo($workDir, $platform); + + if (!isset($enrichment['build'])) { + $enrichment['build'] = []; + } + $enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform); + $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform); + + $enrichedXml = $this->enrichManifestXml($existingXml, $enrichment); + $dc = count($enrichment['deploy'] ?? []); + $sc = count($enrichment['scripts'] ?? []); + $details = "deploy={$dc} scripts={$sc}"; + + if ($this->dryRun) { + echo "WOULD ENRICH [{$details}]\n"; + $stats['enriched']++; + $this->rmTree($workDir); + continue; + } + + file_put_contents($manifestPath, $enrichedXml); + $this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); + $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); + $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); + + [$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}"); + if ($cr !== 0) { + echo "SKIP (no diff)\n"; + $stats['skipped']++; + $this->rmTree($workDir); + continue; + } + + [$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch); + if ($pr !== 0) { + echo "FAIL (push)\n"; + $stats['failed']++; + } else { + echo "ENRICHED [{$details}]\n"; + $stats['enriched']++; + } + + $this->rmTree($workDir); } - } - if (!empty($targets)) { - $enrichment['deploy'] = $targets; + + @rmdir($tmpBase); + echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n"; + + return 0; } - // Scripts from Makefile + composer - $scripts = []; - if (file_exists("{$workDir}/Makefile")) { - $mk = file_get_contents("{$workDir}/Makefile"); - $known = [ - 'build' => 'build', 'test' => 'test', 'lint' => 'lint', - 'clean' => 'build', 'package' => 'build', - 'validate' => 'validate', 'release' => 'release', - ]; - if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) { - foreach ($matches[1] as $tgt) { - $tl = strtolower($tgt); - if (isset($known[$tl])) { - $scripts[] = [ - 'name' => $tl, 'phase' => $known[$tl], - 'command' => "make {$tgt}", - 'desc' => ucfirst($tl) . ' via make', - 'runner' => 'make', - ]; - } - } - } - } - if (file_exists("{$workDir}/composer.json")) { - $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; - $km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate']; - foreach ($composer['scripts'] ?? [] as $sn => $cmd) { - $sl = strtolower($sn); - foreach ($km as $match => $phase) { - if (str_contains($sl, $match)) { - $exists = false; - foreach ($scripts as $s) { - if ($s['name'] === $sl) { - $exists = true; - break; - } - } - if (!$exists) { - $scripts[] = [ - 'name' => $sn, 'phase' => $phase, - 'command' => "composer run {$sn}", - 'desc' => is_string($cmd) ? $cmd : "Run {$sn}", - 'runner' => 'composer', - ]; - } + private function inspectRepo(string $workDir, string $platform): array + { + $enrichment = []; + $build = []; + + if (is_dir("{$workDir}/src")) { + foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) { + $c = file_get_contents($xf); + if (str_contains($c, 'preserveWhiteSpace = false; - $dom->formatOutput = true; - if (!$dom->loadXML($xml)) { - return $xml; - } - - $ns = MokoStandardsParser::NAMESPACE_URI; - $root = $dom->documentElement; - - foreach (['build', 'deploy', 'scripts'] as $tag) { - $toRemove = []; - $existing = $root->getElementsByTagNameNS($ns, $tag); - for ($i = 0; $i < $existing->length; $i++) { - $toRemove[] = $existing->item($i); - } - foreach ($toRemove as $node) { - $root->removeChild($node); - } - } - - if (!empty($enrichment['build'])) { - $build = $dom->createElementNS($ns, 'build'); - $b = $enrichment['build']; - foreach (['language', 'runtime'] as $f) { - if (isset($b[$f])) { - $build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1))); + foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) { + $build['entry_point'] = str_replace("{$workDir}/", '', $mf); + break; } } - if (isset($b['package_type'])) { - $build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1))); - } - if (isset($b['entry_point'])) { - $build->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1))); - } - if (isset($b['artifact'])) { - $art = $dom->createElementNS($ns, 'artifact'); - foreach (['format','path','filename'] as $af) { - if (isset($b['artifact'][$af])) { - $art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); + + if (file_exists("{$workDir}/composer.json")) { + $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; + $phpReq = $composer['require']['php'] ?? null; + if ($phpReq) { + $build['runtime'] = "php:{$phpReq}"; + } + + $deps = []; + foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) { + if (isset($composer['require'][$pd])) { + $deps[] = ['name' => $pd, 'version' => $composer['require'][$pd], 'type' => 'platform']; } } - $build->appendChild($art); + if (isset($composer['require']['mokoconsulting-tech/enterprise'])) { + $deps[] = [ + 'name' => 'mokoconsulting-tech/enterprise', + 'version' => $composer['require']['mokoconsulting-tech/enterprise'], + 'type' => 'composer', + ]; + } + if (!empty($deps)) { + $build['dependencies'] = $deps; + } } - if (isset($b['dependencies'])) { - $deps = $dom->createElementNS($ns, 'dependencies'); - foreach ($b['dependencies'] as $d) { - $req = $dom->createElementNS($ns, 'requires', ''); - $req->setAttribute('name', $d['name']); - if (isset($d['version'])) { - $req->setAttribute('version', $d['version']); + + if (file_exists("{$workDir}/Makefile")) { + $mk = file_get_contents("{$workDir}/Makefile"); + if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) { + $build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]]; + } + } + + if (!empty($build)) { + $enrichment['build'] = $build; + } + + $targets = []; + $wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows"; + if (is_dir($wfDir)) { + foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) { + $wf = "{$wfDir}/{$dn}.yml"; + if (!file_exists($wf)) { + continue; } - if (isset($d['type'])) { - $req->setAttribute('type', $d['type']); + $wc = file_get_contents($wf); + $t = ['name' => str_replace('deploy-', '', $dn)]; + if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) { + $t['method'] = 'sftp'; + } elseif (str_contains($wc, 'rsync')) { + $t['method'] = 'rsync'; } - $deps->appendChild($req); + if (str_contains($wc, 'src/')) { + $t['src_dir'] = 'src/'; + } + if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) { + $t['branch'] = $m[1]; + } + $targets[] = $t; } - $build->appendChild($deps); } - $root->appendChild($build); - } - - if (!empty($enrichment['deploy'])) { - $deploy = $dom->createElementNS($ns, 'deploy'); - foreach ($enrichment['deploy'] as $t) { - $target = $dom->createElementNS($ns, 'target'); - $target->setAttribute('name', $t['name']); - $target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}')); - $target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}')); - if (isset($t['method'])) { - $target->appendChild($dom->createElementNS($ns, 'method', $t['method'])); - } - if (isset($t['branch'])) { - $target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1))); - } - if (isset($t['src_dir'])) { - $target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1))); - } - $deploy->appendChild($target); + if (!empty($targets)) { + $enrichment['deploy'] = $targets; } - $root->appendChild($deploy); - } - if (!empty($enrichment['scripts'])) { - $scriptsEl = $dom->createElementNS($ns, 'scripts'); - foreach ($enrichment['scripts'] as $s) { - $script = $dom->createElementNS($ns, 'script'); - $script->setAttribute('name', $s['name']); - if (isset($s['phase'])) { - $script->setAttribute('phase', $s['phase']); + $scripts = []; + if (file_exists("{$workDir}/Makefile")) { + $mk = file_get_contents("{$workDir}/Makefile"); + $known = [ + 'build' => 'build', 'test' => 'test', 'lint' => 'lint', + 'clean' => 'build', 'package' => 'build', + 'validate' => 'validate', 'release' => 'release', + ]; + if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) { + foreach ($matches[1] as $tgt) { + $tl = strtolower($tgt); + if (isset($known[$tl])) { + $scripts[] = [ + 'name' => $tl, 'phase' => $known[$tl], + 'command' => "make {$tgt}", + 'desc' => ucfirst($tl) . ' via make', + 'runner' => 'make', + ]; + } + } } - $script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1))); - if (isset($s['desc'])) { - $script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1))); - } - if (isset($s['runner'])) { - $script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1))); - } - $scriptsEl->appendChild($script); } - $root->appendChild($scriptsEl); + if (file_exists("{$workDir}/composer.json")) { + $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; + $km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate']; + foreach ($composer['scripts'] ?? [] as $sn => $cmd) { + $sl = strtolower($sn); + foreach ($km as $match => $phase) { + if (str_contains($sl, $match)) { + $exists = false; + foreach ($scripts as $s) { + if ($s['name'] === $sl) { + $exists = true; + break; + } + } + if (!$exists) { + $scripts[] = [ + 'name' => $sn, 'phase' => $phase, + 'command' => "composer run {$sn}", + 'desc' => is_string($cmd) ? $cmd : "Run {$sn}", + 'runner' => 'composer', + ]; + } + break; + } + } + } + } + if (!empty($scripts)) { + $enrichment['scripts'] = $scripts; + } + + return $enrichment; } - return $dom->saveXML(); + private function enrichManifestXml(string $xml, array $enrichment): string + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->preserveWhiteSpace = false; + $dom->formatOutput = true; + if (!$dom->loadXML($xml)) { + return $xml; + } + + $ns = MokoStandardsParser::NAMESPACE_URI; + $root = $dom->documentElement; + + foreach (['build', 'deploy', 'scripts'] as $tag) { + $toRemove = []; + $existing = $root->getElementsByTagNameNS($ns, $tag); + for ($i = 0; $i < $existing->length; $i++) { + $toRemove[] = $existing->item($i); + } + foreach ($toRemove as $node) { + $root->removeChild($node); + } + } + + if (!empty($enrichment['build'])) { + $buildEl = $dom->createElementNS($ns, 'build'); + $b = $enrichment['build']; + foreach (['language', 'runtime'] as $f) { + if (isset($b[$f])) { + $buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1))); + } + } + if (isset($b['package_type'])) { + $buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1))); + } + if (isset($b['entry_point'])) { + $buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1))); + } + if (isset($b['artifact'])) { + $art = $dom->createElementNS($ns, 'artifact'); + foreach (['format','path','filename'] as $af) { + if (isset($b['artifact'][$af])) { + $art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); + } + } + $buildEl->appendChild($art); + } + if (isset($b['dependencies'])) { + $deps = $dom->createElementNS($ns, 'dependencies'); + foreach ($b['dependencies'] as $d) { + $req = $dom->createElementNS($ns, 'requires', ''); + $req->setAttribute('name', $d['name']); + if (isset($d['version'])) { + $req->setAttribute('version', $d['version']); + } + if (isset($d['type'])) { + $req->setAttribute('type', $d['type']); + } + $deps->appendChild($req); + } + $buildEl->appendChild($deps); + } + $root->appendChild($buildEl); + } + + if (!empty($enrichment['deploy'])) { + $deploy = $dom->createElementNS($ns, 'deploy'); + foreach ($enrichment['deploy'] as $t) { + $target = $dom->createElementNS($ns, 'target'); + $target->setAttribute('name', $t['name']); + $target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}')); + $target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}')); + if (isset($t['method'])) { + $target->appendChild($dom->createElementNS($ns, 'method', $t['method'])); + } + if (isset($t['branch'])) { + $target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1))); + } + if (isset($t['src_dir'])) { + $target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1))); + } + $deploy->appendChild($target); + } + $root->appendChild($deploy); + } + + if (!empty($enrichment['scripts'])) { + $scriptsEl = $dom->createElementNS($ns, 'scripts'); + foreach ($enrichment['scripts'] as $s) { + $script = $dom->createElementNS($ns, 'script'); + $script->setAttribute('name', $s['name']); + if (isset($s['phase'])) { + $script->setAttribute('phase', $s['phase']); + } + $script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1))); + if (isset($s['desc'])) { + $script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1))); + } + if (isset($s['runner'])) { + $script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1))); + } + $scriptsEl->appendChild($script); + } + $root->appendChild($scriptsEl); + } + + return $dom->saveXML(); + } + + /** @return array{int, string} */ + private function safeExec(string $command, string $cwd = '.'): array + { + $proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd); + if (!is_resource($proc)) { + return [1, "proc_open failed"]; + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [proc_close($proc), trim($stdout . "\n" . $stderr)]; + } + + private function rmTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); + $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @chmod($file->getPathname(), 0777); + @unlink($file->getPathname()); + } + } + @rmdir($dir); + } + + /** @return array{int, string} */ + private function gitCmd(string $workDir, string ...$args): array + { + $cmd = 'git'; + foreach ($args as $a) { + $cmd .= ' ' . escapeshellarg($a); + } + return $this->safeExec($cmd, $workDir); + } + + private function fetchRepos(string $url, string $org, string $token): array + { + $repos = []; + $page = 1; + do { + $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code !== 200) { + break; + } + $batch = json_decode($body, true); + if (empty($batch)) { + break; + } + $repos = array_merge($repos, $batch); + $page++; + } while (count($batch) >= 50); + return $repos; + } } -// ── Main ───────────────────────────────────────────────────────────────── -echo "=== MokoStandards XML Manifest Enrichment ===\n"; -echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n"; -if (!empty($skipRepos)) { - echo "Skipping: " . implode(', ', $skipRepos) . "\n"; -} -echo "\n"; - -if (empty($token)) { - fprintf(STDERR, "ERROR: GA_TOKEN required\n"); - exit(1); -} - -$repos = fetchRepos($giteaUrl, $giteaOrg, $token); -echo "Found " . count($repos) . " repositories\n\n"; - -$stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0]; - -foreach ($repos as $repo) { - $name = $repo['name']; - if ($repoFilter && $name !== $repoFilter) { - continue; - } - if (in_array($name, $skipRepos, true)) { - echo " {$name} ... SKIP (excluded)\n"; - $stats['skipped']++; - continue; - } - if ($repo['archived'] ?? false) { - $stats['skipped']++; - continue; - } - - $defaultBranch = $repo['default_branch'] ?? 'main'; - $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; - $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); - - echo " {$name} ... "; - - $workDir = "{$tmpBase}/{$name}"; - @mkdir($workDir, 0755, true); - [$ret] = safeExec( - 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) - . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) - ); - if ($ret !== 0) { - echo "FAIL (clone)\n"; - $stats['failed']++; - continue; - } - - $manifestPath = "{$workDir}/.mokogitea/.mokostandards"; - if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), 'extractPlatform($existingXml) ?? 'default-repository'; - $enrichment = inspectRepo($workDir, $platform); - - if (!isset($enrichment['build'])) { - $enrichment['build'] = []; - } - $enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform); - $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform); - - $enrichedXml = enrichManifestXml($existingXml, $enrichment); - $dc = count($enrichment['deploy'] ?? []); - $sc = count($enrichment['scripts'] ?? []); - $details = "deploy={$dc} scripts={$sc}"; - - if ($dryRun) { - echo "WOULD ENRICH [{$details}]\n"; - $stats['enriched']++; - rmTree($workDir); - continue; - } - - file_put_contents($manifestPath, $enrichedXml); - gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); - gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); - gitCmd($workDir, 'add', '.mokogitea/.mokostandards'); - - [$cr, $co] = gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}"); - if ($cr !== 0) { - echo "SKIP (no diff)\n"; - $stats['skipped']++; - rmTree($workDir); - continue; - } - - [$pr] = gitCmd($workDir, 'push', 'origin', $defaultBranch); - if ($pr !== 0) { - echo "FAIL (push)\n"; - $stats['failed']++; - } else { - echo "ENRICHED [{$details}]\n"; - $stats['enriched']++; - } - - rmTree($workDir); -} - -@rmdir($tmpBase); -echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n"; +$app = new EnrichMokostandardsXmlCli(); +exit($app->execute()); diff --git a/automation/push_manifest_xml.php b/automation/push_manifest_xml.php new file mode 100644 index 0000000..988755a --- /dev/null +++ b/automation/push_manifest_xml.php @@ -0,0 +1,345 @@ +#!/usr/bin/env php + + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoPlatform.Automation + * INGROUP: MokoPlatform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /automation/push_manifest_xml.php + * BRIEF: Push XML manifests to all governed repositories + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; +use MokoEnterprise\MokoStandardsParser; + +class PushManifestXmlCli extends CliFramework +{ + private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods']; + + protected function configure(): void + { + $this->setDescription('Push XML manifest.xml to all governed repositories'); + $this->addArgument('--repo', 'Filter to a single repo name', ''); + $this->addArgument('--skip', 'Comma-separated list of repos to skip', ''); + $this->addArgument('--force', 'Force overwrite even if already XML', false); + } + + protected function run(): int + { + $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); + $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; + $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; + + $force = $this->getArgument('--force'); + $repoFilter = $this->getArgument('--repo') ?: null; + $skipStr = $this->getArgument('--skip'); + $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : []; + + $parser = new MokoStandardsParser(); + $tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid(); + + echo "=== moko-platform XML Manifest Push ===\n"; + echo "Org: {$giteaOrg}\n"; + echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n"; + if ($repoFilter) { + echo "Filter: {$repoFilter}\n"; + } + echo "\n"; + + if (empty($token)) { + $this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required'); + return 1; + } + + $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token); + echo "Found " . count($repos) . " repositories\n\n"; + + $stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0]; + + foreach ($repos as $repo) { + $name = $repo['name']; + if ($repoFilter && $name !== $repoFilter) { + continue; + } + if (in_array($name, $skipRepos, true)) { + echo " SKIP {$name} (excluded)\n"; + $stats['skipped']++; + continue; + } + if ($repo['archived'] ?? false) { + echo " SKIP {$name} (archived)\n"; + $stats['skipped']++; + continue; + } + + $platform = $this->detectPlatform($repo); + $defaultBranch = $repo['default_branch'] ?? 'main'; + $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; + $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); + + echo " {$name} [{$platform}] ... "; + + // Generate XML manifest + $xmlContent = $parser->generate([ + 'name' => $name, + 'org' => $giteaOrg, + 'platform' => $platform, + 'standards_version' => '04.07.00', + 'description' => $repo['description'] ?? '', + 'license' => 'GPL-3.0-or-later', + 'topics' => $repo['topics'] ?? [], + 'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform), + 'package_type' => MokoStandardsParser::platformPackageType($platform), + 'last_synced' => date('c'), + ]); + + if ($this->dryRun) { + echo "WOULD WRITE ({$platform})\n"; + $stats['created']++; + continue; + } + + // Clone shallow via HTTPS (token-authed) + $workDir = "{$tmpBase}/{$name}"; + @mkdir($workDir, 0755, true); + + [$ret, $out] = $this->safeExec( + 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' + . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) + ); + if ($ret !== 0) { + echo "FAIL (clone)\n"; + fprintf(STDERR, " %s\n", $out); + $stats['failed']++; + continue; + } + + // Check if already XML and up-to-date + $manifestPath = "{$workDir}/.mokogitea/manifest.xml"; + $existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), 'extractPlatform(file_get_contents($manifestPath)); + if ($existingPlatform === $platform) { + echo "SKIP (already XML)\n"; + $stats['skipped']++; + $this->rmTree($workDir); + continue; + } + } + + // Write manifest + @mkdir("{$workDir}/.gitea", 0755, true); + file_put_contents($manifestPath, $xmlContent); + + // Delete legacy files if present + $legacyDeleted = []; + foreach (['.mokostandards', '.github/.mokostandards', '.gitea/.mokostandards', '.mokogitea/.mokostandards'] as $legacy) { + $legacyPath = "{$workDir}/{$legacy}"; + if (file_exists($legacyPath)) { + unlink($legacyPath); + $legacyDeleted[] = $legacy; + } + } + + // Commit + $isNew = !$existingIsXml; + $commitMsg = $isNew + ? 'chore: add XML manifest.xml' + : 'chore: update manifest.xml'; + if (!empty($legacyDeleted)) { + $commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted); + } + + $this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); + $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); + $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); + foreach ($legacyDeleted as $lf) { + $this->gitCmd($workDir, 'add', $lf); + } + + [$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg); + if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) { + echo "SKIP (no changes)\n"; + $stats['skipped']++; + $this->rmTree($workDir); + continue; + } + if ($commitRet !== 0) { + echo "FAIL (commit)\n"; + fprintf(STDERR, " %s\n", $commitOut); + $stats['failed']++; + $this->rmTree($workDir); + continue; + } + + [$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch); + if ($pushRet !== 0) { + echo "FAIL (push)\n"; + fprintf(STDERR, " %s\n", $pushOut); + $stats['failed']++; + } else { + $action = $isNew ? 'CREATED' : 'UPDATED'; + echo "{$action}\n"; + $stats[$isNew ? 'created' : 'updated']++; + } + + // Cleanup + $this->rmTree($workDir); + } + + // Cleanup tmp base + @rmdir($tmpBase); + + echo "\n=== Summary ===\n"; + echo "Created: {$stats['created']}\n"; + echo "Updated: {$stats['updated']}\n"; + echo "Skipped: {$stats['skipped']}\n"; + echo "Failed: {$stats['failed']}\n"; + + return 0; + } + + private function detectPlatform(array $repo): string + { + $name = $repo['name'] ?? ''; + $nameLower = strtolower($name); + $description = strtolower($repo['description'] ?? ''); + $topics = $repo['topics'] ?? []; + + if (in_array($name, self::CRM_PLATFORM_REPOS, true)) { + return 'crm-platform'; + } + if (in_array('dolibarr-platform', $topics)) { + return 'crm-platform'; + } + if (in_array('joomla-template', $topics)) { + return 'joomla-template'; + } + if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { + return 'waas-component'; + } + if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { + return 'crm-module'; + } + + if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { + return 'joomla-template'; + } + if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { + return 'waas-component'; + } + if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { + return 'crm-module'; + } + + if (str_contains($description, 'joomla template')) { + return 'joomla-template'; + } + if (str_contains($description, 'joomla') || str_contains($description, 'component')) { + return 'waas-component'; + } + if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { + return 'crm-module'; + } + + if (str_contains($nameLower, 'standard')) { + return 'standards-repository'; + } + return 'default-repository'; + } + + /** + * @return array{int, string} + */ + private function safeExec(string $command, string $cwd = '.'): array + { + $proc = proc_open( + $command, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $cwd + ); + if (!is_resource($proc)) { + return [1, "proc_open failed for: {$command}"]; + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + return [$code, trim($stdout . "\n" . $stderr)]; + } + + private function rmTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); + $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @chmod($file->getPathname(), 0777); + @unlink($file->getPathname()); + } + } + @rmdir($dir); + } + + /** + * @return array{int, string} + */ + private function gitCmd(string $workDir, string ...$args): array + { + $cmd = 'git'; + foreach ($args as $a) { + $cmd .= ' ' . escapeshellarg($a); + } + return $this->safeExec($cmd, $workDir); + } + + private function fetchRepos(string $url, string $org, string $token): array + { + $repos = []; + $page = 1; + do { + $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code !== 200) { + $this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}"); + break; + } + + $batch = json_decode($body, true); + if (empty($batch)) { + break; + } + $repos = array_merge($repos, $batch); + $page++; + } while (count($batch) >= 50); + + return $repos; + } +} + +$app = new PushManifestXmlCli(); +exit($app->execute()); diff --git a/automation/push_mokostandards_xml.php b/automation/push_mokostandards_xml.php index 62427cb..09150b4 100644 --- a/automation/push_mokostandards_xml.php +++ b/automation/push_mokostandards_xml.php @@ -6,348 +6,340 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION - * DEFGROUP: MokoStandards.Automation - * INGROUP: MokoStandards + * DEFGROUP: MokoPlatform.Automation + * INGROUP: MokoPlatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /automation/push_mokostandards_xml.php * BRIEF: Push XML manifests to all governed repositories - * - * Push XML .mokostandards manifest to all governed repositories. - * - * Uses git SSH to bypass the Gitea reverse-proxy WAF that blocks - * API requests to paths containing ".mokogitea". - * - * Usage: - * php automation/push_mokostandards_xml.php [--dry-run] [--repo NAME] [--force] */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; +use MokoEnterprise\CliFramework; use MokoEnterprise\MokoStandardsParser; -// ── Configuration ──────────────────────────────────────────────────────── -$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); -$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; -$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; -$sshBase = 'ssh://gitea@git.mokoconsulting.tech:2222'; - -// ── CLI args ───────────────────────────────────────────────────────────── -$dryRun = in_array('--dry-run', $argv, true); -$force = in_array('--force', $argv, true); -$repoFilter = null; -$skipRepos = []; -foreach ($argv as $i => $arg) { - if ($arg === '--repo' && isset($argv[$i + 1])) { - $repoFilter = $argv[$i + 1]; - } - if ($arg === '--skip' && isset($argv[$i + 1])) { - $skipRepos = array_map('trim', explode(',', $argv[$i + 1])); - } -} - -$parser = new MokoStandardsParser(); -$tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid(); - -// ── Platform detection heuristics (mirrors RepositorySynchronizer) ─────── -$CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods']; - -function detectPlatform(array $repo): string +class PushMokostandardsXmlCli extends CliFramework { - global $CRM_PLATFORM_REPOS; - $name = $repo['name'] ?? ''; - $nameLower = strtolower($name); - $description = strtolower($repo['description'] ?? ''); - $topics = $repo['topics'] ?? []; + private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods']; - if (in_array($name, $CRM_PLATFORM_REPOS, true)) { - return 'crm-platform'; - } - if (in_array('dolibarr-platform', $topics)) { - return 'crm-platform'; - } - if (in_array('joomla-template', $topics)) { - return 'joomla-template'; - } - if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { - return 'waas-component'; - } - if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { - return 'crm-module'; + protected function configure(): void + { + $this->setDescription('Push XML manifests to all governed repositories'); + $this->addArgument('--repo', 'Filter to a single repo name', ''); + $this->addArgument('--skip', 'Comma-separated list of repos to skip', ''); + $this->addArgument('--force', 'Force overwrite even if already XML', false); } - if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { - return 'joomla-template'; - } - if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { - return 'waas-component'; - } - if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { - return 'crm-module'; - } + protected function run(): int + { + $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); + $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; + $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; - if (str_contains($description, 'joomla template')) { - return 'joomla-template'; - } - if (str_contains($description, 'joomla') || str_contains($description, 'component')) { - return 'waas-component'; - } - if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { - return 'crm-module'; - } + $force = $this->getArgument('--force'); + $repoFilter = $this->getArgument('--repo') ?: null; + $skipStr = $this->getArgument('--skip'); + $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : []; - if (str_contains($nameLower, 'standard')) { - return 'standards-repository'; - } - return 'default-repository'; -} + $parser = new MokoStandardsParser(); + $tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid(); -/** - * Safe shell execution — uses proc_open with explicit arguments to avoid injection. - * @return array{int, string} - */ -function safeExec(string $command, string $cwd = '.'): array -{ - $proc = proc_open( - $command, - [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - $cwd - ); - if (!is_resource($proc)) { - return [1, "proc_open failed for: {$command}"]; - } - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $code = proc_close($proc); - return [$code, trim($stdout . "\n" . $stderr)]; -} - -/** Recursively remove a directory (cross-platform). */ -function rmTree(string $dir): void -{ - if (!is_dir($dir)) { - return; - } - $it = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); - $files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); - foreach ($files as $file) { - if ($file->isDir()) { - @rmdir($file->getPathname()); - } else { - // Clear read-only flag (git objects on Windows) - @chmod($file->getPathname(), 0777); - @unlink($file->getPathname()); + echo "=== moko-platform XML Manifest Push ===\n"; + echo "Org: {$giteaOrg}\n"; + echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n"; + if ($repoFilter) { + echo "Filter: {$repoFilter}\n"; } - } - @rmdir($dir); -} + echo "\n"; -/** - * Run a git command safely in a given working directory. - * @return array{int, string} - */ -function gitCmd(string $workDir, string ...$args): array -{ - $cmd = 'git'; - foreach ($args as $a) { - $cmd .= ' ' . escapeshellarg($a); - } - return safeExec($cmd, $workDir); -} - -// ── Fetch all repos via API ────────────────────────────────────────────── -function fetchRepos(string $url, string $org, string $token): array -{ - $repos = []; - $page = 1; - do { - $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], - CURLOPT_TIMEOUT => 30, - ]); - $body = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($code !== 200) { - fprintf(STDERR, "API error (HTTP %d) fetching repos page %d\n", $code, $page); - break; + if (empty($token)) { + $this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required'); + return 1; } - $batch = json_decode($body, true); - if (empty($batch)) { - break; + $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token); + echo "Found " . count($repos) . " repositories\n\n"; + + $stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0]; + + foreach ($repos as $repo) { + $name = $repo['name']; + if ($repoFilter && $name !== $repoFilter) { + continue; + } + if (in_array($name, $skipRepos, true)) { + echo " SKIP {$name} (excluded)\n"; + $stats['skipped']++; + continue; + } + if ($repo['archived'] ?? false) { + echo " SKIP {$name} (archived)\n"; + $stats['skipped']++; + continue; + } + + $platform = $this->detectPlatform($repo); + $defaultBranch = $repo['default_branch'] ?? 'main'; + $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; + $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); + + echo " {$name} [{$platform}] ... "; + + // Generate XML manifest + $xmlContent = $parser->generate([ + 'name' => $name, + 'org' => $giteaOrg, + 'platform' => $platform, + 'standards_version' => '04.07.00', + 'description' => $repo['description'] ?? '', + 'license' => 'GPL-3.0-or-later', + 'topics' => $repo['topics'] ?? [], + 'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform), + 'package_type' => MokoStandardsParser::platformPackageType($platform), + 'last_synced' => date('c'), + ]); + + if ($this->dryRun) { + echo "WOULD WRITE ({$platform})\n"; + $stats['created']++; + continue; + } + + // Clone shallow via HTTPS (token-authed) + $workDir = "{$tmpBase}/{$name}"; + @mkdir($workDir, 0755, true); + + [$ret, $out] = $this->safeExec( + 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' + . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) + ); + if ($ret !== 0) { + echo "FAIL (clone)\n"; + fprintf(STDERR, " %s\n", $out); + $stats['failed']++; + continue; + } + + // Check if already XML and up-to-date + $manifestPath = "{$workDir}/.mokogitea/manifest.xml"; + $existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), 'extractPlatform(file_get_contents($manifestPath)); + if ($existingPlatform === $platform) { + echo "SKIP (already XML)\n"; + $stats['skipped']++; + $this->rmTree($workDir); + continue; + } + } + + // Write manifest + @mkdir("{$workDir}/.gitea", 0755, true); + file_put_contents($manifestPath, $xmlContent); + + // Delete legacy files if present + $legacyDeleted = []; + foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) { + $legacyPath = "{$workDir}/{$legacy}"; + if (file_exists($legacyPath)) { + unlink($legacyPath); + $legacyDeleted[] = $legacy; + } + } + + // Commit + $isNew = !$existingIsXml; + $commitMsg = $isNew + ? 'chore: add XML manifest.xml' + : 'chore: update .mokostandards to XML format'; + if (!empty($legacyDeleted)) { + $commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted); + } + + $this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); + $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); + $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); + foreach ($legacyDeleted as $lf) { + $this->gitCmd($workDir, 'add', $lf); + } + + [$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg); + if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) { + echo "SKIP (no changes)\n"; + $stats['skipped']++; + $this->rmTree($workDir); + continue; + } + if ($commitRet !== 0) { + echo "FAIL (commit)\n"; + fprintf(STDERR, " %s\n", $commitOut); + $stats['failed']++; + $this->rmTree($workDir); + continue; + } + + [$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch); + if ($pushRet !== 0) { + echo "FAIL (push)\n"; + fprintf(STDERR, " %s\n", $pushOut); + $stats['failed']++; + } else { + $action = $isNew ? 'CREATED' : 'UPDATED'; + echo "{$action}\n"; + $stats[$isNew ? 'created' : 'updated']++; + } + + // Cleanup + $this->rmTree($workDir); } - $repos = array_merge($repos, $batch); - $page++; - } while (count($batch) >= 50); - return $repos; -} + // Cleanup tmp base + @rmdir($tmpBase); -// ── Main ───────────────────────────────────────────────────────────────── -echo "=== MokoStandards XML Manifest Push ===\n"; -echo "Org: {$giteaOrg}\n"; -echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n"; -if ($repoFilter) { - echo "Filter: {$repoFilter}\n"; -} -echo "\n"; + echo "\n=== Summary ===\n"; + echo "Created: {$stats['created']}\n"; + echo "Updated: {$stats['updated']}\n"; + echo "Skipped: {$stats['skipped']}\n"; + echo "Failed: {$stats['failed']}\n"; -if (empty($token)) { - fprintf(STDERR, "ERROR: GA_TOKEN or GH_TOKEN environment variable required\n"); - exit(1); -} - -$repos = fetchRepos($giteaUrl, $giteaOrg, $token); -echo "Found " . count($repos) . " repositories\n\n"; - -$stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0]; - -foreach ($repos as $repo) { - $name = $repo['name']; - if ($repoFilter && $name !== $repoFilter) { - continue; - } - if (in_array($name, $skipRepos, true)) { - echo " SKIP {$name} (excluded)\n"; - $stats['skipped']++; - continue; - } - if ($repo['archived'] ?? false) { - echo " SKIP {$name} (archived)\n"; - $stats['skipped']++; - continue; + return 0; } - $platform = detectPlatform($repo); - $defaultBranch = $repo['default_branch'] ?? 'main'; - // Prefer HTTPS with token (SSH port 2222 may be blocked); fall back to SSH - $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; - // Embed token in HTTPS URL for push auth - $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); + private function detectPlatform(array $repo): string + { + $name = $repo['name'] ?? ''; + $nameLower = strtolower($name); + $description = strtolower($repo['description'] ?? ''); + $topics = $repo['topics'] ?? []; - echo " {$name} [{$platform}] ... "; - - // Generate XML manifest - $xmlContent = $parser->generate([ - 'name' => $name, - 'org' => $giteaOrg, - 'platform' => $platform, - 'standards_version' => '04.07.00', - 'description' => $repo['description'] ?? '', - 'license' => 'GPL-3.0-or-later', - 'topics' => $repo['topics'] ?? [], - 'language' => $repo['language'] ?? MokoStandardsParser::platformLanguage($platform), - 'package_type' => MokoStandardsParser::platformPackageType($platform), - 'last_synced' => date('c'), - ]); - - if ($dryRun) { - echo "WOULD WRITE ({$platform})\n"; - $stats['created']++; - continue; - } - - // Clone shallow via HTTPS (token-authed) - $workDir = "{$tmpBase}/{$name}"; - @mkdir($workDir, 0755, true); - - [$ret, $out] = safeExec( - 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' - . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) - ); - if ($ret !== 0) { - echo "FAIL (clone)\n"; - fprintf(STDERR, " %s\n", $out); - $stats['failed']++; - continue; - } - - // Check if already XML and up-to-date - $manifestPath = "{$workDir}/.mokogitea/.mokostandards"; - $existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), 'extractPlatform(file_get_contents($manifestPath)); - if ($existingPlatform === $platform) { - echo "SKIP (already XML)\n"; - $stats['skipped']++; - rmTree($workDir); - continue; + if (in_array($name, self::CRM_PLATFORM_REPOS, true)) { + return 'crm-platform'; } - } - - // Write manifest - @mkdir("{$workDir}/.gitea", 0755, true); - file_put_contents($manifestPath, $xmlContent); - - // Delete legacy files if present - $legacyDeleted = []; - foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) { - $legacyPath = "{$workDir}/{$legacy}"; - if (file_exists($legacyPath)) { - unlink($legacyPath); - $legacyDeleted[] = $legacy; + if (in_array('dolibarr-platform', $topics)) { + return 'crm-platform'; } + if (in_array('joomla-template', $topics)) { + return 'joomla-template'; + } + if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { + return 'waas-component'; + } + if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { + return 'crm-module'; + } + + if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { + return 'joomla-template'; + } + if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { + return 'waas-component'; + } + if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { + return 'crm-module'; + } + + if (str_contains($description, 'joomla template')) { + return 'joomla-template'; + } + if (str_contains($description, 'joomla') || str_contains($description, 'component')) { + return 'waas-component'; + } + if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { + return 'crm-module'; + } + + if (str_contains($nameLower, 'standard')) { + return 'standards-repository'; + } + return 'default-repository'; } - // Commit - $isNew = !$existingIsXml; - $commitMsg = $isNew - ? 'chore: add XML .mokostandards manifest' - : 'chore: update .mokostandards to XML format'; - if (!empty($legacyDeleted)) { - $commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted); + /** + * @return array{int, string} + */ + private function safeExec(string $command, string $cwd = '.'): array + { + $proc = proc_open( + $command, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $cwd + ); + if (!is_resource($proc)) { + return [1, "proc_open failed for: {$command}"]; + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($proc); + return [$code, trim($stdout . "\n" . $stderr)]; } - gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); - gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); - gitCmd($workDir, 'add', '.mokogitea/.mokostandards'); - foreach ($legacyDeleted as $lf) { - gitCmd($workDir, 'add', $lf); + private function rmTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); + $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @chmod($file->getPathname(), 0777); + @unlink($file->getPathname()); + } + } + @rmdir($dir); } - [$commitRet, $commitOut] = gitCmd($workDir, 'commit', '-m', $commitMsg); - if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) { - echo "SKIP (no changes)\n"; - $stats['skipped']++; - rmTree($workDir); - continue; - } - if ($commitRet !== 0) { - echo "FAIL (commit)\n"; - fprintf(STDERR, " %s\n", $commitOut); - $stats['failed']++; - rmTree($workDir); - continue; + /** + * @return array{int, string} + */ + private function gitCmd(string $workDir, string ...$args): array + { + $cmd = 'git'; + foreach ($args as $a) { + $cmd .= ' ' . escapeshellarg($a); + } + return $this->safeExec($cmd, $workDir); } - [$pushRet, $pushOut] = gitCmd($workDir, 'push', 'origin', $defaultBranch); - if ($pushRet !== 0) { - echo "FAIL (push)\n"; - fprintf(STDERR, " %s\n", $pushOut); - $stats['failed']++; - } else { - $action = $isNew ? 'CREATED' : 'UPDATED'; - echo "{$action}\n"; - $stats[$isNew ? 'created' : 'updated']++; - } + private function fetchRepos(string $url, string $org, string $token): array + { + $repos = []; + $page = 1; + do { + $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + $body = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); - // Cleanup - rmTree($workDir); + if ($code !== 200) { + $this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}"); + break; + } + + $batch = json_decode($body, true); + if (empty($batch)) { + break; + } + $repos = array_merge($repos, $batch); + $page++; + } while (count($batch) >= 50); + + return $repos; + } } -// Cleanup tmp base -@rmdir($tmpBase); - -echo "\n=== Summary ===\n"; -echo "Created: {$stats['created']}\n"; -echo "Updated: {$stats['updated']}\n"; -echo "Skipped: {$stats['skipped']}\n"; -echo "Failed: {$stats['failed']}\n"; +$app = new PushMokostandardsXmlCli(); +exit($app->execute()); diff --git a/cli/archive_repo.php b/cli/archive_repo.php index 26b18d5..3ae2b81 100644 --- a/cli/archive_repo.php +++ b/cli/archive_repo.php @@ -12,134 +12,151 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/archive_repo.php * BRIEF: Gracefully retire a governed repository — archive, close issues/PRs, remove sync def - * - * USAGE - * php cli/archive_repo.php --repo MokoOldModule - * php cli/archive_repo.php --repo MokoOldModule --dry-run - * php cli/archive_repo.php --repo MokoOldModule --skip-close # Archive only, keep issues open */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; +use MokoEnterprise\CliFramework; use MokoEnterprise\Config; use MokoEnterprise\PlatformAdapterFactory; -$dryRun = in_array('--dry-run', $argv); -$skipClose = in_array('--skip-close', $argv); +class ArchiveRepoCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Gracefully retire a governed repository — archive, close issues/PRs, remove sync def'); + $this->addArgument('--repo', 'Repository name to archive', ''); + $this->addArgument('--skip-close', 'Archive only, keep issues open', false); + } -$repoName = null; + protected function run(): int + { + $repoName = $this->getArgument('--repo'); + $skipClose = $this->getArgument('--skip-close'); -foreach ($argv as $i => $arg) { - if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } + if (empty($repoName)) { + $this->log('ERROR', 'Usage: php archive_repo.php --repo [--skip-close] [--dry-run]'); + return 2; + } + + $config = Config::load(); + $adapter = PlatformAdapterFactory::create($config); + $org = $config->getString( + $adapter->getPlatformName() . '.organization', + 'mokoconsulting-tech' + ); + + $platformName = $adapter->getPlatformName(); + + echo "Archiving repository: {$org}/{$repoName} (on {$platformName})\n\n"; + + // -- Step 1: Verify repo exists -- + echo "Step 1: Verifying repository...\n"; + try { + $repoData = $adapter->getRepo($org, $repoName); + } catch (\Exception $e) { + $this->log('ERROR', "Repository {$org}/{$repoName} not found: " . $e->getMessage()); + return 1; + } + if ($repoData['archived'] ?? false) { + echo " Already archived — nothing to do\n"; + return 0; + } + echo " Found: " . ($repoData['html_url'] ?? "{$org}/{$repoName}") . "\n"; + + // -- Step 2: Close all open PRs -- + if (!$skipClose) { + echo "Step 2: Closing open pull requests...\n"; + $prs = $adapter->listPullRequests($org, $repoName, ['state' => 'open']); + $prCount = count($prs); + echo " Found {$prCount} open PRs\n"; + + foreach ($prs as $pr) { + $num = $pr['number']; + if (!$this->dryRun) { + $adapter->updatePullRequest($org, $repoName, $num, ['state' => 'closed']); + $adapter->addIssueComment( + $org, + $repoName, + $num, + "Closed as part of repository archival. This repository is being retired.\n\n*Auto-closed by `archive_repo.php`*" + ); + } + echo " Closed PR #{$num}: {$pr['title']}\n"; + } + + // -- Step 3: Close all open issues -- + echo "Step 3: Closing open issues...\n"; + $issues = $adapter->listIssues($org, $repoName, ['state' => 'open']); + $issues = array_filter($issues, fn($i) => !isset($i['pull_request'])); + $issueCount = count($issues); + echo " Found {$issueCount} open issues\n"; + + foreach ($issues as $issue) { + $num = $issue['number']; + if (!$this->dryRun) { + $adapter->closeIssue($org, $repoName, $num); + $adapter->addIssueComment( + $org, + $repoName, + $num, + "Closed as part of repository archival.\n\n*Auto-closed by `archive_repo.php`*" + ); + } + echo " Closed issue #{$num}: {$issue['title']}\n"; + } + } else { + echo "Step 2-3: Skipping issue/PR closure (--skip-close)\n"; + } + + // -- Step 4: Archive the repository -- + echo "Step 4: Archiving repository...\n"; + if (!$this->dryRun) { + try { + $adapter->archiveRepo($org, $repoName); + echo " Repository archived\n"; + } catch (\Exception $e) { + echo " Failed to archive: " . $e->getMessage() . "\n"; + } + } else { + echo " (dry-run) would archive {$org}/{$repoName}\n"; + } + + // -- Step 5: (removed — sync definitions no longer used) -- + + // -- Step 6: Create archival record -- + echo "Step 6: Creating archival record...\n"; + if (!$this->dryRun) { + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + try { + $issue = $adapter->createIssue( + $org, + 'moko-platform', + "chore: archived repository {$repoName}", + "## Repository Archived\n\n**Repository:** `{$org}/{$repoName}`\n**Archived:** {$now}\n**Platform:** {$platformName}\n**Sync definition removed:** yes\n\n---\n*Auto-created by `archive_repo.php`*\n", + [ + 'labels' => ['type: chore', 'automation', 'archived'], + 'assignees' => ['jmiller'], + ] + ); + if (isset($issue['number'])) { + echo " Archival record: moko-platform#{$issue['number']}\n"; + } + } catch (\Exception $e) { + echo " Warning: could not create archival record: " . $e->getMessage() . "\n"; + } + } else { + echo " (dry-run) would create archival record issue\n"; + } + + echo "\n" . str_repeat('-', 50) . "\n"; + echo "Repository {$org}/{$repoName} archived successfully\n"; + return 0; + } } -if (!$repoName) { - fwrite(STDERR, "Usage: php archive_repo.php --repo [--skip-close] [--dry-run]\n"); - exit(2); -} - -$config = Config::load(); -$adapter = PlatformAdapterFactory::create($config); -$org = $config->getString( - $adapter->getPlatformName() . '.organization', - 'mokoconsulting-tech' -); - -$repoRoot = dirname(__DIR__, 2); -$platformName = $adapter->getPlatformName(); - -echo "Archiving repository: {$org}/{$repoName} (on {$platformName})\n\n"; - -// ── Step 1: Verify repo exists ────────────────────────────────────────── -echo "Step 1: Verifying repository...\n"; -try { - $repoData = $adapter->getRepo($org, $repoName); -} catch (\Exception $e) { - fwrite(STDERR, " Repository {$org}/{$repoName} not found: " . $e->getMessage() . "\n"); - exit(1); -} -if ($repoData['archived'] ?? false) { - echo " Already archived — nothing to do\n"; - exit(0); -} -echo " Found: " . ($repoData['html_url'] ?? "{$org}/{$repoName}") . "\n"; - -// ── Step 2: Close all open PRs ────────────────────────────────────────── -if (!$skipClose) { - echo "Step 2: Closing open pull requests...\n"; - $prs = $adapter->listPullRequests($org, $repoName, ['state' => 'open']); - $prCount = count($prs); - echo " Found {$prCount} open PRs\n"; - - foreach ($prs as $pr) { - $num = $pr['number']; - if (!$dryRun) { - $adapter->updatePullRequest($org, $repoName, $num, ['state' => 'closed']); - $adapter->addIssueComment($org, $repoName, $num, - "Closed as part of repository archival. This repository is being retired.\n\n*Auto-closed by `archive_repo.php`*" - ); - } - echo " Closed PR #{$num}: {$pr['title']}\n"; - } - - // ── Step 3: Close all open issues ─────────────────────────────────── - echo "Step 3: Closing open issues...\n"; - $issues = $adapter->listIssues($org, $repoName, ['state' => 'open']); - $issues = array_filter($issues, fn($i) => !isset($i['pull_request'])); - $issueCount = count($issues); - echo " Found {$issueCount} open issues\n"; - - foreach ($issues as $issue) { - $num = $issue['number']; - if (!$dryRun) { - $adapter->closeIssue($org, $repoName, $num); - $adapter->addIssueComment($org, $repoName, $num, - "Closed as part of repository archival.\n\n*Auto-closed by `archive_repo.php`*" - ); - } - echo " Closed issue #{$num}: {$issue['title']}\n"; - } -} else { - echo "Step 2-3: Skipping issue/PR closure (--skip-close)\n"; -} - -// ── Step 4: Archive the repository ────────────────────────────────────── -echo "Step 4: Archiving repository...\n"; -if (!$dryRun) { - try { - $adapter->archiveRepo($org, $repoName); - echo " Repository archived\n"; - } catch (\Exception $e) { - echo " Failed to archive: " . $e->getMessage() . "\n"; - } -} else { - echo " (dry-run) would archive {$org}/{$repoName}\n"; -} - -// ── Step 5: (removed — sync definitions no longer used) ───────────────── - -// ── Step 6: Create archival record ────────────────────────────────────── -echo "Step 6: Creating archival record...\n"; -if (!$dryRun) { - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - try { - $issue = $adapter->createIssue($org, 'MokoStandards', - "chore: archived repository {$repoName}", - "## Repository Archived\n\n**Repository:** `{$org}/{$repoName}`\n**Archived:** {$now}\n**Platform:** {$platformName}\n**Sync definition removed:** yes\n\n---\n*Auto-created by `archive_repo.php`*\n", - [ - 'labels' => ['type: chore', 'automation', 'archived'], - 'assignees' => ['jmiller'], - ] - ); - if (isset($issue['number'])) { echo " Archival record: MokoStandards#{$issue['number']}\n"; } - } catch (\Exception $e) { - echo " Warning: could not create archival record: " . $e->getMessage() . "\n"; - } -} else { - echo " (dry-run) would create archival record issue\n"; -} - -echo "\n" . str_repeat('-', 50) . "\n"; -echo "Repository {$org}/{$repoName} archived successfully\n"; +$app = new ArchiveRepoCli(); +exit($app->execute()); diff --git a/cli/badge_update.php b/cli/badge_update.php index 7470de5..f45a538 100644 --- a/cli/badge_update.php +++ b/cli/badge_update.php @@ -10,59 +10,70 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/badge_update.php * BRIEF: Update [VERSION: XX.XX.XX] badges in all markdown files - * - * Usage: - * php badge_update.php --path /repo --version 04.01.00 */ declare(strict_types=1); -$path = '.'; -$version = null; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; +use MokoEnterprise\CliFramework; + +class BadgeUpdateCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Update VERSION badges in all markdown files'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--version', 'Version string XX.YY.ZZ', ''); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + + if (empty($version)) { + $this->log('ERROR', 'Usage: badge_update.php --path . --version XX.YY.ZZ'); + return 1; + } + + $root = realpath($path) ?: $path; + $pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/'; + $replacement = "[VERSION: {$version}]"; + $updated = 0; + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + $filePath = $file->getPathname(); + + if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) { + continue; + } + if (!preg_match('/\.md$/i', $filePath)) { + continue; + } + + $content = file_get_contents($filePath); + if (preg_match($pattern, $content)) { + $newContent = preg_replace($pattern, $replacement, $content); + if ($newContent !== $content) { + if (!$this->dryRun) { + file_put_contents($filePath, $newContent); + } + $relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath); + $this->log('INFO', "Updated: {$relative}"); + $updated++; + } + } + } + + $this->success("Updated {$updated} file(s) to {$replacement}"); + return 0; + } } -if ($version === null) { - fwrite(STDERR, "Usage: badge_update.php --path . --version XX.YY.ZZ\n"); - exit(1); -} - -$root = realpath($path) ?: $path; -$pattern = '/\[VERSION:\s*\d{2}\.\d{2}\.\d{2}\]/'; -$replacement = "[VERSION: {$version}]"; -$updated = 0; - -$iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) -); - -foreach ($iterator as $file) { - $filePath = $file->getPathname(); - - // Skip .git and vendor directories - if (preg_match('#[/\\\\](\.git|vendor)[/\\\\]#', $filePath)) { - continue; - } - - // Only process markdown files - if (!preg_match('/\.md$/i', $filePath)) { - continue; - } - - $content = file_get_contents($filePath); - if (preg_match($pattern, $content)) { - $newContent = preg_replace($pattern, $replacement, $content); - if ($newContent !== $content) { - file_put_contents($filePath, $newContent); - $relative = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath); - echo "Updated: {$relative}\n"; - $updated++; - } - } -} - -echo "Updated {$updated} file(s) to {$replacement}\n"; -exit(0); +$app = new BadgeUpdateCli(); +exit($app->execute()); diff --git a/cli/branch_rename.php b/cli/branch_rename.php index f051778..0d0c772 100644 --- a/cli/branch_rename.php +++ b/cli/branch_rename.php @@ -9,130 +9,139 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/branch_rename.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Rename a git branch via Gitea API (create new, update PR, delete old) - * - * Usage: - * php branch_rename.php --from dev --to rc --token TOKEN --api-base URL [--pr 42] - * php branch_rename.php --from dev --to rc --token TOKEN --api-base URL --pr 42 --dry-run */ declare(strict_types=1); -$from = ''; -$to = ''; -$token = ''; -$apiBase = ''; -$prNum = ''; -$dryRun = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1]; - if ($arg === '--to' && isset($argv[$i + 1])) $to = $argv[$i + 1]; - if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; - if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1]; - if ($arg === '--pr' && isset($argv[$i + 1])) $prNum = $argv[$i + 1]; - if ($arg === '--dry-run') $dryRun = true; -} +use MokoEnterprise\CliFramework; -if (empty($from) || empty($to) || empty($token) || empty($apiBase)) { - fwrite(STDERR, "Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]\n"); - exit(1); -} - -if ($from === $to) { - echo "Source and target are the same ({$from}) — nothing to do\n"; - exit(0); -} - -$headers = [ - "Authorization: token {$token}", - 'Content-Type: application/json', - 'Accept: application/json', -]; - -/** - * Make an API request. - */ -function apiRequest(string $method, string $url, array $headers, ?array $body = null): array +class BranchRenameCli extends CliFramework { - $ch = curl_init(); - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => 30, - ]); - - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + protected function configure(): void + { + $this->setDescription('Rename a git branch via Gitea API (create new, update PR, delete old)'); + $this->addArgument('--from', 'Source branch name', ''); + $this->addArgument('--to', 'Target branch name', ''); + $this->addArgument('--token', 'API token', ''); + $this->addArgument('--api-base', 'API base URL', ''); + $this->addArgument('--pr', 'PR number to update head branch', ''); } - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + protected function run(): int + { + $from = $this->getArgument('--from'); + $to = $this->getArgument('--to'); + $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $prNum = $this->getArgument('--pr'); - return [ - 'code' => $httpCode, - 'body' => json_decode($response ?: '{}', true) ?: [], - ]; -} - -// Step 1: Verify source branch exists -echo "Checking source branch: {$from}\n"; -$check = apiRequest('GET', "{$apiBase}/branches/{$from}", $headers); -if ($check['code'] !== 200) { - fwrite(STDERR, "Source branch '{$from}' not found (HTTP {$check['code']})\n"); - exit(1); -} - -// Step 2: Delete target branch if it already exists -$targetCheck = apiRequest('GET', "{$apiBase}/branches/{$to}", $headers); -if ($targetCheck['code'] === 200) { - echo "Target branch '{$to}' already exists — deleting\n"; - if (!$dryRun) { - apiRequest('DELETE', "{$apiBase}/branches/{$to}", $headers); - } -} - -// Step 3: Create new branch from source -echo "Creating branch: {$to} (from {$from})\n"; -if (!$dryRun) { - $create = apiRequest('POST', "{$apiBase}/branches", $headers, [ - 'new_branch_name' => $to, - 'old_branch_name' => $from, - ]); - if ($create['code'] < 200 || $create['code'] >= 300) { - fwrite(STDERR, "Failed to create branch '{$to}': HTTP {$create['code']}\n"); - fwrite(STDERR, json_encode($create['body']) . "\n"); - exit(1); - } -} - -// Step 4: Update PR head branch if PR number provided -if (!empty($prNum)) { - echo "Updating PR #{$prNum} head branch: {$from} -> {$to}\n"; - if (!$dryRun) { - $update = apiRequest('PATCH', "{$apiBase}/pulls/{$prNum}", $headers, [ - 'head' => $to, - ]); - if ($update['code'] < 200 || $update['code'] >= 300) { - fwrite(STDERR, "Warning: Could not update PR head branch (HTTP {$update['code']})\n"); - // Non-fatal — the PR may need manual update + if (empty($from) || empty($to) || empty($token) || empty($apiBase)) { + $this->log('ERROR', 'Usage: branch_rename.php --from BRANCH --to BRANCH --token TOKEN --api-base URL [--pr NUM] [--dry-run]'); + return 1; } + + if ($from === $to) { + echo "Source and target are the same ({$from}) — nothing to do\n"; + return 0; + } + + $headers = [ + "Authorization: token {$token}", + 'Content-Type: application/json', + 'Accept: application/json', + ]; + + // Step 1: Verify source branch exists + echo "Checking source branch: {$from}\n"; + $check = $this->apiRequest('GET', "{$apiBase}/branches/{$from}", $headers); + if ($check['code'] !== 200) { + $this->log('ERROR', "Source branch '{$from}' not found (HTTP {$check['code']})"); + return 1; + } + + // Step 2: Delete target branch if it already exists + $targetCheck = $this->apiRequest('GET', "{$apiBase}/branches/{$to}", $headers); + if ($targetCheck['code'] === 200) { + echo "Target branch '{$to}' already exists — deleting\n"; + if (!$this->dryRun) { + $this->apiRequest('DELETE', "{$apiBase}/branches/{$to}", $headers); + } + } + + // Step 3: Create new branch from source + echo "Creating branch: {$to} (from {$from})\n"; + if (!$this->dryRun) { + $create = $this->apiRequest('POST', "{$apiBase}/branches", $headers, [ + 'new_branch_name' => $to, + 'old_branch_name' => $from, + ]); + if ($create['code'] < 200 || $create['code'] >= 300) { + $this->log('ERROR', "Failed to create branch '{$to}': HTTP {$create['code']}"); + $this->log('ERROR', json_encode($create['body'])); + return 1; + } + } + + // Step 4: Update PR head branch if PR number provided + if (!empty($prNum)) { + echo "Updating PR #{$prNum} head branch: {$from} -> {$to}\n"; + if (!$this->dryRun) { + $update = $this->apiRequest('PATCH', "{$apiBase}/pulls/{$prNum}", $headers, [ + 'head' => $to, + ]); + if ($update['code'] < 200 || $update['code'] >= 300) { + $this->log('ERROR', "Warning: Could not update PR head branch (HTTP {$update['code']})"); + // Non-fatal — the PR may need manual update + } + } + } + + // Step 5: Delete old source branch + echo "Deleting old branch: {$from}\n"; + if (!$this->dryRun) { + $delete = $this->apiRequest('DELETE', "{$apiBase}/branches/{$from}", $headers); + if ($delete['code'] !== 204 && $delete['code'] !== 200) { + $this->log('ERROR', "Warning: Could not delete old branch '{$from}' (HTTP {$delete['code']})"); + // Non-fatal — branch protection may prevent deletion + } + } + + echo "Renamed: {$from} -> {$to}\n"; + return 0; + } + + /** + * Make an API request. + */ + private function apiRequest(string $method, string $url, array $headers, ?array $body = null): array + { + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30, + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return [ + 'code' => $httpCode, + 'body' => json_decode($response ?: '{}', true) ?: [], + ]; } } -// Step 5: Delete old source branch -echo "Deleting old branch: {$from}\n"; -if (!$dryRun) { - $delete = apiRequest('DELETE', "{$apiBase}/branches/{$from}", $headers); - if ($delete['code'] !== 204 && $delete['code'] !== 200) { - fwrite(STDERR, "Warning: Could not delete old branch '{$from}' (HTTP {$delete['code']})\n"); - // Non-fatal — branch protection may prevent deletion - } -} - -echo "Renamed: {$from} -> {$to}\n"; -exit(0); +$app = new BranchRenameCli(); +exit($app->execute()); diff --git a/cli/bulk_workflow_push.php b/cli/bulk_workflow_push.php index 37f45b7..ba3168f 100644 --- a/cli/bulk_workflow_push.php +++ b/cli/bulk_workflow_push.php @@ -12,110 +12,125 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/bulk_workflow_push.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Push a workflow file to all governed repos via the Gitea Contents API */ declare(strict_types=1); -final class BulkWorkflowPush -{ - private string $giteaUrl = 'https://git.mokoconsulting.tech'; - private string $token = ''; - private string $org = ''; - private string $workflowFile = ''; - private string $destPath = ''; - private string $branch = 'main'; - private bool $dryRun = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; +use MokoEnterprise\CliFramework; + +class BulkWorkflowPushCli extends CliFramework +{ private int $updated = 0; private int $created = 0; private int $skipped = 0; private int $errors = 0; - public function run(): int + protected function configure(): void { - $this->parseArgs(); + $this->setDescription('Push a workflow file to all governed repos via the Gitea Contents API'); + $this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech'); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--org', 'Target organization', ''); + $this->addArgument('--file', 'Local workflow file to push', ''); + $this->addArgument('--dest', 'Destination path in repos (default: .mokogitea/workflows/)', ''); + $this->addArgument('--branch', 'Target branch (default: main)', 'main'); + } - if ($this->token === '') { - $this->log('ERROR: --token is required.'); - $this->printUsage(); + protected function run(): int + { + $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); + $token = $this->getArgument('--token'); + $org = $this->getArgument('--org'); + $workflowFile = $this->getArgument('--file'); + $destPath = $this->getArgument('--dest'); + $branch = $this->getArgument('--branch'); + + if ($token === '') { + $this->log('ERROR', '--token is required.'); return 1; } - if ($this->workflowFile === '') { - $this->log('ERROR: --file is required.'); - $this->printUsage(); + if ($workflowFile === '') { + $this->log('ERROR', '--file is required.'); return 1; } - if (!file_exists($this->workflowFile)) { - $this->log("ERROR: File not found: {$this->workflowFile}"); + if (!file_exists($workflowFile)) { + $this->log('ERROR', "File not found: {$workflowFile}"); return 1; } - if ($this->org === '') { - $this->log('ERROR: --org is required.'); - $this->printUsage(); + if ($org === '') { + $this->log('ERROR', '--org is required.'); return 1; } - if ($this->destPath === '') { - $this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile); + if ($destPath === '') { + $destPath = '.mokogitea/workflows/' . basename($workflowFile); } - $localContent = file_get_contents($this->workflowFile); + $localContent = file_get_contents($workflowFile); if ($localContent === false) { - $this->log("ERROR: Could not read file: {$this->workflowFile}"); + $this->log('ERROR', "Could not read file: {$workflowFile}"); return 1; } - $this->log("Pushing: {$this->workflowFile}"); - $this->log(" -> {$this->destPath} (branch: {$this->branch})"); - $this->log(" -> Org: {$this->org} @ {$this->giteaUrl}"); + $this->log('INFO', "Pushing: {$workflowFile}"); + $this->log('INFO', " -> {$destPath} (branch: {$branch})"); + $this->log('INFO', " -> Org: {$org} @ {$giteaUrl}"); if ($this->dryRun) { - $this->log('[DRY RUN] No changes will be made.'); + $this->log('INFO', '[DRY RUN] No changes will be made.'); } - $this->log(''); + echo "\n"; - $repos = $this->fetchOrgRepos(); + $repos = $this->fetchOrgRepos($giteaUrl, $token, $org); if ($repos === null) { return 1; } - $this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\"."); - $this->log(''); - $this->log(sprintf('%-45s | %s', 'Repo', 'Status')); - $this->log(str_repeat('-', 70)); + $this->log('INFO', "Found " . count($repos) . " repo(s) in \"{$org}\"."); + echo "\n"; + fprintf(STDERR, "%-45s | %s\n", 'Repo', 'Status'); + fprintf(STDERR, "%s\n", str_repeat('-', 70)); $encodedContent = base64_encode($localContent); foreach ($repos as $repo) { - $this->pushToRepo($repo, $encodedContent, $localContent); + $this->pushToRepo($giteaUrl, $token, $repo, $encodedContent, $localContent, $destPath, $branch); } - $this->log(''); - $this->log("Done: {$this->created} created, {$this->updated} updated, " + echo "\n"; + $this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, " . "{$this->skipped} skipped, {$this->errors} error(s)."); return $this->errors > 0 ? 1 : 0; } private function pushToRepo( + string $giteaUrl, + string $token, string $repoFullName, string $encodedContent, - string $localContent + string $localContent, + string $destPath, + string $branch ): void { [$owner, $repoName] = explode('/', $repoFullName, 2); $existing = $this->apiRequest( + $giteaUrl, + $token, 'GET', "/api/v1/repos/{$owner}/{$repoName}/contents/" - . "{$this->destPath}?ref={$this->branch}" + . "{$destPath}?ref={$branch}" ); if ($existing['code'] === 200) { @@ -124,21 +139,13 @@ final class BulkWorkflowPush $remoteContent = base64_decode($data['content'] ?? ''); if ($remoteContent === $localContent) { - $this->log(sprintf( - '%-45s | %s', - $repoFullName, - 'IDENTICAL (skipped)' - )); + fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'IDENTICAL (skipped)'); $this->skipped++; return; } if ($this->dryRun) { - $this->log(sprintf( - '%-45s | %s', - $repoFullName, - 'WOULD UPDATE' - )); + fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD UPDATE'); $this->updated++; return; } @@ -146,100 +153,82 @@ final class BulkWorkflowPush $payload = json_encode([ 'content' => $encodedContent, 'sha' => $remoteSha, - 'message' => "chore: sync {$this->destPath} " + 'message' => "chore: sync {$destPath} " . "from moko-platform [skip ci]", - 'branch' => $this->branch, + 'branch' => $branch, ]); $response = $this->apiRequest( + $giteaUrl, + $token, 'PUT', "/api/v1/repos/{$owner}/{$repoName}/contents/" - . $this->destPath, + . $destPath, $payload ); if ($response['code'] === 200) { - $this->log(sprintf( - '%-45s | %s', - $repoFullName, - 'UPDATED' - )); + fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'UPDATED'); $this->updated++; } else { - $this->log(sprintf( - '%-45s | %s', - $repoFullName, - "ERROR (HTTP {$response['code']})" - )); + fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})"); $this->errors++; } } elseif ($existing['code'] === 404) { if ($this->dryRun) { - $this->log(sprintf( - '%-45s | %s', - $repoFullName, - 'WOULD CREATE' - )); + fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD CREATE'); $this->created++; return; } $payload = json_encode([ 'content' => $encodedContent, - 'message' => "chore: add {$this->destPath} " + 'message' => "chore: add {$destPath} " . "from moko-platform [skip ci]", - 'branch' => $this->branch, + 'branch' => $branch, ]); $response = $this->apiRequest( + $giteaUrl, + $token, 'POST', "/api/v1/repos/{$owner}/{$repoName}/contents/" - . $this->destPath, + . $destPath, $payload ); if ($response['code'] === 201) { - $this->log(sprintf( - '%-45s | %s', - $repoFullName, - 'CREATED' - )); + fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'CREATED'); $this->created++; } else { - $this->log(sprintf( - '%-45s | %s', - $repoFullName, - "ERROR (HTTP {$response['code']})" - )); + fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})"); $this->errors++; } } else { - $this->log(sprintf( - '%-45s | %s', - $repoFullName, - "ERROR (HTTP {$existing['code']})" - )); + fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$existing['code']})"); $this->errors++; } } - private function fetchOrgRepos(): ?array + private function fetchOrgRepos(string $giteaUrl, string $token, string $org): ?array { - $this->log("Fetching repos from org: {$this->org}"); + $this->log('INFO', "Fetching repos from org: {$org}"); $page = 1; $repos = []; while (true) { $response = $this->apiRequest( + $giteaUrl, + $token, 'GET', - "/api/v1/orgs/{$this->org}/repos?" + "/api/v1/orgs/{$org}/repos?" . "limit=50&page={$page}" ); if ($response['code'] < 200 || $response['code'] >= 300) { if ($page === 1) { - $this->log("ERROR: Could not fetch repos " + $this->log('ERROR', "Could not fetch repos " . "(HTTP {$response['code']})."); return null; } @@ -271,76 +260,14 @@ final class BulkWorkflowPush return $repos; } - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) { - switch ($args[$i]) { - case '--gitea-url': - $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); - break; - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--org': - $this->org = $args[++$i] ?? ''; - break; - case '--file': - $this->workflowFile = $args[++$i] ?? ''; - break; - case '--dest': - $this->destPath = $args[++$i] ?? ''; - break; - case '--branch': - $this->branch = $args[++$i] ?? 'main'; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown argument: {$args[$i]}"); - break; - } - } - } - - private function printUsage(): void - { - $this->log( - 'Usage: bulk_workflow_push.php ' - . '--token --file --org [options]' - ); - $this->log(''); - $this->log( - 'Push a workflow file from moko-platform ' - . 'to all governed repos.' - ); - $this->log(''); - $this->log('Options:'); - $this->log(' --gitea-url Gitea URL ' - . '(default: https://git.mokoconsulting.tech)'); - $this->log(' --token Gitea API token'); - $this->log(' --org Target organization'); - $this->log(' --file Local workflow file to push'); - $this->log(' --dest Destination path in repos ' - . '(default: .mokogitea/workflows/)'); - $this->log(' --branch Target branch (default: main)'); - $this->log(' --dry-run Show what would be done'); - $this->log(' --help, -h Show this help'); - } - private function apiRequest( + string $giteaUrl, + string $token, string $method, string $endpoint, ?string $body = null ): array { - $url = $this->giteaUrl . $endpoint; + $url = $giteaUrl . $endpoint; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); @@ -349,7 +276,7 @@ final class BulkWorkflowPush curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Accept: application/json', - "Authorization: token {$this->token}", + "Authorization: token {$token}", ]); if ($body !== null) { @@ -376,12 +303,7 @@ final class BulkWorkflowPush return ['code' => $httpCode, 'body' => $responseBody]; } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } } -$app = new BulkWorkflowPush(); -exit($app->run()); +$app = new BulkWorkflowPushCli(); +exit($app->execute()); diff --git a/cli/bulk_workflow_trigger.php b/cli/bulk_workflow_trigger.php index 4463836..794201e 100644 --- a/cli/bulk_workflow_trigger.php +++ b/cli/bulk_workflow_trigger.php @@ -11,309 +11,234 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/bulk_workflow_trigger.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Trigger a workflow across multiple repos at once */ declare(strict_types=1); -final class BulkWorkflowTrigger +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class BulkWorkflowTriggerCli extends CliFramework { - private string $giteaUrl = 'https://git.mokoconsulting.tech'; - private string $token = ''; - private string $reposFile = ''; - private string $org = ''; - private string $workflow = ''; - private string $ref = 'main'; - private string $inputs = ''; - private bool $dryRun = false; + private string $giteaUrl = 'https://git.mokoconsulting.tech'; + private string $token = ''; + private string $reposFile = ''; + private string $org = ''; + private string $workflow = ''; + private string $ref = 'main'; + private string $inputs = ''; - public function run(): int - { - $this->parseArgs(); + protected function configure(): void + { + $this->setDescription('Trigger a workflow across multiple repos at once'); + $this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech'); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--repos', 'File with newline-separated owner/repo list', ''); + $this->addArgument('--org', 'Trigger on all repos in an org', ''); + $this->addArgument('--workflow', 'Workflow file (e.g., "sync-servers.yml")', ''); + $this->addArgument('--ref', 'Branch ref (default: "main")', 'main'); + $this->addArgument('--inputs', 'Workflow inputs as JSON string', ''); + } - if ($this->token === '') - { - $this->log('ERROR: --token is required.'); - $this->printUsage(); - return 1; - } + protected function run(): int + { + $this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); + $this->token = $this->getArgument('--token'); + $this->reposFile = $this->getArgument('--repos'); + $this->org = $this->getArgument('--org'); + $this->workflow = $this->getArgument('--workflow'); + $this->ref = $this->getArgument('--ref'); + $this->inputs = $this->getArgument('--inputs'); - if ($this->workflow === '') - { - $this->log('ERROR: --workflow is required.'); - $this->printUsage(); - return 1; - } + if ($this->token === '') { + $this->log('ERROR', '--token is required.'); + return 1; + } - if ($this->reposFile === '' && $this->org === '') - { - $this->log('ERROR: Either --repos or --org is required.'); - $this->printUsage(); - return 1; - } + if ($this->workflow === '') { + $this->log('ERROR', '--workflow is required.'); + return 1; + } - // Build repo list - $repos = $this->buildRepoList(); + if ($this->reposFile === '' && $this->org === '') { + $this->log('ERROR', 'Either --repos or --org is required.'); + return 1; + } - if ($repos === null || count($repos) === 0) - { - $this->log('ERROR: No repos found to process.'); - return 1; - } + // Build repo list + $repos = $this->buildRepoList(); - $this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s)."); - $this->log("Gitea URL: {$this->giteaUrl}"); + if ($repos === null || count($repos) === 0) { + $this->log('ERROR', 'No repos found to process.'); + return 1; + } - if ($this->dryRun) - { - $this->log('[DRY RUN] No requests will be sent.'); - } + $this->log('INFO', "Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s)."); + $this->log('INFO', "Gitea URL: {$this->giteaUrl}"); - $this->log(''); + if ($this->dryRun) { + $this->log('INFO', '[DRY RUN] No requests will be sent.'); + } - // Parse inputs - $inputsDecoded = null; + $this->log('INFO', ''); - if ($this->inputs !== '') - { - $inputsDecoded = json_decode($this->inputs, true); + // Parse inputs + $inputsDecoded = null; - if (!is_array($inputsDecoded)) - { - $this->log('ERROR: --inputs must be valid JSON.'); - return 1; - } - } + if ($this->inputs !== '') { + $inputsDecoded = json_decode($this->inputs, true); - // Print header - $this->log(sprintf('%-40s | %s', 'Repo', 'Status')); - $this->log(str_repeat('-', 60)); + if (!is_array($inputsDecoded)) { + $this->log('ERROR', '--inputs must be valid JSON.'); + return 1; + } + } - $failCount = 0; + // Print header + $this->log('INFO', sprintf('%-40s | %s', 'Repo', 'Status')); + $this->log('INFO', str_repeat('-', 60)); - foreach ($repos as $repo) - { - $repo = trim($repo); + $failCount = 0; - if ($repo === '' || strpos($repo, '/') === false) - { - continue; - } + foreach ($repos as $repo) { + $repo = trim($repo); - [$owner, $repoName] = explode('/', $repo, 2); + if ($repo === '' || strpos($repo, '/') === false) { + continue; + } - if ($this->dryRun) - { - $this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)')); - continue; - } + [$owner, $repoName] = explode('/', $repo, 2); - $payload = ['ref' => $this->ref]; + if ($this->dryRun) { + $this->log('INFO', sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)')); + continue; + } - if ($inputsDecoded !== null) - { - $payload['inputs'] = $inputsDecoded; - } + $payload = ['ref' => $this->ref]; - $response = $this->apiRequest( - 'POST', - "/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches", - json_encode($payload) - ); + if ($inputsDecoded !== null) { + $payload['inputs'] = $inputsDecoded; + } - if ($response['code'] >= 200 && $response['code'] < 300) - { - $status = 'TRIGGERED'; - } - elseif ($response['code'] === 404) - { - $status = 'FAILED (not found)'; - $failCount++; - } - elseif ($response['code'] === 422) - { - $status = 'SKIPPED (unprocessable)'; - } - else - { - $status = "FAILED (HTTP {$response['code']})"; - $failCount++; - } + $response = $this->apiRequest( + 'POST', + "/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches", + json_encode($payload) + ); - $this->log(sprintf('%-40s | %s', $repo, $status)); - } + if ($response['code'] >= 200 && $response['code'] < 300) { + $status = 'TRIGGERED'; + } elseif ($response['code'] === 404) { + $status = 'FAILED (not found)'; + $failCount++; + } elseif ($response['code'] === 422) { + $status = 'SKIPPED (unprocessable)'; + } else { + $status = "FAILED (HTTP {$response['code']})"; + $failCount++; + } - $this->log(''); - $this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.')); + $this->log('INFO', sprintf('%-40s | %s', $repo, $status)); + } - return $failCount > 0 ? 1 : 0; - } + $this->log('INFO', ''); + $this->log('INFO', 'Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.')); - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); + return $failCount > 0 ? 1 : 0; + } - for ($i = 1; $i < $count; $i++) - { - switch ($args[$i]) - { - case '--gitea-url': - $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); - break; - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--repos': - $this->reposFile = $args[++$i] ?? ''; - break; - case '--org': - $this->org = $args[++$i] ?? ''; - break; - case '--workflow': - $this->workflow = $args[++$i] ?? ''; - break; - case '--ref': - $this->ref = $args[++$i] ?? 'main'; - break; - case '--inputs': - $this->inputs = $args[++$i] ?? ''; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown argument: {$args[$i]}"); - break; - } - } - } + private function buildRepoList(): ?array + { + if ($this->reposFile !== '') { + if (!file_exists($this->reposFile)) { + $this->log('ERROR', "Repos file not found: {$this->reposFile}"); + return null; + } - private function printUsage(): void - { - $this->log('Usage: bulk_workflow_trigger.php --token --workflow [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --token Gitea API token'); - $this->log(' --repos File with newline-separated owner/repo list'); - $this->log(' --org Trigger on all repos in an org'); - $this->log(' --workflow Workflow file (e.g., "sync-servers.yml")'); - $this->log(' --ref Branch ref (default: "main")'); - $this->log(' --inputs Workflow inputs as JSON string'); - $this->log(' --dry-run Show what would be done without triggering'); - $this->log(' --help, -h Show this help'); - } + $content = file_get_contents($this->reposFile); + $lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool { + return $line !== '' && $line[0] !== '#'; + }); - private function buildRepoList(): ?array - { - if ($this->reposFile !== '') - { - if (!file_exists($this->reposFile)) - { - $this->log("ERROR: Repos file not found: {$this->reposFile}"); - return null; - } + return array_values($lines); + } - $content = file_get_contents($this->reposFile); - $lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool { - return $line !== '' && $line[0] !== '#'; - }); + // Fetch all repos from org + $this->log('INFO', "Fetching repos from org: {$this->org}"); - return array_values($lines); - } + $page = 1; + $repos = []; - // Fetch all repos from org - $this->log("Fetching repos from org: {$this->org}"); + while (true) { + $response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}"); - $page = 1; - $repos = []; + if ($response['code'] < 200 || $response['code'] >= 300) { + if ($page === 1) { + $this->log('ERROR', "Could not fetch repos for org (HTTP {$response['code']})."); + return null; + } - while (true) - { - $response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}"); + break; + } - if ($response['code'] < 200 || $response['code'] >= 300) - { - if ($page === 1) - { - $this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']})."); - return null; - } + $data = json_decode($response['body'], true); - break; - } + if (!is_array($data) || count($data) === 0) { + break; + } - $data = json_decode($response['body'], true); + foreach ($data as $repo) { + $fullName = $repo['full_name'] ?? ''; - if (!is_array($data) || count($data) === 0) - { - break; - } + if ($fullName !== '') { + $repos[] = $fullName; + } + } - foreach ($data as $repo) - { - $fullName = $repo['full_name'] ?? ''; + $page++; + } - if ($fullName !== '') - { - $repos[] = $fullName; - } - } + $this->log('INFO', 'Found ' . count($repos) . " repo(s) in org \"{$this->org}\"."); - $page++; - } + return $repos; + } - $this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\"."); + private function apiRequest(string $method, string $endpoint, ?string $body = null): array + { + $url = $this->giteaUrl . $endpoint; - return $repos; - } + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Accept: application/json', + "Authorization: token {$this->token}", + ]); - private function apiRequest(string $method, string $endpoint, ?string $body = null): array - { - $url = $this->giteaUrl . $endpoint; + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Accept: application/json', - "Authorization: token {$this->token}", - ]); + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($body !== null) - { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); - $responseBody = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + return ['code' => 0, 'body' => "cURL error: {$error}"]; + } - if (curl_errno($ch)) - { - $error = curl_error($ch); - curl_close($ch); + curl_close($ch); - return ['code' => 0, 'body' => "cURL error: {$error}"]; - } - - curl_close($ch); - - return ['code' => $httpCode, 'body' => $responseBody]; - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } + return ['code' => $httpCode, 'body' => $responseBody]; + } } -$app = new BulkWorkflowTrigger(); -exit($app->run()); +$app = new BulkWorkflowTriggerCli(); +exit($app->execute()); diff --git a/cli/changelog_promote.php b/cli/changelog_promote.php index c1c7fa2..f0f069f 100644 --- a/cli/changelog_promote.php +++ b/cli/changelog_promote.php @@ -10,73 +10,83 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/changelog_promote.php * BRIEF: Promote [Unreleased] section in CHANGELOG.md to a versioned entry - * - * Usage: - * php changelog_promote.php --path /repo --version 04.01.00 - * php changelog_promote.php --path /repo --version 04.01.00 --date 2026-05-21 */ declare(strict_types=1); -$path = '.'; -$version = null; -$date = date('Y-m-d'); +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; - if ($arg === '--date' && isset($argv[$i + 1])) $date = $argv[$i + 1]; +use MokoEnterprise\CliFramework; + +class ChangelogPromoteCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Promote [Unreleased] CHANGELOG section to a versioned entry'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--version', 'Version string XX.YY.ZZ', ''); + $this->addArgument('--date', 'Release date YYYY-MM-DD', date('Y-m-d')); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $date = $this->getArgument('--date'); + + if (empty($version)) { + $this->log('ERROR', 'Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]'); + return 1; + } + + $changelog = realpath($path) . '/CHANGELOG.md'; + if (!file_exists($changelog)) { + $this->log('ERROR', "No CHANGELOG.md found at {$path}"); + return 1; + } + + $content = file_get_contents($changelog); + + if (!preg_match('/## \[?Unreleased\]?/i', $content)) { + $this->log('ERROR', 'No [Unreleased] section found in CHANGELOG.md'); + return 1; + } + + // Replace [Unreleased] with versioned entry + $content = preg_replace( + '/## \[Unreleased\]/i', + "## [{$version}] --- {$date}", + $content, + 1 + ); + $content = preg_replace( + '/## Unreleased/i', + "## [{$version}] --- {$date}", + $content, + 1 + ); + + // Insert new [Unreleased] section after the first heading line + $lines = explode("\n", $content); + $inserted = false; + $result = []; + + foreach ($lines as $line) { + $result[] = $line; + if (!$inserted && preg_match('/^# /', $line)) { + $result[] = ''; + $result[] = '## [Unreleased]'; + $result[] = ''; + $inserted = true; + } + } + + $content = implode("\n", $result); + file_put_contents($changelog, $content); + $this->success("CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}"); + return 0; + } } -if ($version === null) { - fwrite(STDERR, "Usage: changelog_promote.php --path . --version XX.YY.ZZ [--date YYYY-MM-DD]\n"); - exit(1); -} - -$changelog = realpath($path) . '/CHANGELOG.md'; -if (!file_exists($changelog)) { - fwrite(STDERR, "No CHANGELOG.md found at {$path}\n"); - exit(1); -} - -$content = file_get_contents($changelog); - -// Check if [Unreleased] section exists -if (!preg_match('/## \[?Unreleased\]?/i', $content)) { - fwrite(STDERR, "No [Unreleased] section found in CHANGELOG.md\n"); - exit(1); -} - -// Replace [Unreleased] with versioned entry -$content = preg_replace( - '/## \[Unreleased\]/i', - "## [{$version}] --- {$date}", - $content, - 1 -); -$content = preg_replace( - '/## Unreleased/i', - "## [{$version}] --- {$date}", - $content, - 1 -); - -// Insert new [Unreleased] section after the first heading line (# Changelog) -$lines = explode("\n", $content); -$inserted = false; -$result = []; - -foreach ($lines as $line) { - $result[] = $line; - if (!$inserted && preg_match('/^# /', $line)) { - $result[] = ''; - $result[] = '## [Unreleased]'; - $result[] = ''; - $inserted = true; - } -} - -$content = implode("\n", $result); -file_put_contents($changelog, $content); -echo "CHANGELOG promoted: [Unreleased] -> [{$version}] --- {$date}\n"; -exit(0); +$app = new ChangelogPromoteCli(); +exit($app->execute()); diff --git a/cli/changelog_prune.php b/cli/changelog_prune.php index 40a4ec6..56889a0 100644 --- a/cli/changelog_prune.php +++ b/cli/changelog_prune.php @@ -10,125 +10,125 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/changelog_prune.php * BRIEF: Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases - * - * Usage: - * php changelog_prune.php --path /repo --keep 5 - * php changelog_prune.php --path /repo --keep 3 --dry-run */ declare(strict_types=1); -$path = '.'; -$keep = 5; -$dryRun = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--keep' && isset($argv[$i + 1])) $keep = (int)$argv[$i + 1]; - if ($arg === '--dry-run') $dryRun = true; - if ($arg === '--help') { - echo "changelog_prune — Keep [Unreleased] + last N versioned entries\n\n"; - echo "Usage: php changelog_prune.php --path . --keep 5 [--dry-run]\n\n"; - echo "Options:\n"; - echo " --path Repository path (default: .)\n"; - echo " --keep Number of versioned releases to keep (default: 5)\n"; - echo " --dry-run Preview without writing\n"; - exit(0); +use MokoEnterprise\CliFramework; + +class ChangelogPruneCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Prune old CHANGELOG.md entries — keeps [Unreleased] + last N releases'); + $this->addArgument('--path', 'Repository path', '.'); + $this->addArgument('--keep', 'Number of versioned releases to keep', '5'); } -} -$changelog = realpath($path) . '/CHANGELOG.md'; -if (!file_exists($changelog)) { - fwrite(STDERR, "No CHANGELOG.md found at {$path}\n"); - exit(1); -} + protected function run(): int + { + $path = $this->getArgument('--path'); + $keep = (int) $this->getArgument('--keep'); -$content = file_get_contents($changelog); -$lines = explode("\n", $content); + $changelog = realpath($path) . '/CHANGELOG.md'; + if (!file_exists($changelog)) { + $this->log('ERROR', "No CHANGELOG.md found at {$path}"); + return 1; + } -// Split into sections by ## headings -$sections = []; -$current = []; -$currentHeading = null; + $content = file_get_contents($changelog); + $lines = explode("\n", $content); -foreach ($lines as $line) { - if (preg_match('/^## /', $line)) { + // Split into sections by ## headings + $sections = []; + $current = []; + $currentHeading = null; + + foreach ($lines as $line) { + if (preg_match('/^## /', $line)) { + if ($currentHeading !== null) { + $sections[] = ['heading' => $currentHeading, 'lines' => $current]; + } + $currentHeading = $line; + $current = [$line]; + } else { + $current[] = $line; + } + } if ($currentHeading !== null) { $sections[] = ['heading' => $currentHeading, 'lines' => $current]; } - $currentHeading = $line; - $current = [$line]; - } else { - $current[] = $line; - } -} -if ($currentHeading !== null) { - $sections[] = ['heading' => $currentHeading, 'lines' => $current]; -} -// Find the header (everything before the first ## section) -$header = []; -$contentLines = explode("\n", $content); -foreach ($contentLines as $line) { - if (preg_match('/^## /', $line)) { - break; - } - $header[] = $line; -} + // Find the header (everything before the first ## section) + $header = []; + $contentLines = explode("\n", $content); + foreach ($contentLines as $line) { + if (preg_match('/^## /', $line)) { + break; + } + $header[] = $line; + } -// Separate [Unreleased] from versioned sections -$unreleased = null; -$versioned = []; + // Separate [Unreleased] from versioned sections + $unreleased = null; + $versioned = []; -foreach ($sections as $section) { - if (preg_match('/\[Unreleased\]/i', $section['heading'])) { - $unreleased = $section; - } else { - $versioned[] = $section; + foreach ($sections as $section) { + if (preg_match('/\[Unreleased\]/i', $section['heading'])) { + $unreleased = $section; + } else { + $versioned[] = $section; + } + } + + $totalVersioned = count($versioned); + $pruned = $totalVersioned - $keep; + + if ($pruned <= 0) { + echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n"; + return 0; + } + + // Keep only the first N versioned sections + $keptVersioned = array_slice($versioned, 0, $keep); + $droppedVersioned = array_slice($versioned, $keep); + + // Report + echo "CHANGELOG: {$totalVersioned} versioned entries found\n"; + echo " Keeping: {$keep} most recent\n"; + echo " Pruning: {$pruned} old entries\n"; + + foreach ($droppedVersioned as $section) { + $heading = trim($section['heading']); + echo " - {$heading}\n"; + } + + if ($this->dryRun) { + echo "\n(dry-run) No changes written\n"; + return 0; + } + + // Rebuild the file + $output = implode("\n", $header); + + if ($unreleased !== null) { + $output .= implode("\n", $unreleased['lines']) . "\n"; + } + + foreach ($keptVersioned as $section) { + $output .= implode("\n", $section['lines']) . "\n"; + } + + // Clean up excessive blank lines at end + $output = rtrim($output) . "\n"; + + file_put_contents($changelog, $output); + echo "\nCHANGELOG pruned: removed {$pruned} old entries\n"; + return 0; } } -$totalVersioned = count($versioned); -$pruned = $totalVersioned - $keep; - -if ($pruned <= 0) { - echo "CHANGELOG has {$totalVersioned} versioned entries — nothing to prune (keeping {$keep})\n"; - exit(0); -} - -// Keep only the first N versioned sections -$keptVersioned = array_slice($versioned, 0, $keep); -$droppedVersioned = array_slice($versioned, $keep); - -// Report -echo "CHANGELOG: {$totalVersioned} versioned entries found\n"; -echo " Keeping: {$keep} most recent\n"; -echo " Pruning: {$pruned} old entries\n"; - -foreach ($droppedVersioned as $section) { - $heading = trim($section['heading']); - echo " - {$heading}\n"; -} - -if ($dryRun) { - echo "\n(dry-run) No changes written\n"; - exit(0); -} - -// Rebuild the file -$output = implode("\n", $header); - -if ($unreleased !== null) { - $output .= implode("\n", $unreleased['lines']) . "\n"; -} - -foreach ($keptVersioned as $section) { - $output .= implode("\n", $section['lines']) . "\n"; -} - -// Clean up excessive blank lines at end -$output = rtrim($output) . "\n"; - -file_put_contents($changelog, $output); -echo "\nCHANGELOG pruned: removed {$pruned} old entries\n"; -exit(0); +$app = new ChangelogPruneCli(); +exit($app->execute()); diff --git a/cli/client_dashboard.php b/cli/client_dashboard.php index d22502b..bf0a119 100644 --- a/cli/client_dashboard.php +++ b/cli/client_dashboard.php @@ -12,13 +12,17 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/client_dashboard.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Generate unified client dashboard HTML */ declare(strict_types=1); -final class ClientDashboard +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class ClientDashboardCli extends CliFramework { private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $token = ''; @@ -29,29 +33,47 @@ final class ClientDashboard private int $sslWarnDays = 30; private int $httpTimeout = 10; - public function run(): int + protected function configure(): void { - $this->parseArgs(); + $this->setDescription('Generate unified client dashboard HTML'); + $this->addArgument('--token', 'Gitea token (or MOKOGITEA_TOKEN)', ''); + $this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech'); + $this->addArgument('--org', 'Primary org (default: MokoConsulting)', 'MokoConsulting'); + $this->addArgument('--output', 'Output HTML file (default: stdout)', ''); + $this->addArgument('-o', 'Output HTML file (alias)', ''); + $this->addArgument('--no-ssl', 'Skip SSL checks', false); + $this->addArgument('--no-uptime', 'Skip HTTP uptime checks', false); + $this->addArgument('--ssl-warn-days', 'SSL warning days (default: 30)', '30'); + } + + protected function run(): int + { + $this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); + $this->token = $this->getArgument('--token'); + $this->org = $this->getArgument('--org'); + $this->outputFile = $this->getArgument('--output') ?: $this->getArgument('-o'); + $this->checkSsl = !$this->getArgument('--no-ssl'); + $this->checkUptime = !$this->getArgument('--no-uptime'); + $this->sslWarnDays = (int) $this->getArgument('--ssl-warn-days'); if ($this->token === '') { $this->token = getenv('MOKOGITEA_TOKEN') ?: ''; } if ($this->token === '') { - $this->log('ERROR: --token or MOKOGITEA_TOKEN required.'); - $this->printUsage(); + $this->log('ERROR', '--token or MOKOGITEA_TOKEN required.'); return 1; } - $this->log('Gathering client data...'); + $this->log('INFO', 'Gathering client data...'); $clients = $this->discoverClients(); if ($clients === null) { - $this->log('ERROR: Could not fetch client repos.'); + $this->log('ERROR', 'Could not fetch client repos.'); return 1; } - $this->log('Found ' . count($clients) . ' client(s).'); + $this->log('INFO', 'Found ' . count($clients) . ' client(s).'); foreach ($clients as &$client) { $this->enrichClient($client); @@ -63,7 +85,7 @@ final class ClientDashboard if ($this->outputFile !== '') { file_put_contents($this->outputFile, $html); - $this->log("Dashboard: {$this->outputFile}"); + $this->log('INFO', "Dashboard: {$this->outputFile}"); } else { fwrite(STDOUT, $html); } @@ -151,9 +173,8 @@ final class ClientDashboard private function enrichClient(array &$client): void { $repo = $client['repo']; - $this->log(" Checking {$client['name']}..."); + $this->log('INFO', " Checking {$client['name']}..."); - // Fetch variables $resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables"); $vars = []; @@ -185,7 +206,6 @@ final class ClientDashboard } } - // SSL $client['ssl_expiry'] = null; $client['ssl_days'] = null; $client['ssl_status'] = 'unknown'; @@ -212,7 +232,6 @@ final class ClientDashboard } } - // Last release $client['last_release'] = ''; $client['last_release_date'] = ''; $relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1"); @@ -461,69 +480,7 @@ CARD; curl_close($ch); return ['code' => $code, 'body' => $body]; } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) { - switch ($args[$i]) { - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--gitea-url': - $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); - break; - case '--org': - $this->org = $args[++$i] ?? ''; - break; - case '--output': - case '-o': - $this->outputFile = $args[++$i] ?? ''; - break; - case '--no-ssl': - $this->checkSsl = false; - break; - case '--no-uptime': - $this->checkUptime = false; - break; - case '--ssl-warn-days': - $this->sslWarnDays = (int) ($args[++$i] ?? 30); - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown arg: {$args[$i]}"); - break; - } - } - } - - private function printUsage(): void - { - $this->log('Usage: client_dashboard.php --token TOKEN [options]'); - $this->log(''); - $this->log('Generate unified client status dashboard (HTML).'); - $this->log(''); - $this->log('Options:'); - $this->log(' --token Gitea token (or MOKOGITEA_TOKEN)'); - $this->log(' --gitea-url Gitea URL'); - $this->log(' --org Primary org (default: MokoConsulting)'); - $this->log(' -o, --output Output HTML file (default: stdout)'); - $this->log(' --no-ssl Skip SSL checks'); - $this->log(' --no-uptime Skip HTTP uptime checks'); - $this->log(' --ssl-warn-days SSL warning days (default: 30)'); - $this->log(' --help, -h Show this help'); - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } } -$app = new ClientDashboard(); -exit($app->run()); +$app = new ClientDashboardCli(); +exit($app->execute()); diff --git a/cli/client_health_check.php b/cli/client_health_check.php index 09e7fed..3808511 100644 --- a/cli/client_health_check.php +++ b/cli/client_health_check.php @@ -10,179 +10,188 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/client_health_check.php * BRIEF: Verify a client site's update server, installed version, and release availability - * - * Usage: - * php client_health_check.php --update-url URL - * php client_health_check.php --path /repo --github-output - * - * Options: - * --path Repository root (reads update server URL from manifest) - * --update-url Update server XML URL (overrides manifest) - * --site-url Live site URL for version checking via Joomla API (optional) - * --api-token Joomla API token for site-url (optional) - * --github-output Export results to $GITHUB_OUTPUT */ declare(strict_types=1); -$path = '.'; -$updateUrl = null; -$siteUrl = null; -$apiToken = null; -$ghOutput = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--update-url' && isset($argv[$i + 1])) $updateUrl = $argv[$i + 1]; - if ($arg === '--site-url' && isset($argv[$i + 1])) $siteUrl = $argv[$i + 1]; - if ($arg === '--api-token' && isset($argv[$i + 1])) $apiToken = $argv[$i + 1]; - if ($arg === '--github-output') $ghOutput = true; +use MokoEnterprise\CliFramework; + +class ClientHealthCheckCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Verify a client site\'s update server, installed version, and release availability'); + $this->addArgument('--path', 'Repository root (reads update server URL from manifest)', '.'); + $this->addArgument('--update-url', 'Update server XML URL (overrides manifest)', ''); + $this->addArgument('--site-url', 'Live site URL for version checking via Joomla API', ''); + $this->addArgument('--api-token', 'Joomla API token for site-url', ''); + $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $updateUrl = $this->getArgument('--update-url'); + $siteUrl = $this->getArgument('--site-url'); + $apiToken = $this->getArgument('--api-token'); + $ghOutput = $this->getArgument('--github-output'); + + $root = realpath($path) ?: $path; + $checks = []; + + // -- Resolve update server URL from manifest -- + if ($updateUrl === '') { + $updateUrl = null; + $searchDirs = ["{$root}/src", $root]; + foreach ($searchDirs as $dir) { + if (!is_dir($dir)) { + continue; + } + foreach (glob("{$dir}/*.xml") ?: [] as $f) { + $xml = file_get_contents($f); + if (preg_match('/]*>([^<]+)<\/server>/', $xml, $m)) { + $updateUrl = trim($m[1]); + break 2; + } + } + } + } + + if ($updateUrl === null || $updateUrl === '') { + $this->log('ERROR', 'No update server URL found. Use --update-url or provide a manifest with .'); + return 1; + } + + echo "Update server: {$updateUrl}\n\n"; + + // -- Check 1: Update server accessible -- + echo "--- Update Server ---\n"; + $ch = curl_init($updateUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'], + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200 && !empty($response)) { + echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n"; + $checks['update_server'] = 'pass'; + } else { + echo " FAIL: HTTP {$httpCode}\n"; + $checks['update_server'] = 'fail'; + } + + // -- Check 2: Parse updates.xml for stable version -- + $stableVersion = null; + $downloadUrl = null; + + if (!empty($response)) { + $sections = preg_split('//', $response); + foreach ($sections as $section) { + if (strpos($section, 'stable') !== false) { + if (preg_match('/([^<]+)<\/version>/', $section, $m)) { + $stableVersion = $m[1]; + } + if (preg_match('/]*>([^<]+)<\/downloadurl>/', $section, $m)) { + $downloadUrl = trim($m[1]); + } + break; + } + } + + if ($stableVersion === null && preg_match('/([^<]+)<\/version>/', $response, $m)) { + $stableVersion = $m[1]; + } + } + + echo "\n--- Stable Release ---\n"; + if ($stableVersion !== null) { + echo " Version: {$stableVersion}\n"; + $checks['stable_version'] = $stableVersion; + } else { + echo " FAIL: Could not parse stable version\n"; + $checks['stable_version'] = 'fail'; + } + + // -- Check 3: Download URL accessible -- + if ($downloadUrl !== null) { + echo "\n--- Download URL ---\n"; + $ch = curl_init($downloadUrl); + curl_setopt_array($ch, [ + CURLOPT_NOBODY => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_FOLLOWLOCATION => true, + ]); + curl_exec($ch); + $dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); + curl_close($ch); + + if ($dlCode === 200) { + $sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size'; + echo " PASS: HTTP {$dlCode}, {$sizeKb}\n"; + $checks['download'] = 'pass'; + } else { + echo " FAIL: HTTP {$dlCode}\n"; + $checks['download'] = 'fail'; + } + } + + // -- Check 4: Site version (optional) -- + if ($siteUrl !== '' && $apiToken !== '') { + echo "\n--- Site Version ---\n"; + $apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file'; + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_HTTPHEADER => [ + "X-Joomla-Token: {$apiToken}", + 'Accept: application/json', + ], + ]); + $siteResponse = curl_exec($ch); + $siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($siteCode === 200) { + echo " API accessible (HTTP {$siteCode})\n"; + $checks['site_api'] = 'pass'; + } else { + echo " WARN: Site API returned HTTP {$siteCode}\n"; + $checks['site_api'] = 'warn'; + } + } + + // -- Summary -- + echo "\n=== Health Check Summary ===\n"; + $failed = 0; + foreach ($checks as $name => $result) { + $icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK'); + if ($result === 'fail') { + $failed++; + } + echo " {$icon}: {$name} = {$result}\n"; + } + + if ($ghOutput) { + $ghFile = getenv('GITHUB_OUTPUT'); + if ($ghFile) { + file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND); + file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND); + file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND); + } + } + + return $failed > 0 ? 1 : 0; + } } -$root = realpath($path) ?: $path; -$checks = []; - -// ── Resolve update server URL from manifest ───────────────────────────── -if ($updateUrl === null) { - $searchDirs = ["{$root}/src", $root]; - foreach ($searchDirs as $dir) { - if (!is_dir($dir)) continue; - foreach (glob("{$dir}/*.xml") ?: [] as $f) { - $xml = file_get_contents($f); - if (preg_match('/]*>([^<]+)<\/server>/', $xml, $m)) { - $updateUrl = trim($m[1]); - break 2; - } - } - } -} - -if ($updateUrl === null) { - fwrite(STDERR, "No update server URL found. Use --update-url or provide a manifest with .\n"); - exit(1); -} - -echo "Update server: {$updateUrl}\n\n"; - -// ── Check 1: Update server accessible ─────────────────────────────────── -echo "--- Update Server ---\n"; -$ch = curl_init($updateUrl); -curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 15, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_HTTPHEADER => ['User-Agent: MokoHealthCheck/1.0'], -]); -$response = curl_exec($ch); -$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); - -if ($httpCode === 200 && !empty($response)) { - echo " PASS: HTTP {$httpCode}, " . strlen($response) . " bytes\n"; - $checks['update_server'] = 'pass'; -} else { - echo " FAIL: HTTP {$httpCode}\n"; - $checks['update_server'] = 'fail'; -} - -// ── Check 2: Parse updates.xml for stable version ─────────────────────── -$stableVersion = null; -$downloadUrl = null; - -if (!empty($response)) { - $sections = preg_split('//', $response); - foreach ($sections as $section) { - if (strpos($section, 'stable') !== false) { - if (preg_match('/([^<]+)<\/version>/', $section, $m)) { - $stableVersion = $m[1]; - } - if (preg_match('/]*>([^<]+)<\/downloadurl>/', $section, $m)) { - $downloadUrl = trim($m[1]); - } - break; - } - } - - if ($stableVersion === null && preg_match('/([^<]+)<\/version>/', $response, $m)) { - $stableVersion = $m[1]; - } -} - -echo "\n--- Stable Release ---\n"; -if ($stableVersion !== null) { - echo " Version: {$stableVersion}\n"; - $checks['stable_version'] = $stableVersion; -} else { - echo " FAIL: Could not parse stable version\n"; - $checks['stable_version'] = 'fail'; -} - -// ── Check 3: Download URL accessible ──────────────────────────────────── -if ($downloadUrl !== null) { - echo "\n--- Download URL ---\n"; - $ch = curl_init($downloadUrl); - curl_setopt_array($ch, [ - CURLOPT_NOBODY => true, - CURLOPT_TIMEOUT => 15, - CURLOPT_FOLLOWLOCATION => true, - ]); - curl_exec($ch); - $dlCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $dlSize = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); - curl_close($ch); - - if ($dlCode === 200) { - $sizeKb = $dlSize > 0 ? round($dlSize / 1024) . 'KB' : 'unknown size'; - echo " PASS: HTTP {$dlCode}, {$sizeKb}\n"; - $checks['download'] = 'pass'; - } else { - echo " FAIL: HTTP {$dlCode}\n"; - $checks['download'] = 'fail'; - } -} - -// ── Check 4: Site version (optional) ──────────────────────────────────── -if ($siteUrl !== null && $apiToken !== null) { - echo "\n--- Site Version ---\n"; - $apiUrl = rtrim($siteUrl, '/') . '/api/index.php/v1/extensions?filter[type]=file'; - $ch = curl_init($apiUrl); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 15, - CURLOPT_HTTPHEADER => [ - "X-Joomla-Token: {$apiToken}", - 'Accept: application/json', - ], - ]); - $siteResponse = curl_exec($ch); - $siteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($siteCode === 200) { - echo " API accessible (HTTP {$siteCode})\n"; - $checks['site_api'] = 'pass'; - } else { - echo " WARN: Site API returned HTTP {$siteCode}\n"; - $checks['site_api'] = 'warn'; - } -} - -// ── Summary ───────────────────────────────────────────────────────────── -echo "\n=== Health Check Summary ===\n"; -$failed = 0; -foreach ($checks as $name => $result) { - $icon = ($result === 'fail') ? 'FAIL' : (($result === 'warn') ? 'WARN' : 'OK'); - if ($result === 'fail') $failed++; - echo " {$icon}: {$name} = {$result}\n"; -} - -if ($ghOutput) { - $ghFile = getenv('GITHUB_OUTPUT'); - if ($ghFile) { - file_put_contents($ghFile, "health_status=" . ($failed > 0 ? 'fail' : 'pass') . "\n", FILE_APPEND); - file_put_contents($ghFile, "health_version=" . ($stableVersion ?? 'unknown') . "\n", FILE_APPEND); - file_put_contents($ghFile, "health_failures={$failed}\n", FILE_APPEND); - } -} - -exit($failed > 0 ? 1 : 0); +$app = new ClientHealthCheckCli(); +exit($app->execute()); diff --git a/cli/client_inventory.php b/cli/client_inventory.php index 40e9359..c7804c3 100644 --- a/cli/client_inventory.php +++ b/cli/client_inventory.php @@ -11,324 +11,261 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/client_inventory.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Discover and list all client-waas repos with their server configuration status */ declare(strict_types=1); -final class ClientInventory +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class ClientInventoryCli extends CliFramework { - private string $giteaUrl = 'https://git.mokoconsulting.tech'; - private string $token = ''; - private bool $jsonOutput = false; + private string $giteaUrl = 'https://git.mokoconsulting.tech'; + private string $token = ''; + private bool $jsonOutput = false; - public function run(): int - { - $this->parseArgs(); + protected function configure(): void + { + $this->setDescription('Discover and list all client-waas repos with their server configuration status'); + $this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech'); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--json', 'Output results as JSON', false); + } - if ($this->token === '') - { - $this->log('ERROR: --token is required.'); - $this->printUsage(); - return 1; - } + protected function run(): int + { + $this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); + $this->token = $this->getArgument('--token'); + $this->jsonOutput = (bool) $this->getArgument('--json'); - $this->log("Scanning Gitea instance: {$this->giteaUrl}"); + if ($this->token === '') { + $this->log('ERROR', '--token is required.'); + return 1; + } - // Step 1: List all orgs - $orgs = $this->fetchOrgs(); + $this->log('INFO', "Scanning Gitea instance: {$this->giteaUrl}"); - if ($orgs === null) - { - $this->log('ERROR: Failed to fetch organizations.'); - return 1; - } + // Step 1: List all orgs + $orgs = $this->fetchOrgs(); - $this->log('Found ' . count($orgs) . ' organization(s).'); + if ($orgs === null) { + $this->log('ERROR', 'Failed to fetch organizations.'); + return 1; + } - // Step 2 & 3: For each org, find client-waas repos - $inventory = []; + $this->log('INFO', 'Found ' . count($orgs) . ' organization(s).'); - foreach ($orgs as $org) - { - $orgName = $org['username'] ?? $org['name'] ?? ''; + // Step 2 & 3: For each org, find client-waas repos + $inventory = []; - if ($orgName === '') - { - continue; - } + foreach ($orgs as $org) { + $orgName = $org['username'] ?? $org['name'] ?? ''; - $repos = $this->fetchOrgRepos($orgName); + if ($orgName === '') { + continue; + } - if ($repos === null) - { - $this->log("WARNING: Could not fetch repos for org: {$orgName}"); - continue; - } + $repos = $this->fetchOrgRepos($orgName); - foreach ($repos as $repo) - { - $repoName = $repo['name'] ?? ''; + if ($repos === null) { + $this->log('WARNING', "Could not fetch repos for org: {$orgName}"); + continue; + } - if (strpos($repoName, 'client-waas') === false) - { - continue; - } + foreach ($repos as $repo) { + $repoName = $repo['name'] ?? ''; - $hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']); - $hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']); + if (strpos($repoName, 'client-waas') === false) { + continue; + } - $lastPush = $repo['updated_at'] ?? 'unknown'; + $hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']); + $hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']); - if ($lastPush !== 'unknown') - { - $lastPush = substr($lastPush, 0, 19); - } + $lastPush = $repo['updated_at'] ?? 'unknown'; - $status = 'OK'; + if ($lastPush !== 'unknown') { + $lastPush = substr($lastPush, 0, 19); + } - if (!$hasDevConfig && !$hasLiveConfig) - { - $status = 'UNCONFIGURED'; - } - elseif (!$hasDevConfig) - { - $status = 'NO DEV'; - } - elseif (!$hasLiveConfig) - { - $status = 'NO LIVE'; - } + $status = 'OK'; - $inventory[] = [ - 'org' => $orgName, - 'repo' => $repoName, - 'has_dev_config' => $hasDevConfig, - 'has_live_config' => $hasLiveConfig, - 'last_push' => $lastPush, - 'status' => $status, - ]; - } - } + if (!$hasDevConfig && !$hasLiveConfig) { + $status = 'UNCONFIGURED'; + } elseif (!$hasDevConfig) { + $status = 'NO DEV'; + } elseif (!$hasLiveConfig) { + $status = 'NO LIVE'; + } - // Output results - if ($this->jsonOutput) - { - fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); - return 0; - } + $inventory[] = [ + 'org' => $orgName, + 'repo' => $repoName, + 'has_dev_config' => $hasDevConfig, + 'has_live_config' => $hasLiveConfig, + 'last_push' => $lastPush, + 'status' => $status, + ]; + } + } - if (count($inventory) === 0) - { - $this->log('No client-waas repos found.'); - return 0; - } + // Output results + if ($this->jsonOutput) { + fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + return 0; + } - // Print table - $this->log(''); - $this->log(sprintf( - '%-20s | %-35s | %-10s | %-11s | %-19s | %s', - 'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status' - )); - $this->log(str_repeat('-', 120)); + if (count($inventory) === 0) { + $this->log('INFO', 'No client-waas repos found.'); + return 0; + } - foreach ($inventory as $entry) - { - $this->log(sprintf( - '%-20s | %-35s | %-10s | %-11s | %-19s | %s', - $entry['org'], - $entry['repo'], - $entry['has_dev_config'] ? 'Yes' : 'No', - $entry['has_live_config'] ? 'Yes' : 'No', - $entry['last_push'], - $entry['status'] - )); - } + // Print table + $this->log('INFO', ''); + $this->log('INFO', sprintf( + '%-20s | %-35s | %-10s | %-11s | %-19s | %s', + 'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status' + )); + $this->log('INFO', str_repeat('-', 120)); - $this->log(''); - $this->log('Total: ' . count($inventory) . ' client-waas repo(s).'); + foreach ($inventory as $entry) { + $this->log('INFO', sprintf( + '%-20s | %-35s | %-10s | %-11s | %-19s | %s', + $entry['org'], + $entry['repo'], + $entry['has_dev_config'] ? 'Yes' : 'No', + $entry['has_live_config'] ? 'Yes' : 'No', + $entry['last_push'], + $entry['status'] + )); + } - return 0; - } + $this->log('INFO', ''); + $this->log('INFO', 'Total: ' . count($inventory) . ' client-waas repo(s).'); - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); + return 0; + } - for ($i = 1; $i < $count; $i++) - { - switch ($args[$i]) - { - case '--gitea-url': - $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); - break; - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--json': - $this->jsonOutput = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown argument: {$args[$i]}"); - break; - } - } - } + private function fetchOrgs(): ?array + { + // Try admin endpoint first, fall back to user-visible orgs + $response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50'); - private function printUsage(): void - { - $this->log('Usage: client_inventory.php --token [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --token Gitea API token'); - $this->log(' --json Output results as JSON'); - $this->log(' --help, -h Show this help'); - } + if ($response['code'] >= 200 && $response['code'] < 300) { + $data = json_decode($response['body'], true); - private function fetchOrgs(): ?array - { - // Try admin endpoint first, fall back to user-visible orgs - $response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50'); + if (is_array($data)) { + return $data; + } + } - if ($response['code'] >= 200 && $response['code'] < 300) - { - $data = json_decode($response['body'], true); + $this->log('INFO', 'Admin orgs endpoint unavailable, falling back to user orgs...'); - if (is_array($data)) - { - return $data; - } - } + $response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50'); - $this->log('Admin orgs endpoint unavailable, falling back to user orgs...'); + if ($response['code'] >= 200 && $response['code'] < 300) { + $data = json_decode($response['body'], true); - $response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50'); + if (is_array($data)) { + return $data; + } + } - if ($response['code'] >= 200 && $response['code'] < 300) - { - $data = json_decode($response['body'], true); + return null; + } - if (is_array($data)) - { - return $data; - } - } + private function fetchOrgRepos(string $org): ?array + { + $page = 1; + $allRepos = []; - return null; - } + while (true) { + $response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}"); - private function fetchOrgRepos(string $org): ?array - { - $page = 1; - $allRepos = []; + if ($response['code'] < 200 || $response['code'] >= 300) { + return $page === 1 ? null : $allRepos; + } - while (true) - { - $response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}"); + $data = json_decode($response['body'], true); - if ($response['code'] < 200 || $response['code'] >= 300) - { - return $page === 1 ? null : $allRepos; - } + if (!is_array($data) || count($data) === 0) { + break; + } - $data = json_decode($response['body'], true); + $allRepos = array_merge($allRepos, $data); + $page++; + } - if (!is_array($data) || count($data) === 0) - { - break; - } + return $allRepos; + } - $allRepos = array_merge($allRepos, $data); - $page++; - } + private function checkVariables(string $org, string $repo, array $requiredVars): bool + { + $response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables"); - return $allRepos; - } + if ($response['code'] < 200 || $response['code'] >= 300) { + return false; + } - private function checkVariables(string $org, string $repo, array $requiredVars): bool - { - $response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables"); + $data = json_decode($response['body'], true); - if ($response['code'] < 200 || $response['code'] >= 300) - { - return false; - } + if (!is_array($data)) { + return false; + } - $data = json_decode($response['body'], true); + $existingVars = []; - if (!is_array($data)) - { - return false; - } + foreach ($data as $variable) { + if (isset($variable['name'])) { + $existingVars[] = $variable['name']; + } + } - $existingVars = []; + foreach ($requiredVars as $var) { + if (!in_array($var, $existingVars, true)) { + return false; + } + } - foreach ($data as $variable) - { - if (isset($variable['name'])) - { - $existingVars[] = $variable['name']; - } - } + return true; + } - foreach ($requiredVars as $var) - { - if (!in_array($var, $existingVars, true)) - { - return false; - } - } + private function apiRequest(string $method, string $endpoint, ?string $body = null): array + { + $url = $this->giteaUrl . $endpoint; - return true; - } + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Accept: application/json', + "Authorization: token {$this->token}", + ]); - private function apiRequest(string $method, string $endpoint, ?string $body = null): array - { - $url = $this->giteaUrl . $endpoint; + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Accept: application/json', - "Authorization: token {$this->token}", - ]); + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($body !== null) - { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); - $responseBody = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + return ['code' => 0, 'body' => "cURL error: {$error}"]; + } - if (curl_errno($ch)) - { - $error = curl_error($ch); - curl_close($ch); + curl_close($ch); - return ['code' => 0, 'body' => "cURL error: {$error}"]; - } - - curl_close($ch); - - return ['code' => $httpCode, 'body' => $responseBody]; - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } + return ['code' => $httpCode, 'body' => $responseBody]; + } } -$app = new ClientInventory(); -exit($app->run()); +$app = new ClientInventoryCli(); +exit($app->execute()); diff --git a/cli/client_provision.php b/cli/client_provision.php index 7448251..7e3b853 100644 --- a/cli/client_provision.php +++ b/cli/client_provision.php @@ -12,13 +12,17 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/client_provision.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Provision a new client environment end-to-end */ declare(strict_types=1); -final class ClientProvision +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class ClientProvisionCli extends CliFramework { private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaToken = ''; @@ -26,24 +30,30 @@ final class ClientProvision private string $grafanaToken = ''; private string $configFile = ''; private string $step = ''; - private bool $dryRun = false; /** @var array */ private array $config = []; private string $org = ''; private string $repoName = ''; - public function run(): int + protected function configure(): void { - $this->parseArgs(); + $this->setDescription('Provision a new client environment end-to-end'); + $this->addArgument('--config', 'Client config JSON', ''); + $this->addArgument('--step', 'Run one step: repo, variables, secrets, monitoring, summary', ''); + } + + protected function run(): int + { + $this->configFile = $this->getArgument('--config'); + $this->step = $this->getArgument('--step'); if ($this->configFile === '') { - $this->log('ERROR: --config is required.'); - $this->printUsage(); + $this->log('ERROR', '--config is required.'); return 1; } if (!file_exists($this->configFile)) { - $this->log("ERROR: Not found: {$this->configFile}"); + $this->log('ERROR', "Not found: {$this->configFile}"); return 1; } @@ -51,7 +61,7 @@ final class ClientProvision $this->config = json_decode($json, true); if (!is_array($this->config)) { - $this->log('ERROR: Invalid JSON in config file.'); + $this->log('ERROR', 'Invalid JSON in config file.'); return 1; } @@ -65,7 +75,7 @@ final class ClientProvision ?? $this->giteaUrl; if ($this->giteaToken === '') { - $this->log('ERROR: gitea_token or MOKOGITEA_TOKEN required.'); + $this->log('ERROR', 'gitea_token or MOKOGITEA_TOKEN required.'); return 1; } @@ -73,21 +83,21 @@ final class ClientProvision $clientName = $this->config['name'] ?? ''; if ($this->org === '' || $clientName === '') { - $this->log('ERROR: "org" and "name" required in config.'); + $this->log('ERROR', '"org" and "name" required in config.'); return 1; } $this->repoName = 'client-waas-' . $clientName; - $this->log("=== Client Provisioning: {$clientName} ==="); - $this->log(" Org: {$this->org}"); - $this->log(" Repo: {$this->repoName}"); + $this->log('INFO', "=== Client Provisioning: {$clientName} ==="); + $this->log('INFO', " Org: {$this->org}"); + $this->log('INFO', " Repo: {$this->repoName}"); if ($this->dryRun) { - $this->log(' Mode: DRY RUN'); + $this->log('INFO', ' Mode: DRY RUN'); } - $this->log(''); + echo "\n"; $steps = [ 'repo' => 'createRepo', @@ -116,7 +126,7 @@ final class ClientProvision private function createRepo(): int { - $this->log('[1/5] Creating repository...'); + $this->log('INFO', '[1/5] Creating repository...'); $check = $this->giteaApi( 'GET', @@ -124,14 +134,12 @@ final class ClientProvision ); if ($check['code'] === 200) { - $this->log(" SKIP: repo already exists"); + $this->log('INFO', ' SKIP: repo already exists'); return 0; } if ($this->dryRun) { - $this->log( - " WOULD CREATE: {$this->org}/{$this->repoName}" - ); + $this->log('INFO', " WOULD CREATE: {$this->org}/{$this->repoName}"); return 0; } @@ -153,11 +161,11 @@ final class ClientProvision ); if ($resp['code'] < 200 || $resp['code'] >= 300) { - $this->log(" ERROR: HTTP {$resp['code']}"); + $this->log('ERROR', "HTTP {$resp['code']}"); return 1; } - $this->log(' OK: Repo created'); + $this->log('INFO', ' OK: Repo created'); $this->giteaApi( 'POST', @@ -168,19 +176,19 @@ final class ClientProvision ]) ); - $this->log(' OK: dev branch created'); + $this->log('INFO', ' OK: dev branch created'); return 0; } private function setVariables(): int { - $this->log('[2/5] Setting repo variables...'); + $this->log('INFO', '[2/5] Setting repo variables...'); $vars = $this->config['variables'] ?? []; if (empty($vars)) { - $this->log(' SKIP: No variables in config'); + $this->log('INFO', ' SKIP: No variables in config'); return 0; } @@ -192,16 +200,16 @@ final class ClientProvision if ($this->dryRun) { $display = strlen($value) > 40 ? substr($value, 0, 37) . '...' : $value; - $this->log(" WOULD SET: {$name} = {$display}"); + $this->log('INFO', " WOULD SET: {$name} = {$display}"); continue; } $ok = $this->setOrCreateVariable($api, $name, $value); if ($ok) { - $this->log(" OK: {$name}"); + $this->log('INFO', " OK: {$name}"); } else { - $this->log(" ERROR: {$name}"); + $this->log('ERROR', " {$name}"); $errors++; } } @@ -211,12 +219,12 @@ final class ClientProvision private function setSecrets(): int { - $this->log('[3/5] Setting repo secrets...'); + $this->log('INFO', '[3/5] Setting repo secrets...'); $secrets = $this->config['secrets'] ?? []; if (empty($secrets)) { - $this->log(' SKIP: No secrets in config'); + $this->log('INFO', ' SKIP: No secrets in config'); return 0; } @@ -229,7 +237,7 @@ final class ClientProvision $keyPath = substr($value, 1); if (!file_exists($keyPath)) { - $this->log(" ERROR: {$name} file not found: {$keyPath}"); + $this->log('ERROR', " {$name} file not found: {$keyPath}"); $errors++; continue; } @@ -238,7 +246,7 @@ final class ClientProvision } if ($this->dryRun) { - $this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")"); + $this->log('INFO', " WOULD SET: {$name} (len: " . strlen($value) . ")"); continue; } @@ -249,9 +257,9 @@ final class ClientProvision ); if ($resp['code'] >= 200 && $resp['code'] < 300) { - $this->log(" OK: {$name}"); + $this->log('INFO', " OK: {$name}"); } else { - $this->log(" ERROR: {$name} (HTTP {$resp['code']})"); + $this->log('ERROR', " {$name} (HTTP {$resp['code']})"); $errors++; } } @@ -261,12 +269,12 @@ final class ClientProvision private function setupMonitoring(): int { - $this->log('[4/5] Setting up monitoring...'); + $this->log('INFO', '[4/5] Setting up monitoring...'); $mon = $this->config['monitoring'] ?? []; if (empty($mon)) { - $this->log(' SKIP: No monitoring config'); + $this->log('INFO', ' SKIP: No monitoring config'); return 0; } @@ -291,10 +299,10 @@ final class ClientProvision $urlStr = implode("\n", $urls); if ($this->dryRun) { - $this->log(" WOULD SET: MONITORED_URLS"); + $this->log('INFO', ' WOULD SET: MONITORED_URLS'); } else { $this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr); - $this->log(' OK: MONITORED_URLS'); + $this->log('INFO', ' OK: MONITORED_URLS'); } } @@ -302,10 +310,10 @@ final class ClientProvision $domainStr = implode("\n", $domains); if ($this->dryRun) { - $this->log(" WOULD SET: MONITORED_DOMAINS"); + $this->log('INFO', ' WOULD SET: MONITORED_DOMAINS'); } else { $this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr); - $this->log(' OK: MONITORED_DOMAINS'); + $this->log('INFO', ' OK: MONITORED_DOMAINS'); } } @@ -315,19 +323,19 @@ final class ClientProvision private function pushGrafanaDashboard(string $file, string $folder): void { if (!file_exists($file)) { - $this->log(" WARN: Dashboard not found: {$file}"); + $this->warning("Dashboard not found: {$file}"); return; } if ($this->dryRun) { - $this->log(" WOULD PUSH: dashboard to \"{$folder}\""); + $this->log('INFO', " WOULD PUSH: dashboard to \"{$folder}\""); return; } $dashboard = json_decode(file_get_contents($file), true); if (!is_array($dashboard)) { - $this->log(' ERROR: Invalid dashboard JSON'); + $this->log('ERROR', 'Invalid dashboard JSON'); return; } @@ -346,9 +354,9 @@ final class ClientProvision if ($resp['code'] === 200) { $data = json_decode($resp['body'], true); - $this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")"); + $this->log('INFO', " OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")"); } else { - $this->log(" ERROR: Dashboard push (HTTP {$resp['code']})"); + $this->log('ERROR', " Dashboard push (HTTP {$resp['code']})"); } } @@ -379,20 +387,19 @@ final class ClientProvision { $vars = $this->config['variables'] ?? []; $secrets = $this->config['secrets'] ?? []; - $clientName = $this->config['name'] ?? ''; - $this->log(''); - $this->log('[5/5] Provisioning summary'); - $this->log(str_repeat('=', 60)); - $this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}"); - $this->log(' Variables: ' . count($vars) . ' set'); - $this->log(' Secrets: ' . count($secrets) . ' set'); - $this->log(''); - $this->log('Next steps:'); - $this->log(' 1. Clone and customize the Joomla template'); - $this->log(' 2. Push to dev to trigger dev deployment'); - $this->log(' 3. Merge dev -> main for production release'); - $this->log(str_repeat('=', 60)); + echo "\n"; + $this->log('INFO', '[5/5] Provisioning summary'); + echo str_repeat('=', 60) . "\n"; + echo " Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}\n"; + echo ' Variables: ' . count($vars) . " set\n"; + echo ' Secrets: ' . count($secrets) . " set\n"; + echo "\n"; + echo "Next steps:\n"; + echo " 1. Clone and customize the Joomla template\n"; + echo " 2. Push to dev to trigger dev deployment\n"; + echo " 3. Merge dev -> main for production release\n"; + echo str_repeat('=', 60) . "\n"; return 0; } @@ -419,51 +426,6 @@ final class ClientProvision return $resp['code'] >= 200 && $resp['code'] < 300; } - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) { - switch ($args[$i]) { - case '--config': - $this->configFile = $args[++$i] ?? ''; - break; - case '--step': - $this->step = $args[++$i] ?? ''; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown arg: {$args[$i]}"); - break; - } - } - } - - private function printUsage(): void - { - $this->log('Usage: client_provision.php --config [options]'); - $this->log(''); - $this->log('Provision a new client environment end-to-end.'); - $this->log(''); - $this->log('Options:'); - $this->log(' --config Client config JSON'); - $this->log(' --step Run one step: repo, variables, secrets, monitoring, summary'); - $this->log(' --dry-run Preview without changes'); - $this->log(' --help, -h Show this help'); - $this->log(''); - $this->log('Environment variables:'); - $this->log(' MOKOGITEA_TOKEN Gitea API token'); - $this->log(' GRAFANA_URL Grafana instance URL'); - $this->log(' GRAFANA_TOKEN Grafana API token'); - } - private function giteaApi( string $method, string $endpoint, @@ -523,12 +485,7 @@ final class ClientProvision return ['code' => $httpCode, 'body' => $responseBody]; } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } } -$app = new ClientProvision(); -exit($app->run()); +$app = new ClientProvisionCli(); +exit($app->execute()); diff --git a/cli/completion.php b/cli/completion.php new file mode 100644 index 0000000..12f6c94 --- /dev/null +++ b/cli/completion.php @@ -0,0 +1,168 @@ +#!/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/completion.php + * BRIEF: Generate bash/zsh tab completion scripts for bin/moko + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class CompletionCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Generate bash/zsh tab completion scripts for bin/moko'); + $this->addArgument('--shell', 'Shell type: bash or zsh', 'bash'); + } + + protected function run(): int + { + $shell = $this->getArgument('--shell'); + + // Also accept positional-style: check raw argv for bash/zsh + global $argv; + foreach ($argv as $arg) { + if (in_array($arg, ['bash', 'zsh'], true)) { + $shell = $arg; + break; + } + } + + // Extract command names from bin/moko COMMAND_MAP using regex (no eval). + $mokoFile = dirname(__DIR__) . '/bin/moko'; + $content = file_get_contents($mokoFile); + + // Isolate the COMMAND_MAP block, then extract keys. + if (!preg_match('/const COMMAND_MAP\s*=\s*\[(.+?)\];/s', $content, $block)) { + $this->log('ERROR', 'Could not find COMMAND_MAP in bin/moko'); + return 1; + } + // Match 'command-name' => 'path' entries within the block. + if (!preg_match_all("/'([a-z][a-z0-9:_-]*)'\s*=>/m", $block[1], $matches)) { + $this->log('ERROR', 'Could not parse command names from COMMAND_MAP'); + return 1; + } + + $commandNames = array_unique($matches[1]); + sort($commandNames); + + // Common flags supported by CliFramework. + $commonFlags = ['--help', '--verbose', '--quiet', '--dry-run', '--json', '--no-color', '--path']; + + if ($shell === 'zsh') { + $this->generateZsh($commandNames, $commonFlags); + } else { + $this->generateBash($commandNames, $commonFlags); + } + + return 0; + } + + // -- Generators -- + + private function generateBash(array $commands, array $flags): void + { + $cmdList = implode(' ', $commands); + $flagList = implode(' ', $flags); + + echo << 'Show help for the command', + '--verbose' => 'Show detailed output', + '--quiet' => 'Suppress non-error output', + '--dry-run' => 'Preview changes without writing', + '--json' => 'Machine-readable JSON output', + '--no-color' => 'Disable ANSI colour output', + '--path' => 'Repository root path', + default => $flag, + }; + $flagLines .= " '{$flag}[{$desc}]'\n"; + } + + echo <<execute()); diff --git a/cli/create_project.php b/cli/create_project.php index 6a738d0..46feac8 100644 --- a/cli/create_project.php +++ b/cli/create_project.php @@ -12,469 +12,425 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/create_project.php * BRIEF: Create baseline GitHub Projects for repositories with standard fields and views - * - * USAGE - * php cli/create_project.php --repo MokoCRM # Auto-detect type, create project - * php cli/create_project.php --repo MokoCRM --type dolibarr # Force type - * php cli/create_project.php --org mokoconsulting-tech --all # All repos without projects - * php cli/create_project.php --repo MokoCRM --dry-run # Preview without changes */ declare(strict_types=1); -$dryRun = in_array('--dry-run', $argv); -$allMode = in_array('--all', $argv); +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -$org = 'mokoconsulting-tech'; -$repoName = null; -$typeOverride = null; +use MokoEnterprise\CliFramework; -foreach ($argv as $i => $arg) { - if ($arg === '--repo' && isset($argv[$i + 1])) { - $repoName = $argv[$i + 1]; - } - if ($arg === '--org' && isset($argv[$i + 1])) { - $org = $argv[$i + 1]; - } - if ($arg === '--type' && isset($argv[$i + 1])) { - $typeOverride = $argv[$i + 1]; - } -} - -if (!$repoName && !$allMode) { - fwrite(STDERR, "Usage: php create_project.php --repo [--type ] [--dry-run]\n"); - fwrite(STDERR, " php create_project.php --all [--org ] [--dry-run]\n"); - fwrite(STDERR, "\nTypes: generic, dolibarr, joomla, nodejs, terraform, python, wordpress, mobile-app, api, documentation\n"); - exit(2); -} - -$config = \MokoEnterprise\Config::load(); -$platform = $config->getString('platform', 'gitea'); -try { - $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); - $api = $adapter->getApiClient(); -} catch (\Exception $e) { - fwrite(STDERR, "Platform initialization failed: " . $e->getMessage() . "\n"); - exit(1); -} -$token = $platform === 'gitea' - ? $config->getString('gitea.token', '') - : $config->getString('github.token', ''); - -$repoRoot = dirname(__DIR__, 2); -$templatesDir = "{$repoRoot}/templates/projects"; - -// ── Always-exclude list (no project needed) ───────────────────────────── -$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; - -// ── Platform type map ─────────────────────────────────────────────────── -$PLATFORM_TO_TYPE = [ - 'crm-module' => 'dolibarr', - 'crm-platform' => 'dolibarr', - 'waas-component' => 'joomla', - 'waas-library' => 'joomla', - 'waas-plugin' => 'joomla', - 'waas-package' => 'joomla', - 'nodejs' => 'nodejs', - 'terraform' => 'terraform', - 'python' => 'python', - 'wordpress' => 'wordpress', - 'mobile' => 'mobile-app', - 'api' => 'api', - 'documentation' => 'documentation', -]; - -// ── Template file map ─────────────────────────────────────────────────── -$TYPE_TO_TEMPLATE = [ - 'generic' => 'generic-project-definition.tf', - 'dolibarr' => 'dolibarr-project-definition.tf', - 'joomla' => 'joomla-project-definition.tf', - 'nodejs' => 'nodejs-project-definition.tf', - 'terraform' => 'terraform-project-definition.tf', - 'python' => 'python-project-definition.tf', - 'wordpress' => 'wordpress-project-definition.tf', - 'mobile-app' => 'mobile-app-project-definition.tf', - 'api' => 'api-project-definition.tf', - 'documentation' => 'documentation-project-definition.tf', -]; - -/** - * Execute a GraphQL query (GitHub only — Gitea does not support GraphQL). - * - * @return array - */ -function graphql(string $query, array $variables, string $token, string $platformName = 'gitea'): array +class CreateProjectCli extends CliFramework { - if ($platformName !== 'github') { - return []; - } - $payload = json_encode(['query' => $query, 'variables' => $variables]); - $ch = curl_init('https://api.github.com/graphql'); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $payload, - CURLOPT_HTTPHEADER => [ - 'Authorization: bearer ' . $token, - 'Content-Type: application/json', - 'User-Agent: MokoStandards-CreateProject', - ], - ]); - $body = (string) curl_exec($ch); - $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + /** @var string[] */ + private array $ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; - if ($status !== 200) { - fwrite(STDERR, "GraphQL request failed (HTTP {$status}): {$body}\n"); - return []; - } + /** @var array */ + private array $PLATFORM_TO_TYPE = [ + 'crm-module' => 'dolibarr', + 'crm-platform' => 'dolibarr', + 'waas-component' => 'joomla', + 'waas-library' => 'joomla', + 'waas-plugin' => 'joomla', + 'waas-package' => 'joomla', + 'nodejs' => 'nodejs', + 'terraform' => 'terraform', + 'python' => 'python', + 'wordpress' => 'wordpress', + 'mobile' => 'mobile-app', + 'api' => 'api', + 'documentation' => 'documentation', + ]; - $data = json_decode($body, true) ?? []; - if (!empty($data['errors'])) { - foreach ($data['errors'] as $err) { - fwrite(STDERR, " GraphQL error: " . ($err['message'] ?? 'unknown') . "\n"); - } - } + /** @var array */ + private array $TYPE_TO_TEMPLATE = [ + 'generic' => 'generic-project-definition.tf', + 'dolibarr' => 'dolibarr-project-definition.tf', + 'joomla' => 'joomla-project-definition.tf', + 'nodejs' => 'nodejs-project-definition.tf', + 'terraform' => 'terraform-project-definition.tf', + 'python' => 'python-project-definition.tf', + 'wordpress' => 'wordpress-project-definition.tf', + 'mobile-app' => 'mobile-app-project-definition.tf', + 'api' => 'api-project-definition.tf', + 'documentation' => 'documentation-project-definition.tf', + ]; - return $data['data'] ?? []; + protected function configure(): void + { + $this->setDescription('Create baseline GitHub Projects for repositories with standard fields and views'); + $this->addArgument('--repo', 'Repository name', ''); + $this->addArgument('--org', 'Organization (default: mokoconsulting-tech)', 'mokoconsulting-tech'); + $this->addArgument('--type', 'Force project type', ''); + $this->addArgument('--all', 'Process all repos without projects', false); + } + + protected function run(): int + { + $repoName = $this->getArgument('--repo') ?: null; + $org = $this->getArgument('--org'); + $typeOverride = $this->getArgument('--type') ?: null; + $allMode = $this->getArgument('--all'); + + if (!$repoName && !$allMode) { + $this->log('ERROR', "Usage: php create_project.php --repo [--type ] [--dry-run]"); + $this->log('ERROR', " php create_project.php --all [--org ] [--dry-run]"); + $this->log('ERROR', "Types: generic, dolibarr, joomla, nodejs, terraform, python, wordpress, mobile-app, api, documentation"); + return 2; + } + + $config = \MokoEnterprise\Config::load(); + $platformName = $config->getString('platform', 'gitea'); + try { + $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); + $api = $adapter->getApiClient(); + } catch (\Exception $e) { + $this->log('ERROR', "Platform initialization failed: " . $e->getMessage()); + return 1; + } + $token = $platformName === 'gitea' + ? $config->getString('gitea.token', '') + : $config->getString('github.token', ''); + + $repoRoot = dirname(__DIR__, 2); + $templatesDir = "{$repoRoot}/templates/projects"; + + $repos = []; + + if ($allMode) { + echo "Fetching repositories from {$org}...\n"; + $page = 1; + do { + $batch = $this->restGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token, $api); + foreach ($batch as $r) { + if (!$r['archived'] && !in_array($r['name'], $this->ALWAYS_EXCLUDE, true)) { + $repos[] = $r['name']; + } + } + $page++; + } while (count($batch) === 100); + + sort($repos); + echo "Found " . count($repos) . " repositories\n\n"; + } else { + $repos = [$repoName]; + } + + $ownerId = $this->getOrgNodeId($org, $token); + if (empty($ownerId)) { + $this->log('ERROR', "Could not resolve org node ID for {$org}"); + return 1; + } + + $created = 0; + $skipped = 0; + $failed = 0; + + foreach ($repos as $repo) { + echo "Processing {$repo}...\n"; + + [$hasProject, $existingTitle] = $this->repoHasProject($org, $repo, $token); + if ($hasProject) { + echo " Already has project: {$existingTitle} -- skipping\n"; + $skipped++; + continue; + } + + $type = $typeOverride; + if (!$type) { + $platform = $this->detectRepoPlatform($org, $repo, $token, $api); + $type = $this->PLATFORM_TO_TYPE[$platform] ?? 'generic'; + echo " Platform: {$platform} -> type: {$type}\n"; + } + + $templateFile = $this->TYPE_TO_TEMPLATE[$type] ?? $this->TYPE_TO_TEMPLATE['generic']; + $template = $this->parseTemplate("{$templatesDir}/{$templateFile}"); + + $repoId = $this->getRepoNodeId($org, $repo, $token); + if (empty($repoId)) { + $this->log('ERROR', " Could not resolve repo node ID for {$repo}"); + $failed++; + continue; + } + + $ok = $this->createProject($org, $repo, $ownerId, $repoId, $template, $token); + if ($ok) { + $created++; + } else { + $failed++; + } + + echo "\n"; + } + + echo str_repeat('-', 50) . "\n"; + echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n"; + return $failed > 0 ? 1 : 0; + } + + private function graphql(string $query, array $variables, string $token, string $platformName = 'gitea'): array + { + if ($platformName !== 'github') { + return []; + } + $payload = json_encode(['query' => $query, 'variables' => $variables]); + $ch = curl_init('https://api.github.com/graphql'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: bearer ' . $token, + 'Content-Type: application/json', + 'User-Agent: moko-platform-CreateProject', + ], + ]); + $body = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($status !== 200) { + $this->log('ERROR', "GraphQL request failed (HTTP {$status}): {$body}"); + return []; + } + + $data = json_decode($body, true) ?? []; + if (!empty($data['errors'])) { + foreach ($data['errors'] as $err) { + $this->log('ERROR', " GraphQL error: " . ($err['message'] ?? 'unknown')); + } + } + + return $data['data'] ?? []; + } + + private function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): array + { + if ($apiClient !== null) { + try { + return $apiClient->get("/{$path}"); + } catch (\Exception $e) { + return []; + } + } + return []; + } + + private function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string + { + foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) { + $data = $this->restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient); + if (!empty($data['content'])) { + $content = base64_decode($data['content']); + if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { + return trim($m[1], " \t\n\r\"'"); + } + } + } + return ''; + } + + private function getRepoNodeId(string $org, string $repo, string $token): string + { + $data = $this->graphql( + 'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }', + ['owner' => $org, 'name' => $repo], + $token + ); + return $data['repository']['id'] ?? ''; + } + + private function getOrgNodeId(string $org, string $token): string + { + $data = $this->graphql( + 'query($login: String!) { organization(login: $login) { id } }', + ['login' => $org], + $token + ); + return $data['organization']['id'] ?? ''; + } + + /** @return array{bool, string} */ + private function repoHasProject(string $org, string $repo, string $token): array + { + $data = $this->graphql( + 'query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + projectsV2(first: 1) { nodes { id title } totalCount } + } + }', + ['owner' => $org, 'name' => $repo], + $token + ); + + $count = $data['repository']['projectsV2']['totalCount'] ?? 0; + $title = $data['repository']['projectsV2']['nodes'][0]['title'] ?? ''; + return [$count > 0, $title]; + } + + /** @return array{name: string, fields: array, views: array} */ + private function parseTemplate(string $filePath): array + { + if (!file_exists($filePath)) { + return ['name' => 'Development Board', 'fields' => [], 'views' => []]; + } + + $content = file_get_contents($filePath); + $result = ['name' => 'Development Board', 'fields' => [], 'views' => []]; + + if (preg_match('/name\s*=\s*"([^"]+)"/', $content, $m)) { + $result['name'] = $m[1]; + } + + if (preg_match_all('/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"\s*description\s*=\s*"([^"]+)"(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s', $content, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $field = [ + 'name' => $match[1], + 'type' => $match[2], + 'description' => $match[3], + ]; + if (!empty($match[4])) { + $field['options'] = array_map( + fn($o) => trim($o, " \t\n\r\"'"), + explode(',', $match[4]) + ); + $field['options'] = array_filter($field['options']); + } + $result['fields'][] = $field; + } + } + + return $result; + } + + private function createProject( + string $org, + string $repo, + string $ownerId, + string $repoId, + array $template, + string $token + ): bool { + $title = "{$repo} -- {$template['name']}"; + + if ($this->dryRun) { + echo " (dry-run) would create project: {$title}\n"; + echo " (dry-run) fields: " . count($template['fields']) . "\n"; + return true; + } + + echo " Creating project: {$title}\n"; + $data = $this->graphql( + 'mutation($ownerId: ID!, $title: String!) { + createProjectV2(input: { ownerId: $ownerId, title: $title }) { + projectV2 { id number url } + } + }', + ['ownerId' => $ownerId, 'title' => $title], + $token + ); + + $projectId = $data['createProjectV2']['projectV2']['id'] ?? ''; + $projectUrl = $data['createProjectV2']['projectV2']['url'] ?? ''; + + if (empty($projectId)) { + $this->log('ERROR', " Failed to create project for {$repo}"); + return false; + } + + echo " Project created: {$projectUrl}\n"; + + $this->graphql( + 'mutation($projectId: ID!, $repositoryId: ID!) { + linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) { + repository { id } + } + }', + ['projectId' => $projectId, 'repositoryId' => $repoId], + $token + ); + echo " Linked to {$org}/{$repo}\n"; + + $fieldCount = 0; + foreach ($template['fields'] as $field) { + $fieldType = match ($field['type']) { + 'single_select' => 'SINGLE_SELECT', + 'text' => 'TEXT', + 'number' => 'NUMBER', + 'date' => 'DATE', + 'iteration' => 'ITERATION', + default => 'TEXT', + }; + + $vars = [ + 'projectId' => $projectId, + 'name' => $field['name'], + 'dataType' => $fieldType, + ]; + + if ($fieldType === 'SINGLE_SELECT' && !empty($field['options'])) { + $optionInputs = array_map( + fn($o) => ['name' => $o, 'description' => '', 'color' => 'GRAY'], + $field['options'] + ); + $vars['singleSelectOptions'] = $optionInputs; + + $this->graphql( + 'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) { + createProjectV2Field(input: { + projectId: $projectId, + dataType: $dataType, + name: $name, + singleSelectOptions: $singleSelectOptions + }) { + projectV2Field { ... on ProjectV2SingleSelectField { id name } } + } + }', + $vars, + $token + ); + } else { + $this->graphql( + 'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) { + createProjectV2Field(input: { + projectId: $projectId, + dataType: $dataType, + name: $name + }) { + projectV2Field { ... on ProjectV2Field { id name } } + } + }', + $vars, + $token + ); + } + + $fieldCount++; + } + + echo " Created {$fieldCount} custom fields\n"; + + $this->graphql( + 'mutation($projectId: ID!, $shortDescription: String!) { + updateProjectV2(input: { + projectId: $projectId, + shortDescription: $shortDescription, + readme: "Managed by moko-platform. Run `php cli/create_project.php` to regenerate." + }) { + projectV2 { id } + } + }', + [ + 'projectId' => $projectId, + 'shortDescription' => "Standard project board for {$repo}. Auto-created by moko-platform.", + ], + $token + ); + + echo " Project setup complete\n"; + return true; + } } -/** - * Execute a REST API GET call via the platform adapter's ApiClient. - * - * @return array - */ -function restGet(string $path, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): array -{ - if ($apiClient !== null) { - try { - return $apiClient->get("/{$path}"); - } catch (\Exception $e) { - return []; - } - } - return []; -} - -/** - * Detect platform type from .mokostandards file in the repo. - */ -function detectRepoPlatform(string $org, string $repo, string $token, ?\MokoEnterprise\ApiClient $apiClient = null): string -{ - // Try platform metadata dir first, then root - foreach (['.github/.mokostandards', '.mokogitea/.mokostandards', '.mokostandards'] as $path) { - $data = restGet("repos/{$org}/{$repo}/contents/{$path}", $token, $apiClient); - if (!empty($data['content'])) { - $content = base64_decode($data['content']); - if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { - return trim($m[1], " \t\n\r\"'"); - } - } - } - return ''; -} - -/** - * Get the GitHub node ID for a repository. - */ -function getRepoNodeId(string $org, string $repo, string $token): string -{ - $data = graphql( - 'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }', - ['owner' => $org, 'name' => $repo], - $token - ); - return $data['repository']['id'] ?? ''; -} - -/** - * Get the GitHub node ID for the organization owner. - */ -function getOrgNodeId(string $org, string $token): string -{ - $data = graphql( - 'query($login: String!) { organization(login: $login) { id } }', - ['login' => $org], - $token - ); - return $data['organization']['id'] ?? ''; -} - -/** - * Check if a repo already has a GitHub Project linked. - * - * @return array{bool, string} [hasProject, projectTitle] - */ -function repoHasProject(string $org, string $repo, string $token): array -{ - $data = graphql( - 'query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - projectsV2(first: 1) { nodes { id title } totalCount } - } - }', - ['owner' => $org, 'name' => $repo], - $token - ); - - $count = $data['repository']['projectsV2']['totalCount'] ?? 0; - $title = $data['repository']['projectsV2']['nodes'][0]['title'] ?? ''; - return [$count > 0, $title]; -} - -/** - * Parse a .tf template file to extract custom fields. - * - * @return array{name: string, fields: array, views: array} - */ -function parseTemplate(string $filePath): array -{ - if (!file_exists($filePath)) { - return ['name' => 'Development Board', 'fields' => [], 'views' => []]; - } - - $content = file_get_contents($filePath); - $result = ['name' => 'Development Board', 'fields' => [], 'views' => []]; - - // Extract project name - if (preg_match('/name\s*=\s*"([^"]+)"/', $content, $m)) { - $result['name'] = $m[1]; - } - - // Extract custom fields - if (preg_match_all('/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"\s*description\s*=\s*"([^"]+)"(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s', $content, $matches, PREG_SET_ORDER)) { - foreach ($matches as $match) { - $field = [ - 'name' => $match[1], - 'type' => $match[2], - 'description' => $match[3], - ]; - if (!empty($match[4])) { - $field['options'] = array_map( - fn($o) => trim($o, " \t\n\r\"'"), - explode(',', $match[4]) - ); - $field['options'] = array_filter($field['options']); - } - $result['fields'][] = $field; - } - } - - return $result; -} - -/** - * Create a GitHub Project V2 for a repository. - */ -function createProject( - string $org, - string $repo, - string $ownerId, - string $repoId, - array $template, - string $token, - bool $dryRun -): bool { - $title = "{$repo} — {$template['name']}"; - - if ($dryRun) { - echo " (dry-run) would create project: {$title}\n"; - echo " (dry-run) fields: " . count($template['fields']) . "\n"; - return true; - } - - // Step 1: Create the project - echo " Creating project: {$title}\n"; - $data = graphql( - 'mutation($ownerId: ID!, $title: String!) { - createProjectV2(input: { ownerId: $ownerId, title: $title }) { - projectV2 { id number url } - } - }', - ['ownerId' => $ownerId, 'title' => $title], - $token - ); - - $projectId = $data['createProjectV2']['projectV2']['id'] ?? ''; - $projectUrl = $data['createProjectV2']['projectV2']['url'] ?? ''; - - if (empty($projectId)) { - fwrite(STDERR, " Failed to create project for {$repo}\n"); - return false; - } - - echo " Project created: {$projectUrl}\n"; - - // Step 2: Link the project to the repository - graphql( - 'mutation($projectId: ID!, $repositoryId: ID!) { - linkProjectV2ToRepository(input: { projectId: $projectId, repositoryId: $repositoryId }) { - repository { id } - } - }', - ['projectId' => $projectId, 'repositoryId' => $repoId], - $token - ); - echo " Linked to {$org}/{$repo}\n"; - - // Step 3: Create custom fields - $fieldCount = 0; - foreach ($template['fields'] as $field) { - $fieldType = match ($field['type']) { - 'single_select' => 'SINGLE_SELECT', - 'text' => 'TEXT', - 'number' => 'NUMBER', - 'date' => 'DATE', - 'iteration' => 'ITERATION', - default => 'TEXT', - }; - - $vars = [ - 'projectId' => $projectId, - 'name' => $field['name'], - 'dataType' => $fieldType, - ]; - - // Single select fields need options created with the field - if ($fieldType === 'SINGLE_SELECT' && !empty($field['options'])) { - $optionInputs = array_map( - fn($o) => ['name' => $o, 'description' => '', 'color' => 'GRAY'], - $field['options'] - ); - $vars['singleSelectOptions'] = $optionInputs; - - graphql( - 'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) { - createProjectV2Field(input: { - projectId: $projectId, - dataType: $dataType, - name: $name, - singleSelectOptions: $singleSelectOptions - }) { - projectV2Field { ... on ProjectV2SingleSelectField { id name } } - } - }', - $vars, - $token - ); - } else { - graphql( - 'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) { - createProjectV2Field(input: { - projectId: $projectId, - dataType: $dataType, - name: $name - }) { - projectV2Field { ... on ProjectV2Field { id name } } - } - }', - $vars, - $token - ); - } - - $fieldCount++; - } - - echo " Created {$fieldCount} custom fields\n"; - - // Step 4: Update project description and README - graphql( - 'mutation($projectId: ID!, $shortDescription: String!) { - updateProjectV2(input: { - projectId: $projectId, - shortDescription: $shortDescription, - readme: "Managed by MokoStandards. Run `php cli/create_project.php` to regenerate." - }) { - projectV2 { id } - } - }', - [ - 'projectId' => $projectId, - 'shortDescription' => "Standard project board for {$repo}. Auto-created by MokoStandards.", - ], - $token - ); - - echo " Project setup complete\n"; - return true; -} - -// ── Main ──────────────────────────────────────────────────────────────── - -$repos = []; - -if ($allMode) { - echo "Fetching repositories from {$org}...\n"; - $page = 1; - do { - $batch = restGet("orgs/{$org}/repos?per_page=100&page={$page}&type=all", $token); - foreach ($batch as $r) { - if (!$r['archived'] && !in_array($r['name'], $ALWAYS_EXCLUDE, true)) { - $repos[] = $r['name']; - } - } - $page++; - } while (count($batch) === 100); - - sort($repos); - echo "Found " . count($repos) . " repositories\n\n"; -} else { - $repos = [$repoName]; -} - -$ownerId = getOrgNodeId($org, $token); -if (empty($ownerId)) { - fwrite(STDERR, "Could not resolve org node ID for {$org}\n"); - exit(1); -} - -$created = 0; -$skipped = 0; -$failed = 0; - -foreach ($repos as $repo) { - echo "Processing {$repo}...\n"; - - // Check if project already exists - [$hasProject, $existingTitle] = repoHasProject($org, $repo, $token); - if ($hasProject) { - echo " Already has project: {$existingTitle} — skipping\n"; - $skipped++; - continue; - } - - // Detect project type - $type = $typeOverride; - if (!$type) { - $platform = detectRepoPlatform($org, $repo, $token); - $type = $PLATFORM_TO_TYPE[$platform] ?? 'generic'; - echo " Platform: {$platform} → type: {$type}\n"; - } - - // Load template - $templateFile = $TYPE_TO_TEMPLATE[$type] ?? $TYPE_TO_TEMPLATE['generic']; - $template = parseTemplate("{$templatesDir}/{$templateFile}"); - - // Get repo node ID - $repoId = getRepoNodeId($org, $repo, $token); - if (empty($repoId)) { - fwrite(STDERR, " Could not resolve repo node ID for {$repo}\n"); - $failed++; - continue; - } - - // Create the project - $ok = createProject($org, $repo, $ownerId, $repoId, $template, $token, $dryRun); - if ($ok) { - $created++; - } else { - $failed++; - } - - echo "\n"; -} - -echo str_repeat('-', 50) . "\n"; -echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n"; -exit($failed > 0 ? 1 : 0); +$app = new CreateProjectCli(); +exit($app->execute()); diff --git a/cli/create_repo.php b/cli/create_repo.php index 16003b0..0df151a 100644 --- a/cli/create_repo.php +++ b/cli/create_repo.php @@ -11,243 +11,91 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/create_repo.php - * BRIEF: Scaffold a new governed repository with full MokoStandards baseline - * - * USAGE - * php cli/create_repo.php --name MokoNewModule --type dolibarr --description "My new module" - * php cli/create_repo.php --name MokoNewModule --type joomla --private - * php cli/create_repo.php --name MokoNewModule --type generic --dry-run + * BRIEF: Scaffold a new governed repository with full moko-platform baseline */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; +use MokoEnterprise\CliFramework; use MokoEnterprise\Config; use MokoEnterprise\PlatformAdapterFactory; -$dryRun = in_array('--dry-run', $argv); -$private = in_array('--private', $argv); - -$name = null; -$type = null; -$description = ''; - -foreach ($argv as $i => $arg) { - if ($arg === '--name' && isset($argv[$i + 1])) { $name = $argv[$i + 1]; } - if ($arg === '--type' && isset($argv[$i + 1])) { $type = $argv[$i + 1]; } - if ($arg === '--description' && isset($argv[$i + 1])) { $description = $argv[$i + 1]; } -} - -if (!$name || !$type) { - fwrite(STDERR, "Usage: php create_repo.php --name --type [--description \"...\"] [--private] [--dry-run]\n"); - fwrite(STDERR, "\nTypes: generic, dolibarr, dolibarr-platform, joomla, nodejs, terraform, python, wordpress\n"); - exit(2); -} - -$config = Config::load(); -$adapter = PlatformAdapterFactory::create($config); -$org = $config->getString( - $adapter->getPlatformName() . '.organization', - 'mokoconsulting-tech' -); - -$repoRoot = dirname(__DIR__, 2); - -$TYPE_TO_PLATFORM = [ - 'dolibarr' => 'crm-module', - 'dolibarr-platform' => 'crm-platform', - 'joomla' => 'waas-component', - 'nodejs' => 'nodejs', - 'terraform' => 'terraform', - 'python' => 'python', - 'wordpress' => 'wordpress', - 'generic' => 'generic', -]; - -$TYPE_TO_TOPICS = [ - 'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'], - 'joomla' => ['joomla', 'cms', 'php', 'mokostandards'], - 'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'], - 'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'], - 'python' => ['python', 'mokostandards'], - 'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'], - 'generic' => ['mokostandards'], -]; - -$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic'; -$topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards']; -$platformName = $adapter->getPlatformName(); - -echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n"; -echo " Type: {$type} (platform: {$platform})\n"; -echo " Visibility: " . ($private ? 'private' : 'public') . "\n"; -if ($description) { echo " Description: {$description}\n"; } -echo "\n"; - -// ── Step 1: Create the repository ─────────────────────────────────────── -echo "Step 1: Creating repository...\n"; -if (!$dryRun) { - try { - $data = $adapter->createOrgRepo($org, $name, [ - 'description' => $description ?: "Managed by MokoStandards ({$type})", - 'private' => $private, - 'has_issues' => true, - 'has_projects' => true, - 'has_wiki' => false, - 'auto_init' => true, - 'delete_branch_on_merge' => true, - 'allow_squash_merge' => true, - 'allow_merge_commit' => false, - 'allow_rebase_merge' => false, - ]); - echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n"; - } catch (\Exception $e) { - if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { - echo " Repository already exists — continuing with setup\n"; - } else { - fwrite(STDERR, " Failed to create repo: " . $e->getMessage() . "\n"); - exit(1); - } - } -} else { - echo " (dry-run) would create {$org}/{$name}\n"; -} - -// ── Step 2: Set topics ────────────────────────────────────────────────── -echo "Step 2: Setting topics...\n"; -if (!$dryRun) { - $adapter->setRepoTopics($org, $name, $topics); - echo " Topics: " . implode(', ', $topics) . "\n"; -} else { - echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n"; -} - -// ── Step 3: Create .mokostandards file ────────────────────────────────── -echo "Step 3: Creating .github/.mokostandards...\n"; -$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n"; -if (!$dryRun) { - try { - $adapter->createOrUpdateFile( - $org, $name, '.github/.mokostandards', $mokoContent, - 'chore: add .mokostandards platform config [skip ci]' - ); - echo " .mokostandards created\n"; - } catch (\Exception $e) { - echo " Warning: " . $e->getMessage() . "\n"; - } -} else { - echo " (dry-run) would create .github/.mokostandards\n"; -} - -// ── Step 4: Create initial README.md ──────────────────────────────────── -echo "Step 4: Creating README.md...\n"; - -// Determine the repo base URL based on platform -$baseUrl = $platformName === 'gitea' - ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') - : 'https://github.com'; -$repoUrl = "{$baseUrl}/{$org}/{$name}"; -$standardsUrl = "{$baseUrl}/{$org}/MokoStandards"; - -$readmeContent = << - -SPDX-License-Identifier: GPL-3.0-or-later - -# FILE INFORMATION -DEFGROUP: {$name} -INGROUP: moko-platform -REPO: {$repoUrl} -PATH: /README.md -BRIEF: {$description} ---> - -# {$name} - -[![MokoStandards](https://img.shields.io/badge/MokoStandards-04.06.00-blue)]({$standardsUrl}) -[![Version](https://img.shields.io/badge/version-01.00.00-green)]({$repoUrl}) - -{$description} - -## Getting Started - -This repository is governed by [MokoStandards]({$standardsUrl}). - -## License - -This project is licensed under the GPL-3.0-or-later license. See [LICENSE](LICENSE) for details. - ---- - -*This file is part of the Moko Consulting ecosystem. All rights reserved.* -*This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.* -MD; - -if (!$dryRun) { - // Get existing README sha (auto_init creates one) - $sha = null; - try { - $existing = $adapter->getFileContents($org, $name, 'README.md'); - $sha = $existing['sha'] ?? null; - } catch (\Exception $e) { - $adapter->getApiClient()->resetCircuitBreaker(); +class CreateRepoCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Scaffold a new governed repository with full moko-platform baseline'); + $this->addArgument('--name', 'Repository name', null); + $this->addArgument('--type', 'Project type', null); + $this->addArgument('--description', 'Repository description', ''); + $this->addArgument('--private', 'Create as private', false); } - $adapter->createOrUpdateFile( - $org, $name, 'README.md', $readmeContent, - 'docs: initialize README with MokoStandards header [skip ci]', - $sha - ); - echo " README.md created\n"; -} else { - echo " (dry-run) would create README.md\n"; -} + protected function run(): int + { + $name = $this->getArgument('--name'); $type = $this->getArgument('--type'); + $description = $this->getArgument('--description'); $private = (bool) $this->getArgument('--private'); + if (!$name || !$type) { $this->log('ERROR', "Usage: php create_repo.php --name --type [--description \"...\"] [--private] [--dry-run]"); return 2; } + $config = Config::load(); $adapter = PlatformAdapterFactory::create($config); + $org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); + $repoRoot = dirname(__DIR__, 2); + $TYPE_TO_PLATFORM = ['dolibarr' => 'crm-module', 'dolibarr-platform' => 'crm-platform', 'joomla' => 'waas-component', 'nodejs' => 'nodejs', 'terraform' => 'terraform', 'python' => 'python', 'wordpress' => 'wordpress', 'generic' => 'generic']; + $TYPE_TO_TOPICS = ['dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'], 'joomla' => ['joomla', 'cms', 'php', 'mokostandards'], 'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'], 'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'], 'python' => ['python', 'mokostandards'], 'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'], 'generic' => ['mokostandards']]; + $platform = $TYPE_TO_PLATFORM[$type] ?? 'generic'; $topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards']; + $platformName = $adapter->getPlatformName(); + echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n Type: {$type} (platform: {$platform})\n Visibility: " . ($private ? 'private' : 'public') . "\n"; + if ($description) { echo " Description: {$description}\n"; } echo "\n"; -// ── Step 5: Provision labels ──────────────────────────────────────────── -echo "Step 5: Provisioning labels...\n"; -if (!$dryRun) { - $labelScript = "{$repoRoot}/api/maintenance/setup_labels.php"; - if (file_exists($labelScript)) { - $exitCode = 0; - passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode); - echo $exitCode === 0 ? " Labels provisioned\n" : " Label provisioning had issues\n"; - } else { - echo " Labels will be provisioned on next sync\n"; + echo "Step 1: Creating repository...\n"; + if (!$this->dryRun) { + try { + $data = $adapter->createOrgRepo($org, $name, ['description' => $description ?: "Managed by moko-platform ({$type})", 'private' => $private, 'has_issues' => true, 'has_projects' => true, 'has_wiki' => false, 'auto_init' => true, 'delete_branch_on_merge' => true, 'allow_squash_merge' => true, 'allow_merge_commit' => false, 'allow_rebase_merge' => false]); + echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n"; + } catch (\Exception $e) { + if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { echo " Repository already exists -- continuing with setup\n"; } + else { $this->log('ERROR', "Failed to create repo: " . $e->getMessage()); return 1; } + } + } else { echo " (dry-run) would create {$org}/{$name}\n"; } + + echo "Step 2: Setting topics...\n"; + if (!$this->dryRun) { $adapter->setRepoTopics($org, $name, $topics); echo " Topics: " . implode(', ', $topics) . "\n"; } + else { echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n"; } + + echo "Step 3: Creating .github/.mokostandards...\n"; + $mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n"; + if (!$this->dryRun) { try { $adapter->createOrUpdateFile($org, $name, '.github/.mokostandards', $mokoContent, 'chore: add .mokostandards platform config [skip ci]'); echo " .mokostandards created\n"; } catch (\Exception $e) { echo " Warning: " . $e->getMessage() . "\n"; } } + else { echo " (dry-run) would create .github/.mokostandards\n"; } + + echo "Step 4: Creating README.md...\n"; + $baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com'; + $repoUrl = "{$baseUrl}/{$org}/{$name}"; $standardsUrl = "{$baseUrl}/{$org}/MokoStandards"; + $readmeContent = "\n\n# {$name}\n\n{$description}\n\n## Getting Started\n\nThis repository is governed by [moko-platform]({$standardsUrl}).\n\n## License\n\nGPL-3.0-or-later. See [LICENSE](LICENSE) for details.\n"; + if (!$this->dryRun) { + $sha = null; + try { $existing = $adapter->getFileContents($org, $name, 'README.md'); $sha = $existing['sha'] ?? null; } catch (\Exception $e) { $adapter->getApiClient()->resetCircuitBreaker(); } + $adapter->createOrUpdateFile($org, $name, 'README.md', $readmeContent, 'docs: initialize README with moko-platform header [skip ci]', $sha); + echo " README.md created\n"; + } else { echo " (dry-run) would create README.md\n"; } + + echo "Step 5: Provisioning labels...\n"; + if (!$this->dryRun) { $labelScript = "{$repoRoot}/api/maintenance/setup_labels.php"; if (file_exists($labelScript)) { $exitCode = 0; passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode); } else { echo " Labels will be provisioned on next sync\n"; } } + else { echo " (dry-run) would provision standard labels\n"; } + + echo "Step 6: Running initial sync...\n"; + if (!$this->dryRun) { $syncScript = "{$repoRoot}/api/automation/bulk_sync.php"; if (file_exists($syncScript)) { passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes"); } else { echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n"; } } + else { echo " (dry-run) would run initial sync\n"; } + + echo "Step 7: Creating Project...\n"; + if (!$this->dryRun) { $projectScript = "{$repoRoot}/api/cli/create_project.php"; if (file_exists($projectScript)) { passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type)); } else { echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n"; } } + else { echo " (dry-run) would create Project\n"; } + + echo "\n" . str_repeat('-', 50) . "\nRepository {$org}/{$name} scaffolded successfully\n URL: {$repoUrl}\n Platform: {$platform} ({$platformName})\n Next: verify the sync and merge any PRs\n"; + return 0; } -} else { - echo " (dry-run) would provision standard labels\n"; } -// ── Step 6: Run first sync ────────────────────────────────────────────── -echo "Step 6: Running initial sync...\n"; -if (!$dryRun) { - $syncScript = "{$repoRoot}/api/automation/bulk_sync.php"; - if (file_exists($syncScript)) { - passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes"); - } else { - echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n"; - } -} else { - echo " (dry-run) would run initial sync\n"; -} - -// ── Step 7: Create Project ────────────────────────────────────────────── -echo "Step 7: Creating Project...\n"; -if (!$dryRun) { - $projectScript = "{$repoRoot}/api/cli/create_project.php"; - if (file_exists($projectScript)) { - passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type)); - } else { - echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n"; - } -} else { - echo " (dry-run) would create Project\n"; -} - -echo "\n" . str_repeat('-', 50) . "\n"; -echo "Repository {$org}/{$name} scaffolded successfully\n"; -echo " URL: {$repoUrl}\n"; -echo " Platform: {$platform} ({$platformName})\n"; -echo " Next: verify the sync and merge any PRs\n"; +$app = new CreateRepoCli(); +exit($app->execute()); diff --git a/cli/dev_branch_reset.php b/cli/dev_branch_reset.php index bfb20ec..bdcc309 100644 --- a/cli/dev_branch_reset.php +++ b/cli/dev_branch_reset.php @@ -10,88 +10,90 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/dev_branch_reset.php * BRIEF: Delete and recreate dev branch from main via Gitea API - * - * Usage: - * php dev_branch_reset.php --token TOKEN --api-base URL - * php dev_branch_reset.php --token TOKEN --api-base URL --branch dev --from main - * - * Options: - * --token Gitea API token (required) - * --api-base Gitea API base URL (required) - * --branch Branch to reset (default: dev) - * --from Source branch (default: main) - * --output-summary Write to $GITHUB_STEP_SUMMARY */ declare(strict_types=1); -$token = null; -$apiBase = null; -$branch = 'dev'; -$from = 'main'; -$outputSummary = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; - if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1]; - if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1]; - if ($arg === '--from' && isset($argv[$i + 1])) $from = $argv[$i + 1]; - if ($arg === '--output-summary') $outputSummary = true; +use MokoEnterprise\CliFramework; + +class DevBranchResetCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Delete and recreate dev branch from main via Gitea API'); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--api-base', 'Gitea API base URL', ''); + $this->addArgument('--branch', 'Branch to reset', 'dev'); + $this->addArgument('--from', 'Source branch', 'main'); + $this->addArgument('--output-summary', 'Write to $GITHUB_STEP_SUMMARY', false); + } + + protected function run(): int + { + $token = $this->getArgument('--token') ?: getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: ''; + $apiBase = $this->getArgument('--api-base'); + $branch = $this->getArgument('--branch'); + $from = $this->getArgument('--from'); + $outputSummary = $this->getArgument('--output-summary'); + + if (empty($token) || empty($apiBase)) { + $this->log('ERROR', 'Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]'); + return 1; + } + + // Delete branch (tolerate 404) + $ch = curl_init("{$apiBase}/branches/{$branch}"); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + curl_exec($ch); + $delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($delCode === 204 || $delCode === 200) { + $this->log('INFO', "Deleted branch '{$branch}'"); + } elseif ($delCode === 404) { + $this->log('INFO', "Branch '{$branch}' did not exist (skipped delete)"); + } else { + $this->warning("Delete branch returned HTTP {$delCode}"); + } + + // Create branch from source + $payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]); + $ch = curl_init("{$apiBase}/branches"); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"], + CURLOPT_POSTFIELDS => $payload, + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + $createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($createCode === 201) { + $this->success("Recreated '{$branch}' from '{$from}'"); + } else { + $this->log('ERROR', "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})"); + return 1; + } + + if ($outputSummary) { + $summaryFile = getenv('GITHUB_STEP_SUMMARY'); + if ($summaryFile) { + file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND); + } + } + + return 0; + } } -if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; - -if ($token === null || $apiBase === null) { - fwrite(STDERR, "Usage: dev_branch_reset.php --token TOKEN --api-base URL [--branch dev] [--from main]\n"); - exit(1); -} - -// Delete branch (tolerate 404) -$ch = curl_init("{$apiBase}/branches/{$branch}"); -curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => 'DELETE', - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], - CURLOPT_TIMEOUT => 30, -]); -curl_exec($ch); -$delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); - -if ($delCode === 204 || $delCode === 200) { - echo "Deleted branch '{$branch}'\n"; -} elseif ($delCode === 404) { - echo "Branch '{$branch}' did not exist (skipped delete)\n"; -} else { - fwrite(STDERR, "WARNING: Delete branch returned HTTP {$delCode}\n"); -} - -// Create branch from source -$payload = json_encode(['new_branch_name' => $branch, 'old_branch_name' => $from]); -$ch = curl_init("{$apiBase}/branches"); -curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"], - CURLOPT_POSTFIELDS => $payload, - CURLOPT_TIMEOUT => 30, -]); -$response = curl_exec($ch); -$createCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); - -if ($createCode === 201) { - echo "Recreated '{$branch}' from '{$from}'\n"; -} else { - fwrite(STDERR, "Failed to create branch '{$branch}' from '{$from}' (HTTP {$createCode})\n"); - exit(1); -} - -if ($outputSummary) { - $summaryFile = getenv('GITHUB_STEP_SUMMARY'); - if ($summaryFile) { - file_put_contents($summaryFile, "Dev branch reset: '{$branch}' recreated from '{$from}'\n", FILE_APPEND); - } -} - -exit(0); +$app = new DevBranchResetCli(); +exit($app->execute()); diff --git a/cli/grafana_dashboard.php b/cli/grafana_dashboard.php index 6eb1409..35514ec 100644 --- a/cli/grafana_dashboard.php +++ b/cli/grafana_dashboard.php @@ -12,13 +12,17 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/grafana_dashboard.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Manage Grafana dashboards via API */ declare(strict_types=1); -final class GrafanaDashboard +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class GrafanaDashboardCli extends CliFramework { private string $grafanaUrl = ''; private string $token = ''; @@ -29,24 +33,52 @@ final class GrafanaDashboard private string $folderTitle = ''; private bool $overwrite = true; - public function run(): int + protected function configure(): void { - $this->parseArgs(); + $this->setDescription('Manage Grafana dashboards via API'); + $this->addArgument('--url', 'Grafana URL (or GRAFANA_URL)', ''); + $this->addArgument('--token', 'API token (or GRAFANA_TOKEN)', ''); + $this->addArgument('--uid', 'Dashboard UID (delete/export)', ''); + $this->addArgument('--file', 'JSON file (push/export)', ''); + $this->addArgument('--folder', 'Folder name (push/list)', ''); + $this->addArgument('--folder-id', 'Folder ID (push/list)', '0'); + $this->addArgument('--no-overwrite', 'Fail if dashboard exists', false); + $this->addArgument('--command', 'Command: push, delete, list, export', ''); + } + + protected function run(): int + { + // Parse positional command from raw argv + $rawArgs = $_SERVER['argv'] ?? []; + foreach ($rawArgs as $arg) { + if (in_array($arg, ['push', 'delete', 'list', 'export'], true)) { + $this->command = $arg; + break; + } + } + if ($this->command === '' && $this->getArgument('--command') !== '') { + $this->command = $this->getArgument('--command'); + } + + $this->grafanaUrl = $this->getArgument('--url'); + $this->token = $this->getArgument('--token'); + $this->uid = $this->getArgument('--uid'); + $this->file = $this->getArgument('--file'); + $this->folderTitle = $this->getArgument('--folder'); + $this->folderId = (int) $this->getArgument('--folder-id'); + $this->overwrite = !$this->getArgument('--no-overwrite'); if ($this->grafanaUrl === '') { $this->grafanaUrl = getenv('GRAFANA_URL') ?: ''; } + $this->grafanaUrl = rtrim($this->grafanaUrl, '/'); if ($this->token === '') { $this->token = getenv('GRAFANA_TOKEN') ?: ''; } if ($this->grafanaUrl === '' || $this->token === '') { - $this->log( - 'ERROR: --url and --token are required ' - . '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).' - ); - $this->printUsage(); + $this->log('ERROR', '--url and --token are required (or set GRAFANA_URL / GRAFANA_TOKEN env vars).'); return 1; } @@ -62,12 +94,12 @@ final class GrafanaDashboard private function pushDashboard(): int { if ($this->file === '') { - $this->log('ERROR: --file is required for push.'); + $this->log('ERROR', '--file is required for push.'); return 1; } if (!file_exists($this->file)) { - $this->log("ERROR: File not found: {$this->file}"); + $this->log('ERROR', "File not found: {$this->file}"); return 1; } @@ -75,14 +107,12 @@ final class GrafanaDashboard $dashboard = json_decode($json, true); if (!is_array($dashboard)) { - $this->log('ERROR: Invalid JSON in dashboard file.'); + $this->log('ERROR', 'Invalid JSON in dashboard file.'); return 1; } if ($this->folderTitle !== '' && $this->folderId === 0) { - $this->folderId = $this->resolveFolderId( - $this->folderTitle - ); + $this->folderId = $this->resolveFolderId($this->folderTitle); if ($this->folderId < 0) { return 1; @@ -97,29 +127,23 @@ final class GrafanaDashboard 'overwrite' => $this->overwrite, ]); - $response = $this->apiRequest( - 'POST', - '/api/dashboards/db', - $payload - ); + $response = $this->apiRequest('POST', '/api/dashboards/db', $payload); if ($response['code'] === 200) { $data = json_decode($response['body'], true); $uid = $data['uid'] ?? '?'; $url = $data['url'] ?? ''; $status = $data['status'] ?? 'success'; - $this->log("OK: {$status} (uid: {$uid})"); + $this->log('INFO', "OK: {$status} (uid: {$uid})"); if ($url !== '') { - $this->log("URL: {$this->grafanaUrl}{$url}"); + $this->log('INFO', "URL: {$this->grafanaUrl}{$url}"); } return 0; } - $this->log( - "ERROR: Push failed (HTTP {$response['code']})" - ); + $this->log('ERROR', "Push failed (HTTP {$response['code']})"); $this->logApiError($response['body']); return 1; @@ -128,30 +152,23 @@ final class GrafanaDashboard private function deleteDashboard(): int { if ($this->uid === '') { - $this->log('ERROR: --uid is required for delete.'); + $this->log('ERROR', '--uid is required for delete.'); return 1; } - $response = $this->apiRequest( - 'DELETE', - "/api/dashboards/uid/{$this->uid}" - ); + $response = $this->apiRequest('DELETE', "/api/dashboards/uid/{$this->uid}"); if ($response['code'] === 200) { - $this->log("OK: Deleted dashboard {$this->uid}"); + $this->log('INFO', "OK: Deleted dashboard {$this->uid}"); return 0; } if ($response['code'] === 404) { - $this->log( - "WARN: Dashboard {$this->uid} not found." - ); + $this->warning("Dashboard {$this->uid} not found."); return 0; } - $this->log( - "ERROR: Delete failed (HTTP {$response['code']})" - ); + $this->log('ERROR', "Delete failed (HTTP {$response['code']})"); $this->logApiError($response['body']); return 1; @@ -176,42 +193,33 @@ final class GrafanaDashboard $response = $this->apiRequest('GET', $query); if ($response['code'] !== 200) { - $this->log( - "ERROR: List failed (HTTP {$response['code']})" - ); + $this->log('ERROR', "List failed (HTTP {$response['code']})"); $this->logApiError($response['body']); return 1; } $dashboards = json_decode($response['body'], true); - if ( - !is_array($dashboards) - || count($dashboards) === 0 - ) { - $this->log('No dashboards found.'); + if (!is_array($dashboards) || count($dashboards) === 0) { + $this->log('INFO', 'No dashboards found.'); return 0; } - $this->log(sprintf( - '%-30s | %-20s | %s', - 'Title', - 'UID', - 'Folder' - )); - $this->log(str_repeat('-', 75)); + fprintf(STDERR, "%-30s | %-20s | %s\n", 'Title', 'UID', 'Folder'); + fprintf(STDERR, "%s\n", str_repeat('-', 75)); foreach ($dashboards as $d) { - $this->log(sprintf( - '%-30s | %-20s | %s', + fprintf( + STDERR, + "%-30s | %-20s | %s\n", substr($d['title'] ?? '', 0, 30), $d['uid'] ?? '', $d['folderTitle'] ?? 'General' - )); + ); } - $this->log(''); - $this->log(count($dashboards) . ' dashboard(s).'); + echo "\n"; + $this->log('INFO', count($dashboards) . ' dashboard(s).'); return 0; } @@ -219,20 +227,14 @@ final class GrafanaDashboard private function exportDashboard(): int { if ($this->uid === '') { - $this->log('ERROR: --uid is required for export.'); + $this->log('ERROR', '--uid is required for export.'); return 1; } - $response = $this->apiRequest( - 'GET', - "/api/dashboards/uid/{$this->uid}" - ); + $response = $this->apiRequest('GET', "/api/dashboards/uid/{$this->uid}"); if ($response['code'] !== 200) { - $this->log( - "ERROR: Export failed " - . "(HTTP {$response['code']})" - ); + $this->log('ERROR', "Export failed (HTTP {$response['code']})"); $this->logApiError($response['body']); return 1; } @@ -241,9 +243,7 @@ final class GrafanaDashboard $dashboard = $data['dashboard'] ?? null; if ($dashboard === null) { - $this->log( - 'ERROR: No dashboard data in response.' - ); + $this->log('ERROR', 'No dashboard data in response.'); return 1; } @@ -254,9 +254,7 @@ final class GrafanaDashboard if ($this->file !== '') { file_put_contents($this->file, $output); - $this->log( - "Exported {$this->uid} to {$this->file}" - ); + $this->log('INFO', "Exported {$this->uid} to {$this->file}"); } else { fwrite(STDOUT, $output); } @@ -269,10 +267,7 @@ final class GrafanaDashboard $response = $this->apiRequest('GET', '/api/folders'); if ($response['code'] !== 200) { - $this->log( - "ERROR: Could not fetch folders " - . "(HTTP {$response['code']})" - ); + $this->log('ERROR', "Could not fetch folders (HTTP {$response['code']})"); return -1; } @@ -283,106 +278,22 @@ final class GrafanaDashboard } foreach ($folders as $f) { - if ( - strcasecmp( - $f['title'] ?? '', - $title - ) === 0 - ) { + if (strcasecmp($f['title'] ?? '', $title) === 0) { return (int) ($f['id'] ?? 0); } } - $this->log( - "WARN: Folder \"{$title}\" not found, " - . "using General." - ); + $this->warning("Folder \"{$title}\" not found, using General."); return 0; } private function noCommand(): int { - $this->log('ERROR: No command specified.'); - $this->printUsage(); + $this->log('ERROR', 'No command specified. Use: push, delete, list, export'); return 1; } - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) { - switch ($args[$i]) { - case 'push': - case 'delete': - case 'list': - case 'export': - $this->command = $args[$i]; - break; - case '--url': - $this->grafanaUrl = rtrim( - $args[++$i] ?? '', - '/' - ); - break; - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--uid': - $this->uid = $args[++$i] ?? ''; - break; - case '--file': - $this->file = $args[++$i] ?? ''; - break; - case '--folder-id': - $this->folderId = (int) ( - $args[++$i] ?? 0 - ); - break; - case '--folder': - $this->folderTitle = $args[++$i] ?? ''; - break; - case '--no-overwrite': - $this->overwrite = false; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log( - "WARNING: Unknown arg: {$args[$i]}" - ); - break; - } - } - } - - private function printUsage(): void - { - $u = 'Usage: grafana_dashboard.php ' - . '--url --token [options]'; - $this->log($u); - $this->log(''); - $this->log('Commands:'); - $this->log(' push Create/update dashboard from JSON'); - $this->log(' delete Delete a dashboard by UID'); - $this->log(' list List dashboards (optionally by folder)'); - $this->log(' export Export dashboard JSON by UID'); - $this->log(''); - $this->log('Options:'); - $this->log(' --url Grafana URL (or GRAFANA_URL)'); - $this->log(' --token API token (or GRAFANA_TOKEN)'); - $this->log(' --uid Dashboard UID (delete/export)'); - $this->log(' --file JSON file (push/export)'); - $this->log(' --folder Folder name (push/list)'); - $this->log(' --folder-id Folder ID (push/list)'); - $this->log(' --no-overwrite Fail if dashboard exists'); - $this->log(' --help, -h Show this help'); - } - private function apiRequest( string $method, string $endpoint, @@ -405,10 +316,7 @@ final class GrafanaDashboard } $responseBody = curl_exec($ch); - $httpCode = (int) curl_getinfo( - $ch, - CURLINFO_HTTP_CODE - ); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); if (curl_errno($ch)) { $error = curl_error($ch); @@ -430,15 +338,10 @@ final class GrafanaDashboard $data = json_decode($body, true); if (is_array($data) && isset($data['message'])) { - $this->log(" Grafana: {$data['message']}"); + $this->log('ERROR', " Grafana: {$data['message']}"); } } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } } -$app = new GrafanaDashboard(); -exit($app->run()); +$app = new GrafanaDashboardCli(); +exit($app->execute()); diff --git a/cli/joomla_build.php b/cli/joomla_build.php index dfbcec6..cde2177 100644 --- a/cli/joomla_build.php +++ b/cli/joomla_build.php @@ -9,299 +9,306 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/joomla_build.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Build a Joomla extension ZIP from manifest — all types supported * NOTE: Called by pre-release and auto-release workflows. - * - * USAGE - * php joomla_build.php --path . --version 02.01.24 - * php joomla_build.php --path . --version 02.01.24 --suffix -dev - * php joomla_build.php --path . --version 02.01.24 --output build --github-output - * - * Supports: plugin, module, component, template, package, library, file */ declare(strict_types=1); -// ── Argument parsing ──────────────────────────────────────────────────── -$path = '.'; -$version = ''; -$suffix = ''; -$outputDir = 'build'; -$ghOutput = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; - if ($arg === '--suffix' && isset($argv[$i + 1])) $suffix = $argv[$i + 1]; - if ($arg === '--output' && isset($argv[$i + 1])) $outputDir = $argv[$i + 1]; - if ($arg === '--github-output') $ghOutput = true; -} +use MokoEnterprise\CliFramework; -if ($version === '') { - fwrite(STDERR, "::error::--version is required\n"); - exit(1); -} - -$path = realpath($path) ?: $path; - -// ── Find source directory ────────────────────────────────────────────── -$srcDir = null; -foreach (['src', 'htdocs'] as $d) { - if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; } -} -if ($srcDir === null) { - fwrite(STDERR, "::error::No src/ or htdocs/ directory in {$path}\n"); - exit(1); -} - -// ── Find manifest ────────────────────────────────────────────────────── -$manifest = findManifest($srcDir); -if ($manifest === null) { - fwrite(STDERR, "::error::No Joomla manifest found in {$srcDir}\n"); - exit(1); -} - -fwrite(STDERR, "Manifest: {$manifest}\n"); - -// ── Parse manifest ───────────────────────────────────────────────────── -$meta = parseManifest($manifest); - -// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS") -if (preg_match('/^[A-Z_]+$/', $meta['name'])) { - $resolved = resolveLanguageKey($srcDir, $meta['name']); - if ($resolved !== null) { $meta['name'] = $resolved; } -} - -$prefix = typePrefix($meta); -$zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip"; -$zipPath = "{$outputDir}/{$zipName}"; - -fwrite(STDERR, "=== Joomla Build: {$meta['type']} — {$meta['element']} {$version}{$suffix} ===\n"); -fwrite(STDERR, " Type: {$meta['type']}\n"); -fwrite(STDERR, " Element: {$meta['element']}\n"); -fwrite(STDERR, " Group: " . ($meta['group'] ?: 'n/a') . "\n"); -fwrite(STDERR, " Name: {$meta['name']}\n"); -fwrite(STDERR, " Output: {$zipName}\n"); - -// ── Build ────────────────────────────────────────────────────────────── -if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); } - -if ($meta['type'] === 'package') { - buildPackageZip($srcDir, $zipPath); -} else { - buildZip($srcDir, $zipPath); -} - -$sha256 = hash_file('sha256', $zipPath); -$size = filesize($zipPath); - -fwrite(STDERR, "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)\n"); - -// ── Output variables ─────────────────────────────────────────────────── -$vars = [ - 'zip_name' => $zipName, - 'zip_path' => $zipPath, - 'sha256' => $sha256, - 'ext_type' => $meta['type'], - 'ext_element' => $meta['element'], - 'ext_name' => $meta['name'], - 'ext_group' => $meta['group'], - 'type_prefix' => $prefix, -]; - -if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') { - $fh = fopen($ghFile, 'a'); - foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); } - fclose($fh); - fwrite(STDERR, "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT\n"); -} else { - foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; } -} - -exit(0); - -// ═══════════════════════════════════════════════════════════════════════ -// Functions -// ═══════════════════════════════════════════════════════════════════════ - -function findManifest(string $dir): ?string +class JoomlaBuildCli extends CliFramework { - // Priority: pkg_*.xml (packages), then any *.xml with - foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; } - foreach (glob("{$dir}/*.xml") ?: [] as $f) { - if (str_contains((string) file_get_contents($f), 'isFile() && $item->getExtension() === 'xml') { - if (str_contains((string) file_get_contents($item->getPathname()), 'getPathname(); - } - } - } - return null; + protected function configure(): void + { + $this->setDescription('Build a Joomla extension ZIP from manifest'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--version', 'Version string (required)', ''); + $this->addArgument('--suffix', 'Version suffix (e.g. -dev)', ''); + $this->addArgument('--output', 'Output directory', 'build'); + $this->addArgument('--github-output', 'Write outputs to GITHUB_OUTPUT file', false); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $suffix = $this->getArgument('--suffix'); + $outputDir = $this->getArgument('--output'); + $ghOutput = (bool) $this->getArgument('--github-output'); + + if ($version === '') { + $this->log('ERROR', '::error::--version is required'); + return 1; + } + + $path = realpath($path) ?: $path; + + // ── Find source directory ────────────────────────────────────────────── + $srcDir = null; + foreach (['src', 'htdocs'] as $d) { + if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; } + } + if ($srcDir === null) { + $this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}"); + return 1; + } + + // ── Find manifest ────────────────────────────────────────────────────── + $manifest = $this->findManifest($srcDir); + if ($manifest === null) { + $this->log('ERROR', "::error::No Joomla manifest found in {$srcDir}"); + return 1; + } + + $this->log('INFO', "Manifest: {$manifest}"); + + // ── Parse manifest ───────────────────────────────────────────────────── + $meta = $this->parseManifest($manifest); + + // Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS") + if (preg_match('/^[A-Z_]+$/', $meta['name'])) { + $resolved = $this->resolveLanguageKey($srcDir, $meta['name']); + if ($resolved !== null) { $meta['name'] = $resolved; } + } + + $prefix = $this->typePrefix($meta); + $zipName = "{$prefix}{$meta['element']}-{$version}{$suffix}.zip"; + $zipPath = "{$outputDir}/{$zipName}"; + + $this->log('INFO', "=== Joomla Build: {$meta['type']} — {$meta['element']} {$version}{$suffix} ==="); + $this->log('INFO', " Type: {$meta['type']}"); + $this->log('INFO', " Element: {$meta['element']}"); + $this->log('INFO', " Group: " . ($meta['group'] ?: 'n/a')); + $this->log('INFO', " Name: {$meta['name']}"); + $this->log('INFO', " Output: {$zipName}"); + + // ── Build ────────────────────────────────────────────────────────────── + if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); } + + if ($meta['type'] === 'package') { + $this->buildPackageZip($srcDir, $zipPath); + } else { + $this->buildZip($srcDir, $zipPath); + } + + $sha256 = hash_file('sha256', $zipPath); + $size = filesize($zipPath); + + $this->log('INFO', "Package: {$zipPath} ({$size} bytes, SHA: " . substr($sha256, 0, 16) . "...)"); + + // ── Output variables ─────────────────────────────────────────────────── + $vars = [ + 'zip_name' => $zipName, + 'zip_path' => $zipPath, + 'sha256' => $sha256, + 'ext_type' => $meta['type'], + 'ext_element' => $meta['element'], + 'ext_name' => $meta['name'], + 'ext_group' => $meta['group'], + 'type_prefix' => $prefix, + ]; + + if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') { + $fh = fopen($ghFile, 'a'); + foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); } + fclose($fh); + $this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT"); + } else { + foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; } + } + + return 0; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Private methods + // ═══════════════════════════════════════════════════════════════════════ + + private function findManifest(string $dir): ?string + { + // Priority: pkg_*.xml (packages), then any *.xml with + foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; } + foreach (glob("{$dir}/*.xml") ?: [] as $f) { + if (str_contains((string) file_get_contents($f), 'isFile() && $item->getExtension() === 'xml') { + if (str_contains((string) file_get_contents($item->getPathname()), 'getPathname(); + } + } + } + return null; + } + + private function parseManifest(string $file): array + { + $xml = simplexml_load_file($file); + $name = (string) ($xml->name ?? ''); + $type = (string) ($xml->attributes()->type ?? 'component'); + $element = (string) ($xml->element ?? ''); + $group = (string) ($xml->attributes()->group ?? ''); + + // For packages, prefer as the clean element (avoids pkg_pkg_ duplication) + if ($type === 'package' && $element === '') { + $packageName = (string) ($xml->packagename ?? ''); + if ($packageName !== '') { + $element = $packageName; + } + } + + // Fallback element detection + if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); } + if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); } + if ($element === '') { + $element = strtolower(basename($file, '.xml')); + if (in_array($element, ['templatedetails', 'manifest'], true)) { + $element = strtolower(basename(dirname($file))); + } + } + + // Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas) + $element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element); + + if ($name === '') { $name = $element; } + + return compact('name', 'type', 'element', 'group'); + } + + private function typePrefix(array $meta): string + { + return match ($meta['type']) { + 'plugin' => "plg_{$meta['group']}_", + 'module' => 'mod_', + 'component' => 'com_', + 'template' => 'tpl_', + 'package' => 'pkg_', + 'library' => 'lib_', + default => '', + }; + } + + private function resolveLanguageKey(string $srcDir, string $key): ?string + { + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iter as $item) { + if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) { + foreach (file($item->getPathname()) as $line) { + if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) { + return $m[1]; + } + } + } + } + return null; + } + + private function isExcluded(string $name): bool + { + if ($name === '.ftpignore') return true; + if (str_starts_with($name, 'sftp-config')) return true; + if (str_starts_with($name, '.env')) return true; + if (str_starts_with($name, '.build-trigger')) return true; + $ext = pathinfo($name, PATHINFO_EXTENSION); + return in_array($ext, ['ppk', 'pem', 'key', 'local'], true); + } + + private function buildZip(string $srcDir, string $outPath): void + { + $zip = new ZipArchive(); + if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "::error::Cannot create ZIP: {$outPath}"); + return; + } + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iter as $file) { + $local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1)); + if ($this->isExcluded(basename($local))) continue; + $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); + } + $zip->close(); + } + + private function buildPackageZip(string $srcDir, string $outPath): void + { + $this->log('INFO', "Building Joomla package (multi-extension)..."); + $staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid(); + mkdir($staging, 0755, true); + + // 1. Zip each sub-extension in packages/ + $packagesDir = "{$srcDir}/packages"; + if (is_dir($packagesDir)) { + foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) { + $subManifest = $this->findManifest($extDir); + if ($subManifest) { + $sub = $this->parseManifest($subManifest); + $subPrefix = $this->typePrefix($sub); + $subZipName = "{$subPrefix}{$sub['element']}.zip"; + } else { + $subZipName = basename($extDir) . '.zip'; + } + + $this->log('INFO', " Sub-extension: {$subZipName}"); + $this->buildZip($extDir, "{$staging}/{$subZipName}"); + } + } + + // 2. Copy package-level files (manifest, script, language) + foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); + foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); + foreach (['language', 'administrator'] as $d) { + if (is_dir("{$srcDir}/{$d}")) { + $this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}"); + } + } + + // 3. Create outer zip + $this->buildZip($staging, $outPath); + + // Cleanup + $this->rmTree($staging); + } + + private function copyTree(string $src, string $dst): void + { + if (!is_dir($dst)) mkdir($dst, 0755, true); + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iter as $item) { + $target = "{$dst}/" . $iter->getSubPathname(); + $item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target); + } + } + + private function rmTree(string $dir): void + { + if (!is_dir($dir)) return; + $iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iter as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); + } } -function parseManifest(string $file): array -{ - $xml = simplexml_load_file($file); - $name = (string) ($xml->name ?? ''); - $type = (string) ($xml->attributes()->type ?? 'component'); - $element = (string) ($xml->element ?? ''); - $group = (string) ($xml->attributes()->group ?? ''); - - // For packages, prefer as the clean element (avoids pkg_pkg_ duplication) - if ($type === 'package' && $element === '') { - $packageName = (string) ($xml->packagename ?? ''); - if ($packageName !== '') { - $element = $packageName; - } - } - - // Fallback element detection - if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); } - if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); } - if ($element === '') { - $element = strtolower(basename($file, '.xml')); - if (in_array($element, ['templatedetails', 'manifest'], true)) { - $element = strtolower(basename(dirname($file))); - } - } - - // Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas → mokowaas) - $element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element); - - if ($name === '') { $name = $element; } - - return compact('name', 'type', 'element', 'group'); -} - -function typePrefix(array $meta): string -{ - return match ($meta['type']) { - 'plugin' => "plg_{$meta['group']}_", - 'module' => 'mod_', - 'component' => 'com_', - 'template' => 'tpl_', - 'package' => 'pkg_', - 'library' => 'lib_', - default => '', - }; -} - -function resolveLanguageKey(string $srcDir, string $key): ?string -{ - $iter = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS) - ); - foreach ($iter as $item) { - if ($item->isFile() && str_ends_with($item->getFilename(), '.sys.ini')) { - foreach (file($item->getPathname()) as $line) { - if (preg_match('/^' . preg_quote($key, '/') . '="(.+)"/', trim($line), $m)) { - return $m[1]; - } - } - } - } - return null; -} - -function isExcluded(string $name): bool -{ - if ($name === '.ftpignore') return true; - if (str_starts_with($name, 'sftp-config')) return true; - if (str_starts_with($name, '.env')) return true; - if (str_starts_with($name, '.build-trigger')) return true; - $ext = pathinfo($name, PATHINFO_EXTENSION); - return in_array($ext, ['ppk', 'pem', 'key', 'local'], true); -} - -function buildZip(string $srcDir, string $outPath): void -{ - $zip = new ZipArchive(); - if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - fwrite(STDERR, "::error::Cannot create ZIP: {$outPath}\n"); - exit(1); - } - $iter = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($iter as $file) { - $local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1)); - if (isExcluded(basename($local))) continue; - $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); - } - $zip->close(); -} - -function buildPackageZip(string $srcDir, string $outPath): void -{ - fwrite(STDERR, "Building Joomla package (multi-extension)...\n"); - $staging = sys_get_temp_dir() . '/moko_pkg_' . uniqid(); - mkdir($staging, 0755, true); - - // 1. Zip each sub-extension in packages/ - $packagesDir = "{$srcDir}/packages"; - if (is_dir($packagesDir)) { - foreach (glob("{$packagesDir}/*", GLOB_ONLYDIR) as $extDir) { - $subManifest = findManifest($extDir); - if ($subManifest) { - $sub = parseManifest($subManifest); - $subPrefix = typePrefix($sub); - $subZipName = "{$subPrefix}{$sub['element']}.zip"; - } else { - $subZipName = basename($extDir) . '.zip'; - } - - fwrite(STDERR, " Sub-extension: {$subZipName}\n"); - buildZip($extDir, "{$staging}/{$subZipName}"); - } - } - - // 2. Copy package-level files (manifest, script, language) - foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); - foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); - foreach (['language', 'administrator'] as $d) { - if (is_dir("{$srcDir}/{$d}")) { - copyTree("{$srcDir}/{$d}", "{$staging}/{$d}"); - } - } - - // 3. Create outer zip - buildZip($staging, $outPath); - - // Cleanup - rmTree($staging); -} - -function copyTree(string $src, string $dst): void -{ - if (!is_dir($dst)) mkdir($dst, 0755, true); - $iter = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($iter as $item) { - $target = "{$dst}/" . $iter->getSubPathname(); - $item->isDir() ? (is_dir($target) || mkdir($target, 0755, true)) : copy($item->getPathname(), $target); - } -} - -function rmTree(string $dir): void -{ - if (!is_dir($dir)) return; - $iter = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ($iter as $item) { - $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); - } - rmdir($dir); -} +$app = new JoomlaBuildCli(); +exit($app->execute()); diff --git a/cli/joomla_compat_check.php b/cli/joomla_compat_check.php index b831252..dcf9e98 100644 --- a/cli/joomla_compat_check.php +++ b/cli/joomla_compat_check.php @@ -10,127 +10,134 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/joomla_compat_check.php * BRIEF: Check if extension targetplatform regex matches the latest Joomla version - * - * Usage: - * php joomla_compat_check.php --path /repo - * php joomla_compat_check.php --path /repo --github-output - * - * Options: - * --path Repository root (default: .) - * --github-output Export results to $GITHUB_OUTPUT */ declare(strict_types=1); -$path = '.'; -$ghOutput = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--github-output') $ghOutput = true; +use MokoEnterprise\CliFramework; + +class JoomlaCompatCheckCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Check if extension targetplatform regex matches the latest Joomla version'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $ghOutput = $this->getArgument('--github-output'); + + $root = realpath($path) ?: $path; + + // -- Find manifest and extract targetplatform -- + $manifest = null; + $searchDirs = ["{$root}/src", $root]; + foreach ($searchDirs as $dir) { + if (!is_dir($dir)) { + continue; + } + foreach (glob("{$dir}/*.xml") ?: [] as $f) { + $xml = file_get_contents($f); + if (strpos($xml, 'log('ERROR', 'No manifest with targetplatform found'); + return 1; + } + + $xml = file_get_contents($manifest); + $relManifest = str_replace($root . '/', '', $manifest); + + // Extract targetplatform version regex + $targetRegex = ''; + if (preg_match('/targetplatform[^>]*version="([^"]+)"/', $xml, $m)) { + $targetRegex = $m[1]; + } + + if (empty($targetRegex)) { + echo "No targetplatform version found in {$relManifest}\n"; + return 1; + } + + echo "Manifest: {$relManifest}\n"; + echo "Target regex: {$targetRegex}\n"; + + // -- Fetch latest Joomla version -- + $joomlaVersions = []; + $updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml'; + $updateXml = @file_get_contents($updateUrl); + + if ($updateXml === false) { + // Fallback: try the LTS feed + $updateUrl = 'https://update.joomla.org/core/list.xml'; + $updateXml = @file_get_contents($updateUrl); + } + + if ($updateXml !== false) { + // Parse all version entries + preg_match_all('/([^<]+)<\/version>/', $updateXml, $matches); + $joomlaVersions = $matches[1] ?? []; + } + + if (empty($joomlaVersions)) { + echo "WARNING: Could not fetch Joomla versions from update server\n"; + echo "Tested URL: {$updateUrl}\n"; + return 0; + } + + // Sort and get latest + usort($joomlaVersions, 'version_compare'); + $latestJoomla = end($joomlaVersions); + + echo "Latest Joomla: {$latestJoomla}\n"; + + // -- Test compatibility -- + $compatible = @preg_match("/{$targetRegex}/", $latestJoomla); + + if ($compatible === false) { + echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n"; + $result = 'error'; + } elseif ($compatible === 1) { + echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n"; + $result = 'pass'; + } else { + // Check which major versions are supported + $supported = []; + foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) { + if (@preg_match("/{$targetRegex}/", $v)) { + $supported[] = $v; + } + } + + echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n"; + echo "Supported versions: " . implode(', ', $supported) . "\n"; + echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n"; + $result = 'warn'; + } + + // -- Export -- + if ($ghOutput) { + $ghFile = getenv('GITHUB_OUTPUT'); + if ($ghFile) { + file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND); + file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND); + file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND); + } + } + + return $result === 'error' ? 1 : 0; + } } -$root = realpath($path) ?: $path; - -// ── Find manifest and extract targetplatform ──────────────────────────── -$manifest = null; -$searchDirs = ["{$root}/src", $root]; -foreach ($searchDirs as $dir) { - if (!is_dir($dir)) continue; - foreach (glob("{$dir}/*.xml") ?: [] as $f) { - $xml = file_get_contents($f); - if (strpos($xml, ']*version="([^"]+)"/', $xml, $m)) { - $targetRegex = $m[1]; -} - -if (empty($targetRegex)) { - echo "No targetplatform version found in {$relManifest}\n"; - exit(1); -} - -echo "Manifest: {$relManifest}\n"; -echo "Target regex: {$targetRegex}\n"; - -// ── Fetch latest Joomla version ───────────────────────────────────────── -$joomlaVersions = []; -$updateUrl = 'https://update.joomla.org/core/sts/list_sts.xml'; -$updateXml = @file_get_contents($updateUrl); - -if ($updateXml === false) { - // Fallback: try the LTS feed - $updateUrl = 'https://update.joomla.org/core/list.xml'; - $updateXml = @file_get_contents($updateUrl); -} - -if ($updateXml !== false) { - // Parse all version entries - preg_match_all('/([^<]+)<\/version>/', $updateXml, $matches); - $joomlaVersions = $matches[1] ?? []; -} - -if (empty($joomlaVersions)) { - echo "WARNING: Could not fetch Joomla versions from update server\n"; - echo "Tested URL: {$updateUrl}\n"; - exit(0); -} - -// Sort and get latest -usort($joomlaVersions, 'version_compare'); -$latestJoomla = end($joomlaVersions); - -echo "Latest Joomla: {$latestJoomla}\n"; - -// ── Test compatibility ────────────────────────────────────────────────── -// The targetplatform regex uses Joomla's regex format -// Common patterns: "5\.[0-9]+" or "((5.[0-9])|(6.[0-9]))" -$compatible = @preg_match("/{$targetRegex}/", $latestJoomla); - -if ($compatible === false) { - echo "ERROR: Invalid regex in targetplatform: {$targetRegex}\n"; - $result = 'error'; -} elseif ($compatible === 1) { - echo "PASS: Joomla {$latestJoomla} matches targetplatform regex\n"; - $result = 'pass'; -} else { - // Check which major versions are supported - $supported = []; - foreach (['5.0', '5.1', '5.2', '5.3', '5.4', '6.0', '6.1', '6.2', '7.0'] as $v) { - if (@preg_match("/{$targetRegex}/", $v)) { - $supported[] = $v; - } - } - - echo "WARN: Joomla {$latestJoomla} does NOT match targetplatform regex\n"; - echo "Supported versions: " . implode(', ', $supported) . "\n"; - echo "Consider updating targetplatform to include Joomla {$latestJoomla}\n"; - $result = 'warn'; -} - -// ── Export ─────────────────────────────────────────────────────────────── -if ($ghOutput) { - $ghFile = getenv('GITHUB_OUTPUT'); - if ($ghFile) { - file_put_contents($ghFile, "compat_result={$result}\n", FILE_APPEND); - file_put_contents($ghFile, "compat_joomla={$latestJoomla}\n", FILE_APPEND); - file_put_contents($ghFile, "compat_regex={$targetRegex}\n", FILE_APPEND); - } -} - -exit($result === 'error' ? 1 : 0); +$app = new JoomlaCompatCheckCli(); +exit($app->execute()); diff --git a/cli/manifest_element.php b/cli/manifest_element.php index db0fdd8..73d1ed1 100644 --- a/cli/manifest_element.php +++ b/cli/manifest_element.php @@ -11,228 +11,84 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/manifest_element.php * BRIEF: Extract element name, type, type prefix, and ZIP name from manifest - * - * Usage: - * php manifest_element.php --path . - * php manifest_element.php --path . --version 09.01.00 --stability dev --github-output - * - * Detects platform (joomla, dolibarr, generic) and resolves: - * ext_element — canonical element name (e.g. mokojgdpc) - * ext_type — extension type (plugin, module, component, package, etc.) - * ext_folder — group/folder for plugins (e.g. system) - * ext_name — human-readable name (e.g. "Moko JGDPC") - * type_prefix — Joomla type prefix (plg_system_, com_, mod_, etc.) - * zip_name — computed ZIP filename */ declare(strict_types=1); -$path = '.'; -$version = null; -$stability = 'stable'; -$githubOutput = false; -$repoName = ''; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) { - $path = $argv[$i + 1]; - } - if ($arg === '--version' && isset($argv[$i + 1])) { - $version = $argv[$i + 1]; - } - if ($arg === '--stability' && isset($argv[$i + 1])) { - $stability = $argv[$i + 1]; - } - if ($arg === '--repo' && isset($argv[$i + 1])) { - $repoName = $argv[$i + 1]; - } - if ($arg === '--github-output') { - $githubOutput = true; - } +use MokoEnterprise\CliFramework; + +class ManifestElementCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--version', 'Version string', null); + $this->addArgument('--stability', 'Stability level', 'stable'); + $this->addArgument('--repo', 'Repository name', ''); + $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); $version = $this->getArgument('--version'); + $stability = $this->getArgument('--stability'); $repoName = $this->getArgument('--repo'); + $githubOutput = (bool) $this->getArgument('--github-output'); + $root = realpath($path) ?: $path; + $platform = 'generic'; + $manifestXml = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($manifestXml)) { $content = file_get_contents($manifestXml); if (preg_match('/([^<]+)<\/platform>/', $content, $pm)) { $platform = trim($pm[1]); } } + $extManifest = null; + $manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []); + foreach ($manifestFiles as $file) { $c = file_get_contents($file); if (strpos($c, '([^<]+)<\/element>/', $xml, $em)) { $extElement = $em[1]; } + if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) { $extElement = $mm[1]; } + if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { $extElement = $pm2[1]; } + if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { $extElement = $pn[1]; } + if (empty($extElement)) { $extElement = strtolower(basename($extManifest, '.xml')); if (in_array($extElement, ['templatedetails', 'manifest'], true)) { $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); } } + if (preg_match('/([^<]+)<\/name>/', $xml, $nm)) { $extName = trim($nm[1]); } + break; + case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: + $extType = 'dolibarr-module'; $modBasename = basename($modFile, '.class.php'); + $extElement = strtolower(preg_replace('/^mod/', '', $modBasename)); + $modContent = file_get_contents($modFile); + if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) { $extName = $nm[1]; } + break; + default: + $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); $extType = 'generic'; break; + } + $extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); + $typePrefix = ''; + switch ($extType) { + case 'plugin': $typePrefix = "plg_{$extFolder}_"; break; case 'module': $typePrefix = 'mod_'; break; + case 'component': $typePrefix = 'com_'; break; case 'template': $typePrefix = 'tpl_'; break; + case 'library': $typePrefix = 'lib_'; break; case 'package': $typePrefix = 'pkg_'; break; + } + $suffixMap = ['development' => '-dev', 'dev' => '-dev', 'alpha' => '-alpha', 'beta' => '-beta', 'rc' => '-rc', 'release-candidate' => '-rc', 'stable' => '']; + $suffix = $suffixMap[$stability] ?? ''; $zipName = ''; + if ($version !== null) { $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip"; } + if (empty($extName)) { $extName = $repoName ?: basename($root); } + $outputs = ['platform' => $platform, 'ext_element' => $extElement, 'ext_type' => $extType, 'ext_folder' => $extFolder, 'ext_name' => $extName, 'type_prefix' => $typePrefix, 'zip_name' => $zipName]; + if ($githubOutput) { + $ghOutput = getenv('GITHUB_OUTPUT'); $lines = []; + foreach ($outputs as $key => $value) { $lines[] = "{$key}={$value}"; } + if ($ghOutput) { file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); } + else { foreach ($outputs as $key => $value) { echo "::set-output name={$key}::{$value}\n"; } } + } else { foreach ($outputs as $key => $value) { echo "{$key}={$value}\n"; } } + return 0; + } } -$root = realpath($path) ?: $path; - -// ── Detect platform from manifest.xml ──────────────────────────────────────── -$platform = 'generic'; -$manifestXml = "{$root}/.mokogitea/manifest.xml"; -if (file_exists($manifestXml)) { - $content = file_get_contents($manifestXml); - if (preg_match('/([^<]+)<\/platform>/', $content, $pm)) { - $platform = trim($pm[1]); - } -} - -// ── Find extension manifest (Joomla XML) ───────────────────────────────────── -$extManifest = null; -$manifestFiles = array_merge( - glob("{$root}/src/pkg_*.xml") ?: [], - glob("{$root}/src/*.xml") ?: [], - glob("{$root}/*.xml") ?: [] -); -foreach ($manifestFiles as $file) { - $c = file_get_contents($file); - if (strpos($c, ', module= attribute, plugin= attribute, , or filename - if (preg_match('/([^<]+)<\/element>/', $xml, $em)) { - $extElement = $em[1]; - } - if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) { - $extElement = $mm[1]; - } - if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { - $extElement = $pm[1]; - } - if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { - $extElement = $pn[1]; - } - if (empty($extElement)) { - $extElement = strtolower(basename($extManifest, '.xml')); - if (in_array($extElement, ['templatedetails', 'manifest'], true)) { - $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); - } - } - - // Human-readable name - if (preg_match('/([^<]+)<\/name>/', $xml, $nm)) { - $extName = trim($nm[1]); - } - break; - - // Dolibarr platforms - case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: - $extType = 'dolibarr-module'; - $modBasename = basename($modFile, '.class.php'); - $extElement = strtolower(preg_replace('/^mod/', '', $modBasename)); - - $modContent = file_get_contents($modFile); - if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) { - $extName = $nm[1]; - } - break; - - // Generic / fallback - default: - $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); - $extType = 'generic'; - break; -} - -// ── Strip existing type prefix from element to prevent duplication ──────────── -$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); - -// ── Compute type prefix ────────────────────────────────────────────────────── -$typePrefix = ''; -switch ($extType) { - case 'plugin': - $typePrefix = "plg_{$extFolder}_"; - break; - case 'module': - $typePrefix = 'mod_'; - break; - case 'component': - $typePrefix = 'com_'; - break; - case 'template': - $typePrefix = 'tpl_'; - break; - case 'library': - $typePrefix = 'lib_'; - break; - case 'package': - $typePrefix = 'pkg_'; - break; -} - -// ── Compute ZIP name ───────────────────────────────────────────────────────── -$suffixMap = [ - 'development' => '-dev', - 'dev' => '-dev', - 'alpha' => '-alpha', - 'beta' => '-beta', - 'rc' => '-rc', - 'release-candidate' => '-rc', - 'stable' => '', -]; -$suffix = $suffixMap[$stability] ?? ''; -$zipName = ''; -if ($version !== null) { - $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip"; -} - -// Fallback name -if (empty($extName)) { - $extName = $repoName ?: basename($root); -} - -// ── Output ─────────────────────────────────────────────────────────────────── -$outputs = [ - 'platform' => $platform, - 'ext_element' => $extElement, - 'ext_type' => $extType, - 'ext_folder' => $extFolder, - 'ext_name' => $extName, - 'type_prefix' => $typePrefix, - 'zip_name' => $zipName, -]; - -if ($githubOutput) { - $ghOutput = getenv('GITHUB_OUTPUT'); - $lines = []; - foreach ($outputs as $key => $value) { - $lines[] = "{$key}={$value}"; - } - if ($ghOutput) { - file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); - } else { - // Fallback: echo ::set-output (legacy) - foreach ($outputs as $key => $value) { - echo "::set-output name={$key}::{$value}\n"; - } - } -} else { - foreach ($outputs as $key => $value) { - echo "{$key}={$value}\n"; - } -} - -exit(0); +$app = new ManifestElementCli(); +exit($app->execute()); diff --git a/cli/manifest_read.php b/cli/manifest_read.php index 6aa4a0e..afb05d2 100644 --- a/cli/manifest_read.php +++ b/cli/manifest_read.php @@ -9,167 +9,161 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/manifest_read.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Parse .manifest.xml and output requested field(s) for CI consumption - * - * Usage: - * php manifest_read.php --path /repo --field platform - * php manifest_read.php --path /repo --field entry-point - * php manifest_read.php --path /repo --all - * php manifest_read.php --path /repo --github-output - * - * Fields: name, org, description, license, license-spdx, platform, - * standards-version, standards-source, language, package-type, entry-point, - * source-dir, remote-subdir, excludes, dev-host, demo-host - * - * --all Print all fields as KEY=VALUE lines - * --github-output Append all fields to $GITHUB_OUTPUT (for Gitea/GitHub Actions) - * --json Output all fields as JSON - * --field Print a single field value (no key, just value) */ declare(strict_types=1); -// -- Argument parsing --------------------------------------------------------- -$path = '.'; -$field = null; -$mode = 'field'; // field | all | github-output | json +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--field' && isset($argv[$i + 1])) $field = $argv[$i + 1]; - if ($arg === '--all') $mode = 'all'; - if ($arg === '--github-output') $mode = 'github-output'; - if ($arg === '--json') $mode = 'json'; -} +use MokoEnterprise\CliFramework; -// -- Locate manifest ---------------------------------------------------------- -$root = realpath($path) ?: $path; -$manifestFile = null; - -// Priority: manifest.xml (current standard) -$candidates = [ - "{$root}/.mokogitea/manifest.xml", - "{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed) - "{$root}/.mokogitea/.moko-platform", // legacy v4 -]; - -foreach ($candidates as $candidate) { - if (file_exists($candidate)) { - $manifestFile = $candidate; - break; +class ManifestReadCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Parse manifest.xml and output requested field(s) for CI consumption'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--field', 'Single field name to output', ''); + $this->addArgument('--all', 'Print all fields as KEY=VALUE lines', false); + $this->addArgument('--github-output', 'Append all fields to $GITHUB_OUTPUT', false); + $this->addArgument('--json', 'Output all fields as JSON', false); } -} -if ($manifestFile === null) { - fwrite(STDERR, "No manifest found in {$root} -"); - exit(1); -} + protected function run(): int + { + $path = $this->getArgument('--path'); + $field = $this->getArgument('--field'); + $showAll = $this->getArgument('--all'); + $ghOutput = $this->getArgument('--github-output'); + $jsonMode = $this->getArgument('--json'); -// -- Parse XML ---------------------------------------------------------------- -$xml = @simplexml_load_file($manifestFile); - -if ($xml === false) { - // Fallback: try YAML format (.mokostandards legacy) - $content = file_get_contents($manifestFile); - $fields = []; - if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { - $fields['platform'] = trim($m[1], " - -\"'"); - } - if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) { - $fields['standards-version'] = trim($m[1], " - -\"'"); - } - if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) { - $fields['name'] = trim($m[1], " - -\"'"); - } -} else { - // Register namespace for XPath (optional, simple path works without) - $fields = [ - 'name' => (string)($xml->identity->name ?? ''), - 'display-name' => (string)($xml->identity->{"display-name"} ?? ''), - 'org' => (string)($xml->identity->org ?? ''), - 'description' => (string)($xml->identity->description ?? ''), - 'license' => (string)($xml->identity->license ?? ''), - 'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''), - 'platform' => (string)($xml->governance->platform ?? ''), - 'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''), - 'standards-source' => (string)($xml->governance->{"standards-source"} ?? ''), - 'language' => (string)($xml->build->language ?? ''), - 'package-type' => (string)($xml->build->{"package-type"} ?? ''), - 'entry-point' => (string)($xml->build->{"entry-point"} ?? ''), - 'version' => (string)($xml->identity->version ?? ''), - 'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''), - 'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''), - 'excludes' => (string)($xml->deploy->excludes ?? ''), - 'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''), - 'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''), - 'manifest-file' => $manifestFile, - ]; -} - -// Strip empty values for cleaner output -$fields = array_filter($fields, fn($v) => $v !== ''); - -// -- Output ------------------------------------------------------------------- -switch ($mode) { - case 'field': - if ($field === null) { - fwrite(STDERR, "Usage: manifest_read.php --path --field -"); - fwrite(STDERR, " manifest_read.php --path --all -"); - fwrite(STDERR, " manifest_read.php --path --json -"); - fwrite(STDERR, " manifest_read.php --path --github-output -"); - exit(2); + // Determine mode + if ($ghOutput) { + $mode = 'github-output'; + } elseif ($showAll) { + $mode = 'all'; + } elseif ($jsonMode) { + $mode = 'json'; + } else { + $mode = 'field'; } - echo ($fields[$field] ?? '') . " -"; - break; - case 'all': - foreach ($fields as $k => $v) { - echo "{$k}={$v} -"; + // -- Locate manifest -- + $root = realpath($path) ?: $path; + $manifestFile = null; + + // Priority: manifest.xml (current standard) + $candidates = [ + "{$root}/.mokogitea/manifest.xml", + "{$root}/.mokogitea/.manifest.xml", // legacy (dot-prefixed) + "{$root}/.mokogitea/.moko-platform", // legacy v4 + ]; + + foreach ($candidates as $candidate) { + if (file_exists($candidate)) { + $manifestFile = $candidate; + break; + } } - break; - case 'json': - echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . " -"; - break; + if ($manifestFile === null) { + $this->log('ERROR', "No manifest found in {$root}"); + return 1; + } - case 'github-output': - $outputFile = getenv('GITHUB_OUTPUT'); - if ($outputFile === false || $outputFile === '') { - fwrite(STDERR, "GITHUB_OUTPUT not set — printing to stdout instead -"); - foreach ($fields as $k => $v) { - // Convert field-name to FIELD_NAME for env var style - $envKey = str_replace('-', '_', $k); - echo "{$envKey}={$v} -"; + // -- Parse XML -- + $xml = @simplexml_load_file($manifestFile); + + if ($xml === false) { + // Fallback: try YAML format (.mokostandards legacy) + $content = file_get_contents($manifestFile); + $fields = []; + if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { + $fields['platform'] = trim($m[1], " \t\n\r\"'"); + } + if (preg_match('/^standards_version:\s*(.+)/m', $content, $m)) { + $fields['standards-version'] = trim($m[1], " \t\n\r\"'"); + } + if (preg_match('/^governed_repo:\s*(.+)/m', $content, $m)) { + $fields['name'] = trim($m[1], " \t\n\r\"'"); } } else { - $fh = fopen($outputFile, 'a'); - foreach ($fields as $k => $v) { - $envKey = str_replace('-', '_', $k); - fwrite($fh, "{$envKey}={$v} -"); - } - fclose($fh); - fwrite(STDERR, "Wrote " . count($fields) . " fields to GITHUB_OUTPUT -"); + // Register namespace for XPath (optional, simple path works without) + $fields = [ + 'name' => (string)($xml->identity->name ?? ''), + 'display-name' => (string)($xml->identity->{"display-name"} ?? ''), + 'org' => (string)($xml->identity->org ?? ''), + 'description' => (string)($xml->identity->description ?? ''), + 'license' => (string)($xml->identity->license ?? ''), + 'license-spdx' => (string)($xml->identity->license['spdx'] ?? ''), + 'platform' => (string)($xml->governance->platform ?? ''), + 'standards-version' => (string)($xml->governance->{"standards-version"} ?? ''), + 'standards-source' => (string)($xml->governance->{"standards-source"} ?? ''), + 'language' => (string)($xml->build->language ?? ''), + 'package-type' => (string)($xml->build->{"package-type"} ?? ''), + 'entry-point' => (string)($xml->build->{"entry-point"} ?? ''), + 'version' => (string)($xml->identity->version ?? ''), + 'source-dir' => (string)($xml->deploy->{"source-dir"} ?? ''), + 'remote-subdir' => (string)($xml->deploy->{"remote-subdir"} ?? ''), + 'excludes' => (string)($xml->deploy->excludes ?? ''), + 'dev-host' => (string)($xml->deploy->{"dev-host"} ?? ''), + 'demo-host' => (string)($xml->deploy->{"demo-host"} ?? ''), + 'manifest-file' => $manifestFile, + ]; } - break; + + // Strip empty values for cleaner output + $fields = array_filter($fields, fn($v) => $v !== ''); + + // -- Output -- + switch ($mode) { + case 'field': + if ($field === '') { + $this->log('ERROR', "Usage: manifest_read.php --path --field "); + $this->log('ERROR', " manifest_read.php --path --all"); + $this->log('ERROR', " manifest_read.php --path --json"); + $this->log('ERROR', " manifest_read.php --path --github-output"); + return 2; + } + echo ($fields[$field] ?? '') . "\n"; + break; + + case 'all': + foreach ($fields as $k => $v) { + echo "{$k}={$v}\n"; + } + break; + + case 'json': + echo json_encode($fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + break; + + case 'github-output': + $outputFile = getenv('GITHUB_OUTPUT'); + if ($outputFile === false || $outputFile === '') { + $this->log('ERROR', 'GITHUB_OUTPUT not set — printing to stdout instead'); + foreach ($fields as $k => $v) { + // Convert field-name to FIELD_NAME for env var style + $envKey = str_replace('-', '_', $k); + echo "{$envKey}={$v}\n"; + } + } else { + $fh = fopen($outputFile, 'a'); + foreach ($fields as $k => $v) { + $envKey = str_replace('-', '_', $k); + fwrite($fh, "{$envKey}={$v}\n"); + } + fclose($fh); + $this->log('INFO', "Wrote " . count($fields) . " fields to GITHUB_OUTPUT"); + } + break; + } + + return 0; + } } -exit(0); +$app = new ManifestReadCli(); +exit($app->execute()); diff --git a/cli/package_build.php b/cli/package_build.php index ae4e95b..c50ddf6 100644 --- a/cli/package_build.php +++ b/cli/package_build.php @@ -12,344 +12,334 @@ * PATH: /cli/package_build.php * BRIEF: Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects * - * Usage: - * php package_build.php --path /repo --version 04.01.00 - * php package_build.php --path /repo --version 04.01.00 --output-dir /tmp - * php package_build.php --path /repo --version 04.01.00 --github-output - * - * Options: - * --path Repository root (default: .) - * --version Version string (required) - * --output-dir Directory for built packages (default: /tmp) - * --type-prefix Override type prefix (e.g. plg_system_) - * --element Override element name - * --github-output Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT - * * NOTE: Uses PHP exec() with escapeshellarg() for tar — all arguments are escaped. */ declare(strict_types=1); -$path = '.'; -$version = null; -$outputDir = '/tmp'; -$typePrefixOverride = null; -$elementOverride = null; -$githubOutput = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) { - $path = $argv[$i + 1]; +use MokoEnterprise\CliFramework; + +class PackageBuildCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Build ZIP and tar.gz install packages for Joomla/Dolibarr/generic projects'); + $this->addArgument('--path', 'Repository root (default: .)', '.'); + $this->addArgument('--version', 'Version string (required)', ''); + $this->addArgument('--output-dir', 'Directory for built packages (default: /tmp)', '/tmp'); + $this->addArgument('--type-prefix', 'Override type prefix (e.g. plg_system_)', ''); + $this->addArgument('--element', 'Override element name', ''); + $this->addArgument('--github-output', 'Export zip_name, tar_name, sha256_zip, sha256_tar to $GITHUB_OUTPUT', false); } - if ($arg === '--version' && isset($argv[$i + 1])) { - $version = $argv[$i + 1]; - } - if ($arg === '--output-dir' && isset($argv[$i + 1])) { - $outputDir = $argv[$i + 1]; - } - if ($arg === '--type-prefix' && isset($argv[$i + 1])) { - $typePrefixOverride = $argv[$i + 1]; - } - if ($arg === '--element' && isset($argv[$i + 1])) { - $elementOverride = $argv[$i + 1]; - } - if ($arg === '--github-output') { - $githubOutput = true; - } -} -if ($version === null) { - fwrite(STDERR, "Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]\n"); - exit(1); -} + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $outputDir = $this->getArgument('--output-dir'); + $typePrefixOverride = $this->getArgument('--type-prefix') ?: null; + $elementOverride = $this->getArgument('--element') ?: null; + $githubOutput = $this->getArgument('--github-output'); -$root = realpath($path) ?: $path; - -// Ensure output directory exists -if (!is_dir($outputDir)) { - mkdir($outputDir, 0755, true); -} - -// -- Determine source directory ----------------------------------------------- -$sourceDir = null; -foreach (['src', 'htdocs'] as $candidate) { - if (is_dir("{$root}/{$candidate}")) { - $sourceDir = "{$root}/{$candidate}"; - break; - } -} - -if ($sourceDir === null) { - fwrite(STDERR, "No src/ or htdocs/ directory found in {$root}\n"); - exit(1); -} - -// -- Determine element and type prefix from manifest -------------------------- -$extElement = $elementOverride; -$typePrefix = $typePrefixOverride ?? ''; -$extType = ''; -$isPackage = false; - -if ($extElement === null || $typePrefixOverride === null) { - // Find manifest - $manifest = null; - foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) { - if (strpos(file_get_contents($f), 'log('ERROR', 'Usage: package_build.php --path . --version XX.YY.ZZ [--output-dir /tmp]'); + return 1; } - } - if ($manifest === null) { - foreach (glob("{$sourceDir}/*.xml") ?: [] as $f) { - if (strpos(file_get_contents($f), 'log('ERROR', "No src/ or htdocs/ directory found in {$root}"); + return 1; + } + + // -- Determine element and type prefix from manifest -------------------------- + $extElement = $elementOverride; + $typePrefix = $typePrefixOverride ?? ''; + $extType = ''; + $isPackage = false; + + if ($extElement === null || $typePrefixOverride === null) { + // Find manifest + $manifest = null; + foreach (glob("{$sourceDir}/pkg_*.xml") ?: [] as $f) { + if (strpos(file_get_contents($f), '([^<]+)<\/element>/', $xml, $m)) { + $extElement = $m[1]; + } elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) { + $extElement = $m[1]; + } elseif (preg_match('/module="([^"]+)"/', $xml, $m)) { + $extElement = $m[1]; + } else { + $extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME)); + } + } + + if (preg_match('/]*type="([^"]+)"/', $xml, $m)) { + $extType = $m[1]; + } + $extFolder = ''; + if (preg_match('/]*group="([^"]+)"/', $xml, $m)) { + $extFolder = $m[1]; + } + + if ($typePrefixOverride === null) { + switch ($extType) { + case 'plugin': + $typePrefix = "plg_{$extFolder}_"; + break; + case 'module': + $typePrefix = 'mod_'; + break; + case 'component': + $typePrefix = 'com_'; + break; + case 'template': + $typePrefix = 'tpl_'; + break; + case 'library': + $typePrefix = 'lib_'; + break; + case 'package': + $typePrefix = 'pkg_'; + break; + } + } + + $isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); + } + } if ($extElement === null) { - if (preg_match('/([^<]+)<\/element>/', $xml, $m)) { - $extElement = $m[1]; - } elseif (preg_match('/plugin="([^"]+)"/', $xml, $m)) { - $extElement = $m[1]; - } elseif (preg_match('/module="([^"]+)"/', $xml, $m)) { - $extElement = $m[1]; - } else { - $extElement = strtolower(pathinfo($manifest, PATHINFO_FILENAME)); + $extElement = strtolower(basename($root)); + } + + // Prevent double prefix (e.g. pkg_pkg_mokogallery) + if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) { + $extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1); + } + + $zipName = "{$typePrefix}{$extElement}-{$version}.zip"; + $tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz"; + $zipPath = "{$outputDir}/{$zipName}"; + $tarPath = "{$outputDir}/{$tarName}"; + + // -- Exclude patterns --------------------------------------------------------- + $excludePatterns = [ + '.ftpignore', + 'sftp-config*', + '*.ppk', + '*.pem', + '*.key', + '.env*', + ]; + + // -- Build packages ----------------------------------------------------------- + if ($isPackage) { + echo "=== Building Joomla PACKAGE (multi-extension) ===\n"; + + $stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid(); + $packagesDir = "{$stagingDir}/packages"; + mkdir($packagesDir, 0755, true); + + // ZIP each sub-extension into packages/ + foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) { + $subName = basename($extDir); + echo " Packaging sub-extension: {$subName}\n"; + + $subZip = new \ZipArchive(); + $subZipPath = "{$packagesDir}/{$subName}.zip"; + if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create ZIP for {$subName}"); + continue; + } + + $this->addDirectoryToZip($subZip, $extDir, '', $excludePatterns); + $subZip->close(); + echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n"; } - } - if (preg_match('/]*type="([^"]+)"/', $xml, $m)) { - $extType = $m[1]; - } - $extFolder = ''; - if (preg_match('/]*group="([^"]+)"/', $xml, $m)) { - $extFolder = $m[1]; - } - - if ($typePrefixOverride === null) { - switch ($extType) { - case 'plugin': - $typePrefix = "plg_{$extFolder}_"; - break; - case 'module': - $typePrefix = 'mod_'; - break; - case 'component': - $typePrefix = 'com_'; - break; - case 'template': - $typePrefix = 'tpl_'; - break; - case 'library': - $typePrefix = 'lib_'; - break; - case 'package': - $typePrefix = 'pkg_'; - break; + // Copy package-level files (manifest, script.php, etc.) + foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) { + copy($f, "{$stagingDir}/" . basename($f)); } - } - $isPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); - } -} - -if ($extElement === null) { - $extElement = strtolower(basename($root)); -} - -// Prevent double prefix (e.g. pkg_pkg_mokogallery) -if ($typePrefix !== '' && str_starts_with($extElement, rtrim($typePrefix, '_'))) { - $extElement = substr($extElement, strlen(rtrim($typePrefix, '_')) + 1); -} - -$zipName = "{$typePrefix}{$extElement}-{$version}.zip"; -$tarName = "{$typePrefix}{$extElement}-{$version}.tar.gz"; -$zipPath = "{$outputDir}/{$zipName}"; -$tarPath = "{$outputDir}/{$tarName}"; - -// -- Exclude patterns --------------------------------------------------------- -$excludePatterns = [ - '.ftpignore', - 'sftp-config*', - '*.ppk', - '*.pem', - '*.key', - '.env*', -]; - -// -- Build packages ----------------------------------------------------------- -if ($isPackage) { - echo "=== Building Joomla PACKAGE (multi-extension) ===\n"; - - $stagingDir = sys_get_temp_dir() . '/moko-pkg-' . uniqid(); - $packagesDir = "{$stagingDir}/packages"; - mkdir($packagesDir, 0755, true); - - // ZIP each sub-extension into packages/ - foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) { - $subName = basename($extDir); - echo " Packaging sub-extension: {$subName}\n"; - - $subZip = new ZipArchive(); - $subZipPath = "{$packagesDir}/{$subName}.zip"; - if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - fwrite(STDERR, "Failed to create ZIP for {$subName}\n"); - continue; - } - - addDirectoryToZip($subZip, $extDir, '', $excludePatterns); - $subZip->close(); - echo " -> packages/{$subName}.zip (" . filesize($subZipPath) . " bytes)\n"; - } - - // Copy package-level files (manifest, script.php, etc.) - foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) { - copy($f, "{$stagingDir}/" . basename($f)); - } - - // Copy language directory if present - if (is_dir("{$sourceDir}/language")) { - $langDest = "{$stagingDir}/language"; - mkdir($langDest, 0755, true); - $langIterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator("{$sourceDir}/language", RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($langIterator as $item) { - $target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1); - if ($item->isDir()) { - mkdir($target, 0755, true); - } else { - copy($item->getPathname(), $target); + // Copy language directory if present + if (is_dir("{$sourceDir}/language")) { + $langDest = "{$stagingDir}/language"; + mkdir($langDest, 0755, true); + $langIterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator("{$sourceDir}/language", \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($langIterator as $item) { + $target = $langDest . '/' . substr($item->getPathname(), strlen("{$sourceDir}/language") + 1); + if ($item->isDir()) { + mkdir($target, 0755, true); + } else { + copy($item->getPathname(), $target); + } + } } - } - } - // Create ZIP from staging - $zip = new ZipArchive(); - if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n"); - exit(1); - } - addDirectoryToZip($zip, $stagingDir, '', []); - $zip->close(); - - // Create tar.gz — all arguments are escaped via escapeshellarg() - $tarCmd = sprintf( - 'tar -czf %s -C %s .', - escapeshellarg($tarPath), - escapeshellarg($stagingDir) - ); - passthru($tarCmd, $tarReturn); - - // Cleanup staging - $cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir)); - passthru($cleanCmd); -} else { - echo "=== Building standard extension package ===\n"; - - // ZIP - $zip = new ZipArchive(); - if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - fwrite(STDERR, "Failed to create ZIP: {$zipPath}\n"); - exit(1); - } - addDirectoryToZip($zip, $sourceDir, '', $excludePatterns); - $zip->close(); - - // tar.gz — all arguments are escaped via escapeshellarg() - $excludeArgs = ''; - foreach ($excludePatterns as $pattern) { - $excludeArgs .= ' --exclude=' . escapeshellarg($pattern); - } - $tarCmd = sprintf( - 'tar -czf %s -C %s%s .', - escapeshellarg($tarPath), - escapeshellarg($sourceDir), - $excludeArgs - ); - passthru($tarCmd, $tarReturn); -} - -// -- Calculate SHA-256 -------------------------------------------------------- -$sha256Zip = hash_file('sha256', $zipPath); -$sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : ''; - -$zipSize = filesize($zipPath); -$tarSize = file_exists($tarPath) ? filesize($tarPath) : 0; - -echo "\n"; -echo "ZIP: {$zipName} ({$zipSize} bytes)\n"; -echo " SHA-256: {$sha256Zip}\n"; -if ($tarSize > 0) { - echo "TAR: {$tarName} ({$tarSize} bytes)\n"; - echo " SHA-256: {$sha256Tar}\n"; -} - -// -- Export to GITHUB_OUTPUT -------------------------------------------------- -if ($githubOutput) { - $ghOutput = getenv('GITHUB_OUTPUT'); - $lines = [ - "zip_name={$zipName}", - "tar_name={$tarName}", - "zip_path={$zipPath}", - "tar_path={$tarPath}", - "sha256_zip={$sha256Zip}", - "sha256_tar={$sha256Tar}", - "type_prefix={$typePrefix}", - "ext_element={$extElement}", - ]; - if ($ghOutput) { - file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); - fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n"); - } else { - foreach ($lines as $line) { - echo "{$line}\n"; - } - } -} - -exit(0); - -// ============================================================================= -// Helper: recursively add directory contents to a ZipArchive -// ============================================================================= -function addDirectoryToZip(ZipArchive $zip, string $dir, string $prefix, array $excludes): void -{ - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - - foreach ($iterator as $file) { - $filePath = $file->getPathname(); - $relativePath = $prefix . substr($filePath, strlen($dir) + 1); - - // Check excludes - $basename = basename($filePath); - $skip = false; - foreach ($excludes as $pattern) { - if (fnmatch($pattern, $basename)) { - $skip = true; - break; + // Create ZIP from staging + $zip = new \ZipArchive(); + if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create ZIP: {$zipPath}"); + return 1; } - } - if ($skip) { - continue; - } + $this->addDirectoryToZip($zip, $stagingDir, '', []); + $zip->close(); - // Normalize path separators for ZIP - $relativePath = str_replace('\\', '/', $relativePath); + // Create tar.gz — all arguments are escaped via escapeshellarg() + $tarCmd = sprintf( + 'tar -czf %s -C %s .', + escapeshellarg($tarPath), + escapeshellarg($stagingDir) + ); + passthru($tarCmd, $tarReturn); - if ($file->isDir()) { - $zip->addEmptyDir($relativePath); + // Cleanup staging + $cleanCmd = sprintf('rm -rf %s', escapeshellarg($stagingDir)); + passthru($cleanCmd); } else { - $zip->addFile($filePath, $relativePath); + echo "=== Building standard extension package ===\n"; + + // ZIP + $zip = new \ZipArchive(); + if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create ZIP: {$zipPath}"); + return 1; + } + $this->addDirectoryToZip($zip, $sourceDir, '', $excludePatterns); + $zip->close(); + + // tar.gz — all arguments are escaped via escapeshellarg() + $excludeArgs = ''; + foreach ($excludePatterns as $pattern) { + $excludeArgs .= ' --exclude=' . escapeshellarg($pattern); + } + $tarCmd = sprintf( + 'tar -czf %s -C %s%s .', + escapeshellarg($tarPath), + escapeshellarg($sourceDir), + $excludeArgs + ); + passthru($tarCmd, $tarReturn); + } + + // -- Calculate SHA-256 -------------------------------------------------------- + $sha256Zip = hash_file('sha256', $zipPath); + $sha256Tar = file_exists($tarPath) ? hash_file('sha256', $tarPath) : ''; + + $zipSize = filesize($zipPath); + $tarSize = file_exists($tarPath) ? filesize($tarPath) : 0; + + echo "\n"; + echo "ZIP: {$zipName} ({$zipSize} bytes)\n"; + echo " SHA-256: {$sha256Zip}\n"; + if ($tarSize > 0) { + echo "TAR: {$tarName} ({$tarSize} bytes)\n"; + echo " SHA-256: {$sha256Tar}\n"; + } + + // -- Export to GITHUB_OUTPUT -------------------------------------------------- + if ($githubOutput) { + $ghOutput = getenv('GITHUB_OUTPUT'); + $lines = [ + "zip_name={$zipName}", + "tar_name={$tarName}", + "zip_path={$zipPath}", + "tar_path={$tarPath}", + "sha256_zip={$sha256Zip}", + "sha256_tar={$sha256Tar}", + "type_prefix={$typePrefix}", + "ext_element={$extElement}", + ]; + if ($ghOutput) { + file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); + $this->log('INFO', "Exported " . count($lines) . " fields to GITHUB_OUTPUT"); + } else { + foreach ($lines as $line) { + echo "{$line}\n"; + } + } + } + + return 0; + } + + /** + * Recursively add directory contents to a ZipArchive. + */ + private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $prefix, array $excludes): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + $filePath = $file->getPathname(); + $relativePath = $prefix . substr($filePath, strlen($dir) + 1); + + // Check excludes + $basename = basename($filePath); + $skip = false; + foreach ($excludes as $pattern) { + if (fnmatch($pattern, $basename)) { + $skip = true; + break; + } + } + if ($skip) { + continue; + } + + // Normalize path separators for ZIP + $relativePath = str_replace('\\', '/', $relativePath); + + if ($file->isDir()) { + $zip->addEmptyDir($relativePath); + } else { + $zip->addFile($filePath, $relativePath); + } } } } + +$app = new PackageBuildCli(); +exit($app->execute()); diff --git a/cli/platform_detect.php b/cli/platform_detect.php index 50a08f7..b9d2185 100644 --- a/cli/platform_detect.php +++ b/cli/platform_detect.php @@ -14,27 +14,43 @@ declare(strict_types=1); -$path = '.'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class PlatformDetectCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Detect platform from .mokostandards file'); + $this->addArgument('--path', 'Repository root path', '.'); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $root = realpath($path) ?: $path; + + // Check .github/.mokostandards first, fallback to root + $file = "{$root}/.github/.mokostandards"; + if (!file_exists($file)) { + $file = "{$root}/.mokostandards"; + } + if (!file_exists($file)) { + echo "unknown\n"; + return 0; + } + + $content = file_get_contents($file); + if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { + echo trim($m[1], " \t\n\r\"'") . "\n"; + } else { + echo "unknown\n"; + } + + return 0; + } } -$root = realpath($path) ?: $path; -// Check .github/.mokostandards first, fallback to root -$file = "{$root}/.github/.mokostandards"; -if (!file_exists($file)) { - $file = "{$root}/.mokostandards"; -} -if (!file_exists($file)) { - echo "unknown\n"; - exit(0); -} - -$content = file_get_contents($file); -if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { - echo trim($m[1], " \t\n\r\"'") . "\n"; -} else { - echo "unknown\n"; -} - -exit(0); +$app = new PlatformDetectCli(); +exit($app->execute()); diff --git a/cli/release.php b/cli/release.php index c725032..50ad826 100644 --- a/cli/release.php +++ b/cli/release.php @@ -9,163 +9,183 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release.php - * BRIEF: Automate the MokoStandards version branch release flow - * - * USAGE - * php cli/release.php # Release current version - * php cli/release.php --bump minor # Bump minor, then release - * php cli/release.php --bump major # Bump major, then release - * php cli/release.php --dry-run # Preview without changes + * BRIEF: Automate the moko-platform version branch release flow */ declare(strict_types=1); -$dryRun = in_array('--dry-run', $argv); -$bumpType = null; -foreach ($argv as $i => $arg) { - if ($arg === '--bump' && isset($argv[$i + 1])) { - $bumpType = $argv[$i + 1]; // patch | minor | major +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class ReleaseCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Automate the moko-platform version branch release flow'); + $this->addArgument('--bump', 'Bump type: patch, minor, or major', ''); } -} -$repoRoot = dirname(__DIR__, 2); -$syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php"; -// Check both workflow directories for the bulk-repo-sync workflow -$bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml") - ? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml" - : "{$repoRoot}/.github/workflows/bulk-repo-sync.yml"; -$cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template"; - -// ── Step 1: Read current version ──────────────────────────────────────── -$readme = "{$repoRoot}/README.md"; -$content = file_get_contents($readme); -if (!preg_match('/^\s*VERSION:\s*(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) { - fwrite(STDERR, "No VERSION found in README.md\n"); - exit(1); -} - -$major = (int)$m[1]; -$minor = (int)$m[2]; -$patch = (int)$m[3]; -$currentVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); - -// ── Step 2: Bump version if requested ─────────────────────────────────── -if ($bumpType) { - switch ($bumpType) { - case 'major': $major++; $minor = 0; $patch = 0; break; - case 'minor': $minor++; $patch = 0; break; - case 'patch': $patch++; break; - default: - fwrite(STDERR, "Invalid bump type: {$bumpType} (use patch/minor/major)\n"); - exit(1); - } - $newVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); - echo "Bumping: {$currentVersion} → {$newVersion}\n"; - - if (!$dryRun) { - // Update README.md - $content = preg_replace( - '/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', - '${1}' . $newVersion, - $content, - 1 - ); - file_put_contents($readme, $content); - - // Propagate to all files - echo "Propagating version to all files...\n"; - passthru("php {$repoRoot}/api/maintenance/update_version_from_readme.php --path {$repoRoot}"); - } - $currentVersion = $newVersion; -} else { - echo "Version: {$currentVersion}\n"; -} - -// Derive major.minor for branch naming (patches update existing branch) -$versionParts = explode('.', $currentVersion); -$minorVersion = $versionParts[0] . '.' . $versionParts[1]; -$branch = "version/{$minorVersion}"; - -// ── Step 3: Update STANDARDS_VERSION + STANDARDS_MINOR constants ──────── -echo "Updating STANDARDS_VERSION → {$currentVersion}\n"; -echo "Updating STANDARDS_MINOR → {$minorVersion}\n"; -if (!$dryRun) { - $syncContent = file_get_contents($syncFile); - $syncContent = preg_replace( - "/STANDARDS_VERSION\s*=\s*'[^']+'/", - "STANDARDS_VERSION = '{$currentVersion}'", - $syncContent - ); - $syncContent = preg_replace( - "/STANDARDS_MINOR\s*=\s*'[^']+'/", - "STANDARDS_MINOR = '{$minorVersion}'", - $syncContent - ); - file_put_contents($syncFile, $syncContent); -} - -// ── Step 4: Update bulk-repo-sync.yml checkout ref ────────────────────── -echo "Updating bulk-repo-sync.yml → {$branch}\n"; -if (!$dryRun) { - $bulkContent = file_get_contents($bulkSyncFile); - $bulkContent = preg_replace( - '/ref:\s*version\/[\d.]+/', - "ref: {$branch}", - $bulkContent - ); - file_put_contents($bulkSyncFile, $bulkContent); -} - -// ── Step 5: Update repository-cleanup.yml current branch ──────────────── -echo "Updating repository-cleanup.yml → chore/sync-mokostandards-v{$minorVersion}\n"; -if (!$dryRun) { - $cleanupContent = file_get_contents($cleanupFile); - $cleanupContent = preg_replace( - '/CURRENT="chore\/sync-mokostandards-v[^"]*"/', - "CURRENT=\"chore/sync-mokostandards-v{$minorVersion}\"", - $cleanupContent - ); - file_put_contents($cleanupFile, $cleanupContent); -} - -// ── Step 6: Commit changes ────────────────────────────────────────────── -if (!$dryRun) { - echo "Committing...\n"; - passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\""); - passthru("cd {$repoRoot} && git pull --rebase 2>/dev/null; git push"); -} - -// ── Step 7: Create or update version branch ───────────────────────────── -$isPatch = ($versionParts[2] ?? '00') !== '00'; -if ($isPatch) { - echo "Updating version branch: {$branch} (patch update)\n"; - if (!$dryRun) { - passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1"); - } -} else { - echo "Creating version branch: {$branch} (minor release)\n"; - if (!$dryRun) { - $exitCode = 0; - passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} 2>&1", $exitCode); - if ($exitCode !== 0) { - echo "Branch {$branch} already exists — force updating\n"; - passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1"); + protected function run(): int + { + $bumpType = $this->getArgument('--bump'); + if (empty($bumpType)) { + $bumpType = null; } + + $repoRoot = dirname(__DIR__, 2); + $syncFile = "{$repoRoot}/lib/Enterprise/RepositorySynchronizer.php"; + // Check both workflow directories for the bulk-repo-sync workflow + $bulkSyncFile = file_exists("{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml") + ? "{$repoRoot}/.mokogitea/workflows/bulk-repo-sync.yml" + : "{$repoRoot}/.github/workflows/bulk-repo-sync.yml"; + $cleanupFile = "{$repoRoot}/templates/workflows/shared/repository-cleanup.yml.template"; + + // -- Step 1: Read current version -- + $readme = "{$repoRoot}/README.md"; + $content = file_get_contents($readme); + if (!preg_match('/^\s*VERSION:\s*(\d{2})\.(\d{2})\.(\d{2})/m', $content, $m)) { + $this->log('ERROR', 'No VERSION found in README.md'); + return 1; + } + + $major = (int)$m[1]; + $minor = (int)$m[2]; + $patch = (int)$m[3]; + $currentVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); + + // -- Step 2: Bump version if requested -- + if ($bumpType) { + switch ($bumpType) { + case 'major': + $major++; + $minor = 0; + $patch = 0; + break; + case 'minor': + $minor++; + $patch = 0; + break; + case 'patch': + $patch++; + break; + default: + $this->log('ERROR', "Invalid bump type: {$bumpType} (use patch/minor/major)"); + return 1; + } + $newVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); + echo "Bumping: {$currentVersion} -> {$newVersion}\n"; + + if (!$this->dryRun) { + // Update README.md + $content = preg_replace( + '/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', + '${1}' . $newVersion, + $content, + 1 + ); + file_put_contents($readme, $content); + + // Propagate to all files + echo "Propagating version to all files...\n"; + passthru("php {$repoRoot}/api/maintenance/update_version_from_readme.php --path {$repoRoot}"); + } + $currentVersion = $newVersion; + } else { + echo "Version: {$currentVersion}\n"; + } + + // Derive major.minor for branch naming (patches update existing branch) + $versionParts = explode('.', $currentVersion); + $minorVersion = $versionParts[0] . '.' . $versionParts[1]; + $branch = "version/{$minorVersion}"; + + // -- Step 3: Update STANDARDS_VERSION + STANDARDS_MINOR constants -- + echo "Updating STANDARDS_VERSION -> {$currentVersion}\n"; + echo "Updating STANDARDS_MINOR -> {$minorVersion}\n"; + if (!$this->dryRun) { + $syncContent = file_get_contents($syncFile); + $syncContent = preg_replace( + "/STANDARDS_VERSION\s*=\s*'[^']+'/", + "STANDARDS_VERSION = '{$currentVersion}'", + $syncContent + ); + $syncContent = preg_replace( + "/STANDARDS_MINOR\s*=\s*'[^']+'/", + "STANDARDS_MINOR = '{$minorVersion}'", + $syncContent + ); + file_put_contents($syncFile, $syncContent); + } + + // -- Step 4: Update bulk-repo-sync.yml checkout ref -- + echo "Updating bulk-repo-sync.yml -> {$branch}\n"; + if (!$this->dryRun) { + $bulkContent = file_get_contents($bulkSyncFile); + $bulkContent = preg_replace( + '/ref:\s*version\/[\d.]+/', + "ref: {$branch}", + $bulkContent + ); + file_put_contents($bulkSyncFile, $bulkContent); + } + + // -- Step 5: Update repository-cleanup.yml current branch -- + echo "Updating repository-cleanup.yml -> chore/sync-mokostandards-v{$minorVersion}\n"; + if (!$this->dryRun) { + $cleanupContent = file_get_contents($cleanupFile); + $cleanupContent = preg_replace( + '/CURRENT="chore\/sync-mokostandards-v[^"]*"/', + "CURRENT=\"chore/sync-mokostandards-v{$minorVersion}\"", + $cleanupContent + ); + file_put_contents($cleanupFile, $cleanupContent); + } + + // -- Step 6: Commit changes -- + if (!$this->dryRun) { + echo "Committing...\n"; + passthru("cd {$repoRoot} && git add -A && git commit -m \"chore(release): prepare {$currentVersion} release [skip ci]\""); + passthru("cd {$repoRoot} && git pull --rebase 2>/dev/null; git push"); + } + + // -- Step 7: Create or update version branch -- + $isPatch = ($versionParts[2] ?? '00') !== '00'; + if ($isPatch) { + echo "Updating version branch: {$branch} (patch update)\n"; + if (!$this->dryRun) { + passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1"); + } + } else { + echo "Creating version branch: {$branch} (minor release)\n"; + if (!$this->dryRun) { + $exitCode = 0; + passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} 2>&1", $exitCode); + if ($exitCode !== 0) { + echo "Branch {$branch} already exists — force updating\n"; + passthru("cd " . escapeshellarg($repoRoot) . " && git push origin main:{$branch} --force 2>&1"); + } + } + } + + // -- Step 8: Create git tag (never overwrite existing) -- + $tag = "v{$currentVersion}"; + echo "Creating tag {$tag}\n"; + if (!$this->dryRun) { + $exitCode = 0; + passthru("cd {$repoRoot} && git tag {$tag} 2>/dev/null && git push origin {$tag} 2>/dev/null", $exitCode); + if ($exitCode !== 0) { + echo "Tag {$tag} already exists — skipping\n"; + } + } + + echo "\nRelease {$currentVersion} complete\n"; + echo " Branch: {$branch}\n"; + echo " Tag: {$tag}\n"; + echo " Next: run bulk sync to push to all repos\n"; + return 0; } } -// ── Step 8: Create git tag (never overwrite existing) ─────────────────── -$tag = "v{$currentVersion}"; -echo "Creating tag {$tag}\n"; -if (!$dryRun) { - $exitCode = 0; - passthru("cd {$repoRoot} && git tag {$tag} 2>/dev/null && git push origin {$tag} 2>/dev/null", $exitCode); - if ($exitCode !== 0) { - echo "⚠️ Tag {$tag} already exists — skipping\n"; - } -} - -echo "\n✅ Release {$currentVersion} complete\n"; -echo " Branch: {$branch}\n"; -echo " Tag: {$tag}\n"; -echo " Next: run bulk sync to push to all repos\n"; +$app = new ReleaseCli(); +exit($app->execute()); diff --git a/cli/release_body_update.php b/cli/release_body_update.php index 266823c..a0ae03f 100644 --- a/cli/release_body_update.php +++ b/cli/release_body_update.php @@ -10,143 +10,148 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_body_update.php * BRIEF: Update Gitea release body with changelog extract and checksums - * - * Usage: - * php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL - * php release_body_update.php --version 04.01.00 --release-tag stable --token TOKEN --api-base URL --zip-name pkg.zip --zip-sha abc123 - * - * Options: - * --path Repo root for CHANGELOG.md (default: .) - * --version Version string (required) - * --release-tag Gitea release tag (required) - * --token Gitea API token (required) - * --api-base Gitea API base URL (required) - * --zip-name ZIP filename for checksum table - * --tar-name tar.gz filename for checksum table - * --zip-sha SHA256 of ZIP - * --tar-sha SHA256 of tar.gz - * --output-summary Write to $GITHUB_STEP_SUMMARY */ declare(strict_types=1); -$path = '.'; -$version = null; -$releaseTag = null; -$token = null; -$apiBase = null; -$zipName = null; -$tarName = null; -$zipSha = null; -$tarSha = null; -$outputSummary = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; - if ($arg === '--release-tag' && isset($argv[$i + 1])) $releaseTag = $argv[$i + 1]; - if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; - if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1]; - if ($arg === '--zip-name' && isset($argv[$i + 1])) $zipName = $argv[$i + 1]; - if ($arg === '--tar-name' && isset($argv[$i + 1])) $tarName = $argv[$i + 1]; - if ($arg === '--zip-sha' && isset($argv[$i + 1])) $zipSha = $argv[$i + 1]; - if ($arg === '--tar-sha' && isset($argv[$i + 1])) $tarSha = $argv[$i + 1]; - if ($arg === '--output-summary') $outputSummary = true; +use MokoEnterprise\CliFramework; + +class ReleaseBodyUpdateCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Update Gitea release body with changelog extract and checksums'); + $this->addArgument('--path', 'Repo root for CHANGELOG.md', '.'); + $this->addArgument('--version', 'Version string', ''); + $this->addArgument('--release-tag', 'Gitea release tag', ''); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--api-base', 'Gitea API base URL', ''); + $this->addArgument('--zip-name', 'ZIP filename for checksum table', ''); + $this->addArgument('--tar-name', 'tar.gz filename for checksum table', ''); + $this->addArgument('--zip-sha', 'SHA256 of ZIP', ''); + $this->addArgument('--tar-sha', 'SHA256 of tar.gz', ''); + $this->addArgument('--output-summary', 'Write to $GITHUB_STEP_SUMMARY', false); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $releaseTag = $this->getArgument('--release-tag'); + $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $zipName = $this->getArgument('--zip-name'); + $tarName = $this->getArgument('--tar-name'); + $zipSha = $this->getArgument('--zip-sha'); + $tarSha = $this->getArgument('--tar-sha'); + $outputSummary = $this->getArgument('--output-summary'); + + if (empty($token)) { + $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: ''; + } + + if (empty($version) || empty($releaseTag) || empty($token) || empty($apiBase)) { + $this->log('ERROR', 'Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL'); + return 1; + } + + $root = realpath($path) ?: $path; + + // Extract changelog section for this version + $changelog = ''; + $clFile = "{$root}/CHANGELOG.md"; + if (file_exists($clFile)) { + $lines = file($clFile, FILE_IGNORE_NEW_LINES); + $capturing = false; + $clLines = []; + foreach ($lines as $line) { + if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) { + $capturing = true; + continue; + } + if ($capturing && preg_match('/^## /', $line)) { + break; + } + if ($capturing) { + $clLines[] = $line; + } + } + $changelog = trim(implode("\n", $clLines)); + } + + // Build release body + $body = "## {$version} (" . date('Y-m-d') . ")\n\n"; + if (!empty($changelog)) { + $body .= "{$changelog}\n\n"; + } + + if (!empty($zipSha) || !empty($tarSha)) { + $body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n"; + if (!empty($zipName) && !empty($zipSha)) { + $body .= "| `{$zipName}` | `{$zipSha}` |\n"; + } + if (!empty($tarName) && !empty($tarSha)) { + $body .= "| `{$tarName}` | `{$tarSha}` |\n"; + } + } + + // Get release ID by tag + $ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}"); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200 || empty($response)) { + $this->log('ERROR', "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})"); + return 1; + } + + $release = json_decode($response, true); + $releaseId = $release['id'] ?? null; + + if ($releaseId === null) { + $this->log('ERROR', "No release ID found for tag '{$releaseTag}'"); + return 1; + } + + // PATCH release body + $payload = json_encode(['body' => $body]); + $ch = curl_init("{$apiBase}/releases/{$releaseId}"); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'PATCH', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"], + CURLOPT_POSTFIELDS => $payload, + CURLOPT_TIMEOUT => 30, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + $this->log('ERROR', "Failed to update release body (HTTP {$httpCode})"); + return 1; + } + + echo "Release body updated for {$releaseTag} (release #{$releaseId})\n"; + + if ($outputSummary) { + $summaryFile = getenv('GITHUB_STEP_SUMMARY'); + if ($summaryFile) { + file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND); + } + } + + return 0; + } } -if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; - -if ($version === null || $releaseTag === null || $token === null || $apiBase === null) { - fwrite(STDERR, "Usage: release_body_update.php --version VER --release-tag TAG --token TOKEN --api-base URL\n"); - exit(1); -} - -$root = realpath($path) ?: $path; - -// Extract changelog section for this version -$changelog = ''; -$clFile = "{$root}/CHANGELOG.md"; -if (file_exists($clFile)) { - $lines = file($clFile, FILE_IGNORE_NEW_LINES); - $capturing = false; - $clLines = []; - foreach ($lines as $line) { - if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) { - $capturing = true; - continue; - } - if ($capturing && preg_match('/^## /', $line)) break; - if ($capturing) $clLines[] = $line; - } - $changelog = trim(implode("\n", $clLines)); -} - -// Build release body -$body = "## {$version} (" . date('Y-m-d') . ")\n\n"; -if (!empty($changelog)) { - $body .= "{$changelog}\n\n"; -} - -if ($zipSha !== null || $tarSha !== null) { - $body .= "---\n\n### Checksums\n\n| File | SHA-256 |\n|------|--------|\n"; - if ($zipName !== null && $zipSha !== null) { - $body .= "| `{$zipName}` | `{$zipSha}` |\n"; - } - if ($tarName !== null && $tarSha !== null) { - $body .= "| `{$tarName}` | `{$tarSha}` |\n"; - } -} - -// Get release ID by tag -$ch = curl_init("{$apiBase}/releases/tags/{$releaseTag}"); -curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], - CURLOPT_TIMEOUT => 30, -]); -$response = curl_exec($ch); -$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); - -if ($httpCode !== 200 || empty($response)) { - fwrite(STDERR, "Failed to get release for tag '{$releaseTag}' (HTTP {$httpCode})\n"); - exit(1); -} - -$release = json_decode($response, true); -$releaseId = $release['id'] ?? null; - -if ($releaseId === null) { - fwrite(STDERR, "No release ID found for tag '{$releaseTag}'\n"); - exit(1); -} - -// PATCH release body -$payload = json_encode(['body' => $body]); -$ch = curl_init("{$apiBase}/releases/{$releaseId}"); -curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => 'PATCH', - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ["Authorization: token {$token}", "Content-Type: application/json"], - CURLOPT_POSTFIELDS => $payload, - CURLOPT_TIMEOUT => 30, -]); -$response = curl_exec($ch); -$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); -curl_close($ch); - -if ($httpCode !== 200) { - fwrite(STDERR, "Failed to update release body (HTTP {$httpCode})\n"); - exit(1); -} - -echo "Release body updated for {$releaseTag} (release #{$releaseId})\n"; - -if ($outputSummary) { - $summaryFile = getenv('GITHUB_STEP_SUMMARY'); - if ($summaryFile) { - file_put_contents($summaryFile, "Release body updated with changelog + checksums\n", FILE_APPEND); - } -} - -exit(0); +$app = new ReleaseBodyUpdateCli(); +exit($app->execute()); diff --git a/cli/release_cascade.php b/cli/release_cascade.php index 486e414..37371de 100644 --- a/cli/release_cascade.php +++ b/cli/release_cascade.php @@ -9,9 +9,29 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_cascade.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: DEPRECATED — cascade behavior removed. Each release stream is independent. */ -echo "release_cascade.php: No-op (cascade behavior removed — each stream is independent)\n"; -exit(0); +declare(strict_types=1); + +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class ReleaseCascadeCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('DEPRECATED — cascade behavior removed'); + } + + protected function run(): int + { + $this->log('INFO', 'No-op (cascade behavior removed — each stream is independent)'); + return 0; + } +} + +$app = new ReleaseCascadeCli(); +exit($app->execute()); diff --git a/cli/release_create.php b/cli/release_create.php index 1356d27..7c5a9e0 100644 --- a/cli/release_create.php +++ b/cli/release_create.php @@ -11,328 +11,300 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_create.php * BRIEF: Create or overwrite a Gitea release with proper naming - * - * Usage: - * php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL - * php release_create.php --version 09.01.00 --tag development --token TOKEN --api-base URL --prerelease - * php release_create.php --version 09.01.00 --tag stable --token TOKEN --api-base URL --path . --repo MyRepo - * - * Replaces the inline bash in auto-release.yml Step 7b. - * Detects extension metadata from manifest, builds a proper release name, - * generates release notes, and creates (or overwrites) a Gitea release. */ declare(strict_types=1); -// ── Argument parsing ──────────────────────────────────────────────────────── +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -$path = '.'; -$version = null; -$tag = null; -$token = null; -$apiBase = null; -$branch = 'main'; -$repoName = ''; -$prerelease = false; +use MokoEnterprise\CliFramework; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) { - $path = $argv[$i + 1]; - } - if ($arg === '--version' && isset($argv[$i + 1])) { - $version = $argv[$i + 1]; - } - if ($arg === '--tag' && isset($argv[$i + 1])) { - $tag = $argv[$i + 1]; - } - if ($arg === '--token' && isset($argv[$i + 1])) { - $token = $argv[$i + 1]; - } - if ($arg === '--api-base' && isset($argv[$i + 1])) { - $apiBase = $argv[$i + 1]; - } - if ($arg === '--branch' && isset($argv[$i + 1])) { - $branch = $argv[$i + 1]; - } - if ($arg === '--repo' && isset($argv[$i + 1])) { - $repoName = $argv[$i + 1]; - } - if ($arg === '--prerelease') { - $prerelease = true; - } -} - -// Allow token from environment -if ($token === null) { - $envToken = getenv('MOKOGITEA_TOKEN'); - if ($envToken === false || $envToken === '') { - $envToken = getenv('GITEA_TOKEN'); - } - if ($envToken !== false && $envToken !== '') { - $token = $envToken; - } -} - -if ($version === null || $tag === null || $token === null || $apiBase === null) { - fwrite(STDERR, "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]\n"); - fwrite(STDERR, " --path . Repo root for manifest detection (default: .)\n"); - fwrite(STDERR, " --branch main Target commitish (default: main)\n"); - fwrite(STDERR, " --repo REPO Repo name for fallback element detection\n"); - fwrite(STDERR, " --prerelease Mark release as prerelease\n"); - fwrite(STDERR, " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var\n"); - exit(1); -} - -// ── Helper: Gitea API request ─────────────────────────────────────────────── - -/** - * Send a request to the Gitea API. - * - * @param string $url Full API URL - * @param string $token Authorization token - * @param string $method HTTP method (GET, POST, DELETE, etc.) - * @param string|null $body JSON request body - * - * @return array|null Decoded response or null on failure - */ -function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array +class ReleaseCreateCli extends CliFramework { - $ch = curl_init($url); - if ($ch === false) { - return null; - } - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Content-Type: application/json', - ], - CURLOPT_TIMEOUT => 30, - CURLOPT_CUSTOMREQUEST => $method, - ]); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) { - return null; + protected function configure(): void + { + $this->setDescription('Create or overwrite a Gitea release with proper naming'); + $this->addArgument('--path', 'Repo root for manifest detection (default: .)', '.'); + $this->addArgument('--version', 'Version string (required)', ''); + $this->addArgument('--tag', 'Release tag name (required)', ''); + $this->addArgument('--token', 'Gitea API token (required)', ''); + $this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', ''); + $this->addArgument('--branch', 'Target commitish (default: main)', 'main'); + $this->addArgument('--repo', 'Repo name for fallback element detection', ''); + $this->addArgument('--prerelease', 'Mark release as prerelease', false); } - $decoded = json_decode($response, true); - return is_array($decoded) ? $decoded : null; -} + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $tag = $this->getArgument('--tag'); + $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $branch = $this->getArgument('--branch'); + $repoName = $this->getArgument('--repo'); + $prerelease = (bool) $this->getArgument('--prerelease'); -// ── Detect element metadata ───────────────────────────────────────────────── - -$root = realpath($path) ?: $path; - -$extElement = ''; -$extType = ''; -$extFolder = ''; -$extName = ''; -$typePrefix = ''; - -// Detect platform and display name from manifest.xml -$platform = 'generic'; -$prettyName = ''; -$manifestXml = "{$root}/.mokogitea/manifest.xml"; -if (file_exists($manifestXml)) { - $content = file_get_contents($manifestXml); - if ($content !== false) { - if (preg_match('/([^<]+)<\/platform>/', $content, $pm)) { - $platform = trim($pm[1]); - } - // is the human-friendly name; is the element/repo name - if (preg_match('/([^<]+)<\/display-name>/', $content, $dn)) { - $prettyName = trim($dn[1]); - } elseif (preg_match('/([^<]+)<\/name>/', $content, $nm)) { - $prettyName = trim($nm[1]); - } - } -} - -// Find extension manifest (Joomla XML) -$extManifest = null; -$manifestFiles = array_merge( - glob("{$root}/src/pkg_*.xml") ?: [], - glob("{$root}/src/*.xml") ?: [], - glob("{$root}/*.xml") ?: [] -); -foreach ($manifestFiles as $file) { - $c = file_get_contents($file); - if ($c !== false && strpos($c, ', plugin= attribute, , or filename - if (preg_match('/([^<]+)<\/element>/', $xml, $em)) { - $extElement = $em[1]; - } - if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { - $extElement = $pm2[1]; - } - if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { - $extElement = $pn[1]; - } - if (empty($extElement)) { - $extElement = strtolower(basename($extManifest, '.xml')); - if (in_array($extElement, ['templatedetails', 'manifest'], true)) { - $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); + // Allow token from environment + if ($token === '') { + $envToken = getenv('MOKOGITEA_TOKEN'); + if ($envToken === false || $envToken === '') { + $envToken = getenv('GITEA_TOKEN'); + } + if ($envToken !== false && $envToken !== '') { + $token = $envToken; } } - // Human-readable name - if (preg_match('/([^<]+)<\/name>/', $xml, $nm)) { - $extName = trim($nm[1]); + if ($version === '' || $tag === '' || $token === '' || $apiBase === '') { + $this->log('ERROR', "Usage: release_create.php --version VER --tag TAG --token TOKEN --api-base URL [options]"); + $this->log('ERROR', " --path . Repo root for manifest detection (default: .)"); + $this->log('ERROR', " --branch main Target commitish (default: main)"); + $this->log('ERROR', " --repo REPO Repo name for fallback element detection"); + $this->log('ERROR', " --prerelease Mark release as prerelease"); + $this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var"); + return 1; } - break; - case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: - $extType = 'dolibarr-module'; - $modBasename = basename($modFile, '.class.php'); - $extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename); + // ── Detect element metadata ───────────────────────────────────────────── - $modContent = file_get_contents($modFile); - if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) { - $extName = $nm2[1]; + $root = realpath($path) ?: $path; + + $extElement = ''; + $extType = ''; + $extFolder = ''; + $extName = ''; + $typePrefix = ''; + + // Detect platform and display name from manifest.xml + $platform = 'generic'; + $prettyName = ''; + $manifestXml = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($manifestXml)) { + $content = file_get_contents($manifestXml); + if ($content !== false) { + if (preg_match('/([^<]+)<\/platform>/', $content, $pm)) { + $platform = trim($pm[1]); + } + if (preg_match('/([^<]+)<\/display-name>/', $content, $dn)) { + $prettyName = trim($dn[1]); + } elseif (preg_match('/([^<]+)<\/name>/', $content, $nm)) { + $prettyName = trim($nm[1]); + } + } } - break; - default: - $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); - $extType = 'generic'; - break; -} - -// Strip existing type prefix from element to prevent duplication -$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement; - -// Compute type prefix -switch ($extType) { - case 'plugin': - $typePrefix = "plg_{$extFolder}_"; - break; - case 'module': - $typePrefix = 'mod_'; - break; - case 'component': - $typePrefix = 'com_'; - break; - case 'template': - $typePrefix = 'tpl_'; - break; - case 'library': - $typePrefix = 'lib_'; - break; - case 'package': - $typePrefix = 'pkg_'; - break; -} - -// Fallback name -if (empty($extName)) { - $extName = $repoName !== '' ? $repoName : basename($root); -} - -echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n"; - -// ── Build release name ────────────────────────────────────────────────────── -// Use display-name from manifest.xml if available, otherwise fall back to extName -$displayName = !empty($prettyName) ? $prettyName : $extName; -$releaseName = "{$displayName} (VERSION: {$version})"; -echo "Release name: {$releaseName}\n"; - -// ── Generate release notes ────────────────────────────────────────────────── - -$releaseNotes = "Release {$version}"; -$releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php'; -if (file_exists($releaseNotesScript)) { - $cmd = sprintf( - 'php %s --path %s --version %s', - escapeshellarg($releaseNotesScript), - escapeshellarg($root), - escapeshellarg($version) - ); - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - if ($exitCode === 0 && count($output) > 0) { - $notes = implode("\n", $output); - if (trim($notes) !== '') { - $releaseNotes = $notes; - echo "Release notes: generated from CHANGELOG.md\n"; + // Find extension manifest (Joomla XML) + $extManifest = null; + $manifestFiles = array_merge( + glob("{$root}/src/pkg_*.xml") ?: [], + glob("{$root}/src/*.xml") ?: [], + glob("{$root}/*.xml") ?: [] + ); + foreach ($manifestFiles as $file) { + $c = file_get_contents($file); + if ($c !== false && strpos($c, '([^<]+)<\/element>/', $xml, $em)) { + $extElement = $em[1]; + } + if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { + $extElement = $pm2[1]; + } + if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { + $extElement = $pn[1]; + } + if (empty($extElement)) { + $extElement = strtolower(basename($extManifest, '.xml')); + if (in_array($extElement, ['templatedetails', 'manifest'], true)) { + $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); + } + } + + if (preg_match('/([^<]+)<\/name>/', $xml, $nm)) { + $extName = trim($nm[1]); + } + break; + + case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: + $extType = 'dolibarr-module'; + $modBasename = basename($modFile, '.class.php'); + $extElement = strtolower(preg_replace('/^mod/', '', $modBasename) ?? $modBasename); + + $modContent = file_get_contents($modFile); + if ($modContent !== false && preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm2)) { + $extName = $nm2[1]; + } + break; + + default: + $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); + $extType = 'generic'; + break; + } + + // Strip existing type prefix from element to prevent duplication + $extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement) ?? $extElement; + + // Compute type prefix + switch ($extType) { + case 'plugin': + $typePrefix = "plg_{$extFolder}_"; + break; + case 'module': + $typePrefix = 'mod_'; + break; + case 'component': + $typePrefix = 'com_'; + break; + case 'template': + $typePrefix = 'tpl_'; + break; + case 'library': + $typePrefix = 'lib_'; + break; + case 'package': + $typePrefix = 'pkg_'; + break; + } + + // Fallback name + if (empty($extName)) { + $extName = $repoName !== '' ? $repoName : basename($root); + } + + echo "Element: {$extElement}, Type: {$extType}, Prefix: {$typePrefix}, Name: {$extName}\n"; + + // ── Build release name ────────────────────────────────────────────────────── + $displayName = !empty($prettyName) ? $prettyName : $extName; + $releaseName = "{$displayName} (VERSION: {$version})"; + echo "Release name: {$releaseName}\n"; + + // ── Generate release notes ────────────────────────────────────────────────── + + $releaseNotes = "Release {$version}"; + $releaseNotesScript = dirname(__DIR__) . '/cli/release_notes.php'; + if (file_exists($releaseNotesScript)) { + $cmd = sprintf( + 'php %s --path %s --version %s', + escapeshellarg($releaseNotesScript), + escapeshellarg($root), + escapeshellarg($version) + ); + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + if ($exitCode === 0 && count($output) > 0) { + $notes = implode("\n", $output); + if (trim($notes) !== '') { + $releaseNotes = $notes; + echo "Release notes: generated from CHANGELOG.md\n"; + } + } + } + + // ── Delete existing release at tag (if present) ───────────────────────────── + + $existing = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token); + if ($existing !== null && !empty($existing['id'])) { + $existingId = $existing['id']; + echo "Deleting existing release: {$tag} (id: {$existingId})\n"; + + // Delete release + $this->giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE'); + + // Delete tag + $this->giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE'); + } + + // ── Create new release ────────────────────────────────────────────────────── + + $payload = json_encode([ + 'tag_name' => $tag, + 'target_commitish' => $branch, + 'name' => $releaseName, + 'body' => $releaseNotes, + 'prerelease' => $prerelease, + ]); + + $newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}'); + if ($newRelease === null || empty($newRelease['id'])) { + $this->log('ERROR', "Failed to create release at tag: {$tag}"); + return 1; + } + + $releaseId = $newRelease['id']; + echo "Created release: {$tag} (id: {$releaseId})\n"; + + // Output release_id to stdout for CI consumption + echo "release_id={$releaseId}\n"; + return 0; + } + + private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array + { + $ch = curl_init($url); + if ($ch === false) { + return null; + } + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300 || empty($response) || !is_string($response)) { + return null; + } + + $decoded = json_decode($response, true); + return is_array($decoded) ? $decoded : null; } } -// ── Delete existing release at tag (if present) ───────────────────────────── - -$existing = giteaApi("{$apiBase}/releases/tags/{$tag}", $token); -if ($existing !== null && !empty($existing['id'])) { - $existingId = $existing['id']; - echo "Deleting existing release: {$tag} (id: {$existingId})\n"; - - // Delete release - giteaApi("{$apiBase}/releases/{$existingId}", $token, 'DELETE'); - - // Delete tag - giteaApi("{$apiBase}/tags/{$tag}", $token, 'DELETE'); -} - -// ── Create new release ────────────────────────────────────────────────────── - -$payload = json_encode([ - 'tag_name' => $tag, - 'target_commitish' => $branch, - 'name' => $releaseName, - 'body' => $releaseNotes, - 'prerelease' => $prerelease, -]); - -$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload !== false ? $payload : '{}'); -if ($newRelease === null || empty($newRelease['id'])) { - fwrite(STDERR, "Failed to create release at tag: {$tag}\n"); - exit(1); -} - -$releaseId = $newRelease['id']; -echo "Created release: {$tag} (id: {$releaseId})\n"; - -// Output release_id to stdout for CI consumption -echo "release_id={$releaseId}\n"; -exit(0); +$app = new ReleaseCreateCli(); +exit($app->execute()); diff --git a/cli/release_manage.php b/cli/release_manage.php index 9ce5e61..54ef54d 100644 --- a/cli/release_manage.php +++ b/cli/release_manage.php @@ -10,230 +10,100 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_manage.php * BRIEF: Create/update Gitea releases, upload assets, update release body - * - * Usage: - * # Create a release - * php release_manage.php --action create --tag stable --name "My Plugin 04.01.00" \ - * --body "Release notes" --target main --token TOKEN --api-base URL - * - * # Upload assets to a release - * php release_manage.php --action upload --tag stable --files "/tmp/pkg.zip,/tmp/pkg.tar.gz" \ - * --token TOKEN --api-base URL - * - * # Update release body (e.g. add SHA checksums) - * php release_manage.php --action update-body --tag stable --body "New body" \ - * --token TOKEN --api-base URL - * - * # Delete a release and its tag - * php release_manage.php --action delete --tag stable --token TOKEN --api-base URL - * - * Options: - * --action create | upload | update-body | delete (required) - * --tag Release tag name (required) - * --name Release name/title (for create) - * --body Release body/description (for create, update-body) - * --body-file Read body from file instead of --body - * --target Target branch/commitish (for create, default: main) - * --files Comma-separated file paths to upload (for upload) - * --token Gitea API token (or MOKOGITEA_TOKEN/GITEA_TOKEN env var) - * --api-base Gitea API base URL (e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo) - * - * NOTE: This script uses PHP curl for all HTTP operations (no shell calls). */ declare(strict_types=1); -$action = null; -$tag = null; -$name = null; -$body = null; -$bodyFile = null; -$target = 'main'; -$files = []; -$token = null; -$apiBase = null; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--action' && isset($argv[$i + 1])) $action = $argv[$i + 1]; - if ($arg === '--tag' && isset($argv[$i + 1])) $tag = $argv[$i + 1]; - if ($arg === '--name' && isset($argv[$i + 1])) $name = $argv[$i + 1]; - if ($arg === '--body' && isset($argv[$i + 1])) $body = $argv[$i + 1]; - if ($arg === '--body-file' && isset($argv[$i + 1])) $bodyFile = $argv[$i + 1]; - if ($arg === '--target' && isset($argv[$i + 1])) $target = $argv[$i + 1]; - if ($arg === '--files' && isset($argv[$i + 1])) $files = array_filter(explode(',', $argv[$i + 1])); - if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; - if ($arg === '--api-base' && isset($argv[$i + 1])) $apiBase = $argv[$i + 1]; -} +use MokoEnterprise\CliFramework; -// Allow token from environment -if ($token === null) { - $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; -} - -// Read body from file if specified -if ($bodyFile !== null && file_exists($bodyFile)) { - $body = file_get_contents($bodyFile); -} - -if ($action === null || $tag === null || $token === null || $apiBase === null) { - fwrite(STDERR, "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL\n"); - exit(1); -} - -/** - * Make a Gitea API request using curl - */ -function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array +class ReleaseManageCli extends CliFramework { - $ch = curl_init($url); - $headers = ["Authorization: token {$token}"]; - - $opts = [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 60, - CURLOPT_CUSTOMREQUEST => $method, - ]; - - if ($jsonBody !== null) { - $headers[] = 'Content-Type: application/json'; - $opts[CURLOPT_POSTFIELDS] = $jsonBody; - } elseif ($filePath !== null) { - $headers[] = 'Content-Type: application/octet-stream'; - $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); + protected function configure(): void + { + $this->setDescription('Create/update Gitea releases, upload assets, update release body'); + $this->addArgument('--action', 'create | upload | update-body | delete', null); + $this->addArgument('--tag', 'Release tag name', null); + $this->addArgument('--name', 'Release name/title', null); + $this->addArgument('--body', 'Release body/description', null); + $this->addArgument('--body-file', 'Read body from file', null); + $this->addArgument('--target', 'Target branch/commitish', 'main'); + $this->addArgument('--files', 'Comma-separated file paths to upload', null); + $this->addArgument('--token', 'Gitea API token', null); + $this->addArgument('--api-base', 'Gitea API base URL', null); } - $opts[CURLOPT_HTTPHEADER] = $headers; - curl_setopt_array($ch, $opts); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - $data = json_decode($response ?: '{}', true) ?: []; - return ['code' => $httpCode, 'data' => $data]; -} - -/** - * Get release by tag - */ -function getReleaseByTag(string $apiBase, string $tag, string $token): ?array -{ - $result = releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token); - if ($result['code'] === 200 && isset($result['data']['id'])) { - return $result['data']; - } - return null; -} - -// -- Action dispatch ---------------------------------------------------------- -switch ($action) { - case 'create': - // Delete existing release if present - $existing = getReleaseByTag($apiBase, $tag, $token); - if ($existing !== null) { - $existingId = $existing['id']; - releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token); - releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); - echo "Deleted previous release: {$tag} (id: {$existingId})\n"; - } - - $payload = json_encode([ - 'tag_name' => $tag, - 'name' => $name ?? $tag, - 'body' => $body ?? '', - 'target_commitish' => $target, - ]); - - $result = releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload); - if ($result['code'] >= 200 && $result['code'] < 300) { - $releaseId = $result['data']['id'] ?? 'unknown'; - echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n"; - } else { - fwrite(STDERR, "Failed to create release: HTTP {$result['code']}\n"); - fwrite(STDERR, json_encode($result['data']) . "\n"); - exit(1); - } - break; - - case 'upload': - if (empty($files)) { - fwrite(STDERR, "No files specified. Use --files /path/to/file1,/path/to/file2\n"); - exit(1); - } - - $release = getReleaseByTag($apiBase, $tag, $token); - if ($release === null) { - fwrite(STDERR, "No release found for tag: {$tag}\n"); - exit(1); - } - $releaseId = $release['id']; - - // Get existing assets to avoid duplicates - $assetsResult = releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token); - $existingAssets = $assetsResult['data'] ?? []; - - foreach ($files as $filePath) { - $filePath = trim($filePath); - if (!file_exists($filePath)) { - fwrite(STDERR, "File not found: {$filePath}\n"); - continue; - } - - $fileName = basename($filePath); - - // Delete existing asset with same name - foreach ($existingAssets as $asset) { - if (($asset['name'] ?? '') === $fileName) { - releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token); - echo "Deleted existing asset: {$fileName}\n"; - break; + protected function run(): int + { + $action = $this->getArgument('--action'); $tag = $this->getArgument('--tag'); + $name = $this->getArgument('--name'); $body = $this->getArgument('--body'); + $bodyFile = $this->getArgument('--body-file'); $target = $this->getArgument('--target'); + $filesArg = $this->getArgument('--files'); $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : []; + if ($token === null) { $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; } + if ($bodyFile !== null && file_exists($bodyFile)) { $body = file_get_contents($bodyFile); } + if ($action === null || $tag === null || $token === null || $apiBase === null) { $this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL"); return 1; } + switch ($action) { + case 'create': + $existing = $this->getReleaseByTag($apiBase, $tag, $token); + if ($existing !== null) { $existingId = $existing['id']; $this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token); $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); echo "Deleted previous release: {$tag} (id: {$existingId})\n"; } + $payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]); + $result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload); + if ($result['code'] >= 200 && $result['code'] < 300) { $releaseId = $result['data']['id'] ?? 'unknown'; echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n"; } + else { $this->log('ERROR', "Failed to create release: HTTP {$result['code']}"); return 1; } + break; + case 'upload': + if (empty($files)) { $this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2"); return 1; } + $release = $this->getReleaseByTag($apiBase, $tag, $token); + if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; } + $releaseId = $release['id']; + $assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token); + $existingAssets = $assetsResult['data'] ?? []; + foreach ($files as $filePath) { + $filePath = trim($filePath); if (!file_exists($filePath)) { $this->log('ERROR', "File not found: {$filePath}"); continue; } + $fileName = basename($filePath); + foreach ($existingAssets as $asset) { if (($asset['name'] ?? '') === $fileName) { $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token); echo "Deleted existing asset: {$fileName}\n"; break; } } + $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName); + $result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath); + if ($result['code'] >= 200 && $result['code'] < 300) { echo "Uploaded: {$fileName}\n"; } else { $this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}"); } } - } - - // Upload - $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName); - $result = releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath); - if ($result['code'] >= 200 && $result['code'] < 300) { - echo "Uploaded: {$fileName}\n"; - } else { - fwrite(STDERR, "Failed to upload {$fileName}: HTTP {$result['code']}\n"); - } + break; + case 'update-body': + $release = $this->getReleaseByTag($apiBase, $tag, $token); + if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; } + $payload = json_encode(['body' => $body ?? '']); + $result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload); + if ($result['code'] >= 200 && $result['code'] < 300) { echo "Release body updated for tag: {$tag}\n"; } else { $this->log('ERROR', "Failed to update body: HTTP {$result['code']}"); return 1; } + break; + case 'delete': + $existing = $this->getReleaseByTag($apiBase, $tag, $token); + if ($existing !== null) { $this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token); $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); echo "Deleted: {$tag} (id: {$existing['id']})\n"; } + else { echo "No release found for tag: {$tag}\n"; } + break; + default: $this->log('ERROR', "Unknown action: {$action}"); return 1; } - break; + return 0; + } - case 'update-body': - $release = getReleaseByTag($apiBase, $tag, $token); - if ($release === null) { - fwrite(STDERR, "No release found for tag: {$tag}\n"); - exit(1); - } - $releaseId = $release['id']; + private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array + { + $ch = curl_init($url); $headers = ["Authorization: token {$token}"]; + $opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method]; + if ($jsonBody !== null) { $headers[] = 'Content-Type: application/json'; $opts[CURLOPT_POSTFIELDS] = $jsonBody; } + elseif ($filePath !== null) { $headers[] = 'Content-Type: application/octet-stream'; $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); } + $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); + $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []]; + } - $payload = json_encode(['body' => $body ?? '']); - $result = releaseGiteaApi("{$apiBase}/releases/{$releaseId}", 'PATCH', $token, $payload); - if ($result['code'] >= 200 && $result['code'] < 300) { - echo "Release body updated for tag: {$tag}\n"; - } else { - fwrite(STDERR, "Failed to update body: HTTP {$result['code']}\n"); - exit(1); - } - break; - - case 'delete': - $existing = getReleaseByTag($apiBase, $tag, $token); - if ($existing !== null) { - releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token); - releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); - echo "Deleted: {$tag} (id: {$existing['id']})\n"; - } else { - echo "No release found for tag: {$tag}\n"; - } - break; - - default: - fwrite(STDERR, "Unknown action: {$action}\n"); - fwrite(STDERR, "Valid actions: create, upload, update-body, delete\n"); - exit(1); + private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array + { + $result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token); + return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null; + } } -exit(0); +$app = new ReleaseManageCli(); +exit($app->execute()); diff --git a/cli/release_mirror.php b/cli/release_mirror.php index 4f8fc8e..de1481c 100644 --- a/cli/release_mirror.php +++ b/cli/release_mirror.php @@ -11,290 +11,237 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_mirror.php * BRIEF: Mirror a Gitea release (with assets) to a GitHub repository - * - * Usage: - * php release_mirror.php --version 09.01.00 --tag stable --token TOKEN --api-base URL \ - * --gh-token GH_MIRROR_TOKEN --gh-repo MokoConsulting/MokoWaaS - * - * Mirrors a Gitea release (title, body, assets) to a corresponding GitHub release. - * If the GitHub release already exists at the same tag, its title is updated via PATCH. - * All assets from the Gitea release are downloaded and uploaded to the GitHub release. */ declare(strict_types=1); -// ── Argument parsing ───────────────────────────────────────────────────────── +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -$version = null; -$tag = null; -$token = null; -$apiBase = null; -$ghToken = null; -$ghRepo = null; -$branch = 'main'; +use MokoEnterprise\CliFramework; -foreach ($argv as $i => $arg) { - if ($arg === '--version' && isset($argv[$i + 1])) { - $version = $argv[$i + 1]; - } - if ($arg === '--tag' && isset($argv[$i + 1])) { - $tag = $argv[$i + 1]; - } - if ($arg === '--token' && isset($argv[$i + 1])) { - $token = $argv[$i + 1]; - } - if ($arg === '--api-base' && isset($argv[$i + 1])) { - $apiBase = $argv[$i + 1]; - } - if ($arg === '--gh-token' && isset($argv[$i + 1])) { - $ghToken = $argv[$i + 1]; - } - if ($arg === '--gh-repo' && isset($argv[$i + 1])) { - $ghRepo = $argv[$i + 1]; - } - if ($arg === '--branch' && isset($argv[$i + 1])) { - $branch = $argv[$i + 1]; - } -} - -// Allow tokens from environment -$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null)); -$ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: null); - -if ( - $version === null || $tag === null || $token === null || $apiBase === null - || $ghToken === null || $ghRepo === null -) { - fwrite(STDERR, "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " . - "--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]\n"); - fwrite(STDERR, " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)\n"); - fwrite(STDERR, " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)\n"); - exit(1); -} - -// ── Helper: Gitea API request ──────────────────────────────────────────────── - -/** - * Send a request to the Gitea API. - * - * @param string $url Full Gitea API URL - * @param string $token Gitea API token - * @param string $method HTTP method (GET, POST, PATCH, DELETE) - * @param string|null $body JSON request body or null - * - * @return array|null Decoded response or null on failure - */ -function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array +class ReleaseMirrorCli extends CliFramework { - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Content-Type: application/json', - ], - CURLOPT_TIMEOUT => 30, - CURLOPT_CUSTOMREQUEST => $method, - ]); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { - return null; - } - return json_decode($response, true) ?: null; -} - -/** - * Download a file from Gitea to a local path. - * - * @param string $url Download URL - * @param string $token Gitea API token - * @param string $dest Local destination path - * - * @return bool True on success - */ -function giteaDownload(string $url, string $token, string $dest): bool -{ - $ch = curl_init($url); - $fp = fopen($dest, 'wb'); - curl_setopt_array($ch, [ - CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], - CURLOPT_FILE => $fp, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 120, - ]); - curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - fclose($fp); - return $httpCode >= 200 && $httpCode < 300; -} - -/** - * Send a request to the GitHub API. - * - * @param string $url Full GitHub API URL - * @param string $token GitHub personal access token - * @param string $method HTTP method (GET, POST, PATCH, DELETE) - * @param string|null $body JSON request body or null - * - * @return array|null Decoded response or null on failure - */ -function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array -{ - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Accept: application/vnd.github+json', - 'User-Agent: moko-platform', - 'Content-Type: application/json', - ], - CURLOPT_TIMEOUT => 30, - CURLOPT_CUSTOMREQUEST => $method, - ]); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { - return null; - } - return json_decode($response, true) ?: null; -} - -/** - * Upload a binary asset to a GitHub release. - * - * @param string $uploadUrl GitHub upload URL (uploads.github.com) - * @param string $token GitHub personal access token - * @param string $filePath Local file path to upload - * @param string $name Asset filename for GitHub - * - * @return int HTTP status code - */ -function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int -{ - $url = $uploadUrl . '?name=' . urlencode($name); - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Accept: application/vnd.github+json', - 'User-Agent: moko-platform', - 'Content-Type: application/octet-stream', - ], - CURLOPT_POSTFIELDS => file_get_contents($filePath), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 120, - ]); - curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - return $httpCode; -} - -// ── Step 1: Get Gitea release by tag ───────────────────────────────────────── - -echo "Fetching Gitea release: {$tag}\n"; -$giteaRelease = giteaApi("{$apiBase}/releases/tags/{$tag}", $token); -if (!$giteaRelease || empty($giteaRelease['id'])) { - fwrite(STDERR, "No Gitea release found with tag: {$tag}\n"); - exit(1); -} - -$giteaId = $giteaRelease['id']; -$releaseName = $giteaRelease['name'] ?? "{$version}"; -$releaseBody = $giteaRelease['body'] ?? ''; -$assets = $giteaRelease['assets'] ?? []; - -echo " Name: {$releaseName}\n"; -echo " Assets: " . count($assets) . " file(s)\n"; - -// ── Step 2: Check / create GitHub release ──────────────────────────────────── - -$ghApiBase = "https://api.github.com/repos/{$ghRepo}"; -$ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}"; - -echo "Checking GitHub release: {$tag}\n"; -$ghRelease = githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken); - -if ($ghRelease && !empty($ghRelease['id'])) { - // Update existing release title - $ghReleaseId = $ghRelease['id']; - echo " GitHub release exists (id: {$ghReleaseId}), updating title\n"; - $patchPayload = json_encode([ - 'name' => $releaseName, - 'body' => $releaseBody, - ]); - githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload); -} else { - // Create new release - echo " Creating GitHub release\n"; - $createPayload = json_encode([ - 'tag_name' => $tag, - 'target_commitish' => $branch, - 'name' => $releaseName, - 'body' => $releaseBody, - 'draft' => false, - 'prerelease' => ($tag !== 'stable'), - ]); - $ghRelease = githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload); - if (!$ghRelease || empty($ghRelease['id'])) { - fwrite(STDERR, "Failed to create GitHub release\n"); - exit(1); - } - $ghReleaseId = $ghRelease['id']; - echo " Created GitHub release (id: {$ghReleaseId})\n"; -} - -// ── Step 3: Download assets from Gitea ─────────────────────────────────────── - -$tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid(); -@mkdir($tmpDir, 0755, true); - -$uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets"; - -foreach ($assets as $asset) { - $name = $asset['name'] ?? ''; - $downloadUrl = $asset['browser_download_url'] ?? ''; - if ($name === '' || $downloadUrl === '') { - continue; + protected function configure(): void + { + $this->setDescription('Mirror a Gitea release (with assets) to a GitHub repository'); + $this->addArgument('--version', 'Version string (required)', ''); + $this->addArgument('--tag', 'Release tag name (required)', ''); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--api-base', 'Gitea API base URL for the repo (required)', ''); + $this->addArgument('--gh-token', 'GitHub personal access token', ''); + $this->addArgument('--gh-repo', 'GitHub org/repo (required)', ''); + $this->addArgument('--branch', 'Target branch (default: main)', 'main'); } - $localPath = "{$tmpDir}/{$name}"; - echo " Downloading: {$name}\n"; + protected function run(): int + { + $version = $this->getArgument('--version'); + $tag = $this->getArgument('--tag'); + $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $ghToken = $this->getArgument('--gh-token'); + $ghRepo = $this->getArgument('--gh-repo'); + $branch = $this->getArgument('--branch'); - if (!giteaDownload($downloadUrl, $token, $localPath)) { - fwrite(STDERR, " Failed to download: {$name}\n"); - continue; + // Allow tokens from environment + $token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: '')); + $ghToken = $ghToken ?: (getenv('GH_MIRROR_TOKEN') ?: ''); + + if ($version === '' || $tag === '' || $token === '' || $apiBase === '' || $ghToken === '' || $ghRepo === '') { + $this->log('ERROR', "Usage: release_mirror.php --version VER --tag TAG --token TOKEN " . + "--api-base URL --gh-token GH_MIRROR_TOKEN --gh-repo org/repo [--branch main]"); + $this->log('ERROR', " --token: Gitea token (or MOKOGITEA_TOKEN / GITEA_TOKEN env)"); + $this->log('ERROR', " --gh-token: GitHub token (or GH_MIRROR_TOKEN env)"); + return 1; + } + + // ── Step 1: Get Gitea release by tag ───────────────────────────────────────── + + echo "Fetching Gitea release: {$tag}\n"; + $giteaRelease = $this->giteaApi("{$apiBase}/releases/tags/{$tag}", $token); + if (!$giteaRelease || empty($giteaRelease['id'])) { + $this->log('ERROR', "No Gitea release found with tag: {$tag}"); + return 1; + } + + $giteaId = $giteaRelease['id']; + $releaseName = $giteaRelease['name'] ?? "{$version}"; + $releaseBody = $giteaRelease['body'] ?? ''; + $assets = $giteaRelease['assets'] ?? []; + + echo " Name: {$releaseName}\n"; + echo " Assets: " . count($assets) . " file(s)\n"; + + // ── Step 2: Check / create GitHub release ──────────────────────────────────── + + $ghApiBase = "https://api.github.com/repos/{$ghRepo}"; + $ghUploadBase = "https://uploads.github.com/repos/{$ghRepo}"; + + echo "Checking GitHub release: {$tag}\n"; + $ghRelease = $this->githubApi("{$ghApiBase}/releases/tags/{$tag}", $ghToken); + + if ($ghRelease && !empty($ghRelease['id'])) { + // Update existing release title + $ghReleaseId = $ghRelease['id']; + echo " GitHub release exists (id: {$ghReleaseId}), updating title\n"; + $patchPayload = json_encode([ + 'name' => $releaseName, + 'body' => $releaseBody, + ]); + $this->githubApi("{$ghApiBase}/releases/{$ghReleaseId}", $ghToken, 'PATCH', $patchPayload); + } else { + // Create new release + echo " Creating GitHub release\n"; + $createPayload = json_encode([ + 'tag_name' => $tag, + 'target_commitish' => $branch, + 'name' => $releaseName, + 'body' => $releaseBody, + 'draft' => false, + 'prerelease' => ($tag !== 'stable'), + ]); + $ghRelease = $this->githubApi("{$ghApiBase}/releases", $ghToken, 'POST', $createPayload); + if (!$ghRelease || empty($ghRelease['id'])) { + $this->log('ERROR', 'Failed to create GitHub release'); + return 1; + } + $ghReleaseId = $ghRelease['id']; + echo " Created GitHub release (id: {$ghReleaseId})\n"; + } + + // ── Step 3: Download assets from Gitea ─────────────────────────────────────── + + $tmpDir = sys_get_temp_dir() . '/moko-mirror-' . getmypid(); + @mkdir($tmpDir, 0755, true); + + $uploadUrl = "{$ghUploadBase}/releases/{$ghReleaseId}/assets"; + + foreach ($assets as $asset) { + $name = $asset['name'] ?? ''; + $downloadUrl = $asset['browser_download_url'] ?? ''; + if ($name === '' || $downloadUrl === '') { + continue; + } + + $localPath = "{$tmpDir}/{$name}"; + echo " Downloading: {$name}\n"; + + if (!$this->giteaDownload($downloadUrl, $token, $localPath)) { + $this->log('ERROR', " Failed to download: {$name}"); + continue; + } + + // ── Step 4: Upload asset to GitHub ─────────────────────────────────────── + echo " Uploading: {$name}\n"; + $code = $this->githubUploadAsset($uploadUrl, $ghToken, $localPath, $name); + $status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})"; + echo " {$status}\n"; + } + + // ── Cleanup ────────────────────────────────────────────────────────────────── + + array_map('unlink', glob("{$tmpDir}/*") ?: []); + @rmdir($tmpDir); + + // ── Summary ────────────────────────────────────────────────────────────────── + + echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n"; + echo " Version: {$version}\n"; + echo " Assets: " . count($assets) . " file(s)\n"; + return 0; } - // ── Step 4: Upload asset to GitHub ─────────────────────────────────────── - echo " Uploading: {$name}\n"; - $code = githubUploadAsset($uploadUrl, $ghToken, $localPath, $name); - $status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})"; - echo " {$status}\n"; + private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { + return null; + } + return json_decode($response, true) ?: null; + } + + private function giteaDownload(string $url, string $token, string $dest): bool + { + $ch = curl_init($url); + $fp = fopen($dest, 'wb'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_FILE => $fp, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 120, + ]); + curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + fclose($fp); + return $httpCode >= 200 && $httpCode < 300; + } + + private function githubApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Accept: application/vnd.github+json', + 'User-Agent: moko-platform', + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { + return null; + } + return json_decode($response, true) ?: null; + } + + private function githubUploadAsset(string $uploadUrl, string $token, string $filePath, string $name): int + { + $url = $uploadUrl . '?name=' . urlencode($name); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Accept: application/vnd.github+json', + 'User-Agent: moko-platform', + 'Content-Type: application/octet-stream', + ], + CURLOPT_POSTFIELDS => file_get_contents($filePath), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + ]); + curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return $httpCode; + } } -// ── Cleanup ────────────────────────────────────────────────────────────────── - -array_map('unlink', glob("{$tmpDir}/*") ?: []); -@rmdir($tmpDir); - -// ── Summary ────────────────────────────────────────────────────────────────── - -echo "\nMirror complete: {$tag} -> github.com/{$ghRepo}\n"; -echo " Version: {$version}\n"; -echo " Assets: " . count($assets) . " file(s)\n"; -exit(0); +$app = new ReleaseMirrorCli(); +exit($app->execute()); diff --git a/cli/release_notes.php b/cli/release_notes.php index 643000d..6fb99d5 100644 --- a/cli/release_notes.php +++ b/cli/release_notes.php @@ -14,53 +14,69 @@ declare(strict_types=1); -$path = '.'; -$version = null; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; -} +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -if ($version === null) { - // Read from README.md - $readme = realpath($path) . '/README.md'; - if (file_exists($readme)) { - $content = file_get_contents($readme); - if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { - $version = $m[1]; +use MokoEnterprise\CliFramework; + +class ReleaseNotesCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Extract release notes from CHANGELOG.md for a given version'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--version', 'Version to extract notes for', ''); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version') ?: null; + + if ($version === null || $version === '') { + // Read from README.md + $readme = realpath($path) . '/README.md'; + if (file_exists($readme)) { + $content = file_get_contents($readme); + if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { + $version = $m[1]; + } + } } + + if ($version === null || $version === '') { + $this->log('ERROR', 'Usage: release_notes.php --path . --version XX.YY.ZZ'); + return 1; + } + + $changelog = realpath($path) . '/CHANGELOG.md'; + if (!file_exists($changelog)) { + echo "Release {$version}\n"; + return 0; + } + + $lines = file($changelog, FILE_IGNORE_NEW_LINES); + $notes = []; + $capturing = false; + + foreach ($lines as $line) { + if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) { + $capturing = true; + continue; + } + if ($capturing && preg_match('/^## /', $line)) { + break; + } + if ($capturing) { + $notes[] = $line; + } + } + + $result = trim(implode("\n", $notes)); + echo $result ?: "Release {$version}"; + echo "\n"; + return 0; } } -if ($version === null) { - fwrite(STDERR, "Usage: release_notes.php --path . --version XX.YY.ZZ\n"); - exit(1); -} - -$changelog = realpath($path) . '/CHANGELOG.md'; -if (!file_exists($changelog)) { - echo "Release {$version}\n"; - exit(0); -} - -$lines = file($changelog, FILE_IGNORE_NEW_LINES); -$notes = []; -$capturing = false; - -foreach ($lines as $line) { - if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/', $line)) { - $capturing = true; - continue; - } - if ($capturing && preg_match('/^## /', $line)) { - break; // Next version heading — stop - } - if ($capturing) { - $notes[] = $line; - } -} - -$result = trim(implode("\n", $notes)); -echo $result ?: "Release {$version}"; -echo "\n"; -exit(0); +$app = new ReleaseNotesCli(); +exit($app->execute()); diff --git a/cli/release_package.php b/cli/release_package.php index 4d5851c..c449da9 100644 --- a/cli/release_package.php +++ b/cli/release_package.php @@ -11,572 +11,514 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_package.php * BRIEF: Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release - * - * Usage: - * php release_package.php --path . --version 09.01.00 --tag stable --token TOKEN --api-base URL - * php release_package.php --path . --version 09.01.00 --tag development --token TOKEN --api-base URL --repo myrepo - * - * Builds ZIP and tar.gz packages from src/ or htdocs/, computes SHA-256 checksums, - * creates .sha256 sidecar files, and uploads all assets to an existing Gitea release. - * - * For Joomla packages (type=package with packages/ subdir): - * - ZIPs each sub-extension directory - * - Copies top-level XML/PHP to package root before archiving - * - * For standard extensions: - * - Builds ZIP and tar.gz from source dir - * - Excludes: sftp-config*, .ftpignore, *.ppk, *.pem, *.key, .env*, *.local, .build-trigger */ declare(strict_types=1); -// ── Argument parsing ───────────────────────────────────────────────────────── +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -$path = '.'; -$version = null; -$tag = null; -$token = null; -$apiBase = null; -$repoName = ''; -$outputDir = sys_get_temp_dir(); +use MokoEnterprise\CliFramework; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) { - $path = $argv[$i + 1]; - } - if ($arg === '--version' && isset($argv[$i + 1])) { - $version = $argv[$i + 1]; - } - if ($arg === '--tag' && isset($argv[$i + 1])) { - $tag = $argv[$i + 1]; - } - if ($arg === '--token' && isset($argv[$i + 1])) { - $token = $argv[$i + 1]; - } - if ($arg === '--api-base' && isset($argv[$i + 1])) { - $apiBase = $argv[$i + 1]; - } - if ($arg === '--repo' && isset($argv[$i + 1])) { - $repoName = $argv[$i + 1]; - } - if ($arg === '--output' && isset($argv[$i + 1])) { - $outputDir = $argv[$i + 1]; - } -} - -// Allow token from environment -if ($token === null) { - $token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null); -} - -if ($version === null || $tag === null || $token === null || $apiBase === null) { - fwrite(STDERR, "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL\n"); - fwrite(STDERR, " --repo REPO Repo name for element detection fallback\n"); - fwrite(STDERR, " --output DIR Output directory for built packages (default: sys_get_temp_dir())\n"); - fwrite(STDERR, " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var\n"); - exit(1); -} - -$root = realpath($path) ?: $path; - -// ── Helper: Gitea API request ──────────────────────────────────────────────── - -/** - * Perform a Gitea API request. - * - * @param string $url Full API URL - * @param string $token API token - * @param string $method HTTP method - * @param string|null $body Request body (JSON) - * - * @return array{data: array|null, code: int} - */ -function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array +class ReleasePackageCli extends CliFramework { - $ch = curl_init($url); - if ($ch === false) { - return ['data' => null, 'code' => 0]; - } - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Content-Type: application/json', - ], - CURLOPT_TIMEOUT => 30, - CURLOPT_CUSTOMREQUEST => $method, - ]); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - $response = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + /** @var array */ + private array $excludePatterns = [ + 'sftp-config*', + '.ftpignore', + '*.ppk', + '*.pem', + '*.key', + '.env*', + '*.local', + '.build-trigger', + ]; - if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') { - return ['data' => null, 'code' => $httpCode]; - } + protected function configure(): void + { + $this->setDescription('Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--version', 'Release version', ''); + $this->addArgument('--tag', 'Release tag name', ''); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--api-base', 'Gitea API base URL', ''); + $this->addArgument('--repo', 'Repo name for element detection fallback', ''); + $this->addArgument('--output', 'Output directory for built packages', ''); + } - $decoded = json_decode($response, true); - return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode]; + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $tag = $this->getArgument('--tag'); + $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $repoName = $this->getArgument('--repo'); + $outputDir = $this->getArgument('--output'); + + if ($outputDir === '' || $outputDir === null) { + $outputDir = sys_get_temp_dir(); + } + + // Allow token from environment + if ($token === '' || $token === null) { + $token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: ''); + } + + if ($version === '' || $tag === '' || $token === '' || $apiBase === '') { + $this->log('ERROR', "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL"); + $this->log('ERROR', " --repo REPO Repo name for element detection fallback"); + $this->log('ERROR', " --output DIR Output directory for built packages (default: sys_get_temp_dir())"); + $this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var"); + return 1; + } + + $root = realpath($path) ?: $path; + + // ── Read platform from .mokogitea/manifest.xml ─────────────────────── + $detectedPlatform = 'generic'; + $detectedEntryPoint = ''; + $mokoManifest = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($mokoManifest)) { + $mokoXml = @simplexml_load_file($mokoManifest); + if ($mokoXml !== false) { + $rawPlatform = (string)($mokoXml->governance->platform ?? ''); + if ($rawPlatform !== '') { + $detectedPlatform = match ($rawPlatform) { + 'waas-component' => 'joomla', + 'crm-module' => 'dolibarr', + default => $rawPlatform, + }; + } + $detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? ''); + } + } + + // ── Detect element metadata from manifest XML ──────────────────────── + $extElement = ''; + $extType = ''; + $extFolder = ''; + $typePrefix = ''; + + $manifestFiles = array_merge( + glob("{$root}/src/pkg_*.xml") ?: [], + glob("{$root}/src/*.xml") ?: [], + glob("{$root}/*.xml") ?: [] + ); + + $extManifest = null; + foreach ($manifestFiles as $file) { + $content = file_get_contents($file); + if ($content !== false && strpos($content, '([^<]+)<\/element>/', $xml, $em)) { + $extElement = $em[1]; + } + if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) { + $extElement = $mm[1]; + } + if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { + $extElement = $pm[1]; + } + if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { + $extElement = $pn[1]; + } + if ($extElement === '') { + $extElement = strtolower(basename($extManifest, '.xml')); + if (in_array($extElement, ['templatedetails', 'manifest'], true)) { + $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); + } + } + } + + // Fallback to repo name + if ($extElement === '') { + $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); + } + + // Strip existing type prefix to prevent duplication + $extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); + + // Compute type prefix + switch ($extType) { + case 'plugin': + $typePrefix = "plg_{$extFolder}_"; + break; + case 'module': + $typePrefix = 'mod_'; + break; + case 'component': + $typePrefix = 'com_'; + break; + case 'template': + $typePrefix = 'tpl_'; + break; + case 'library': + $typePrefix = 'lib_'; + break; + case 'package': + $typePrefix = 'pkg_'; + break; + } + + echo "Element: {$typePrefix}{$extElement}\n"; + echo "Type: {$extType}\n"; + + // ── Compute filenames ──────────────────────────────────────────────── + $baseName = "{$typePrefix}{$extElement}-{$version}"; + $zipFile = "{$outputDir}/{$baseName}.zip"; + $tarFile = "{$outputDir}/{$baseName}.tar.gz"; + + echo "ZIP: {$baseName}.zip\n"; + echo "TAR: {$baseName}.tar.gz\n"; + + // ── Find source directory ──────────────────────────────────────────── + $sourceDir = null; + + if ($detectedEntryPoint !== '') { + $entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/'); + if (is_dir("{$root}/{$entryDir}")) { + $sourceDir = "{$root}/{$entryDir}"; + } + } + + if ($sourceDir === null && is_dir("{$root}/src")) { + $sourceDir = "{$root}/src"; + } elseif ($sourceDir === null && is_dir("{$root}/htdocs")) { + $sourceDir = "{$root}/htdocs"; + } + + if ($sourceDir === null) { + echo "No src/ or htdocs/ directory found — skipping package build\n"; + return 0; + } + + echo "Source: {$sourceDir}\n"; + + // ── Build packages ─────────────────────────────────────────────────── + $isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); + + if ($isJoomlaPackage) { + echo "Building Joomla package (sub-extensions)...\n"; + + $zip = new \ZipArchive(); + if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create ZIP: {$zipFile}"); + return 1; + } + + $packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: []; + foreach ($packageDirs as $pkgDir) { + $subName = basename($pkgDir); + $subZipPath = "{$outputDir}/{$subName}.zip"; + + $subZip = new \ZipArchive(); + if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}"); + continue; + } + $this->addDirToZip($subZip, $pkgDir, '', $this->excludePatterns); + $subZip->close(); + + $zip->addFile($subZipPath, "packages/{$subName}.zip"); + echo " Sub-package: {$subName}.zip\n"; + } + + $pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: []; + foreach ($pkgManifests as $pkgXml) { + $pkgContent = file_get_contents($pkgXml); + if (strpos($pkgContent, '') !== false && strpos($pkgContent, 'folder="packages"') === false) { + $pkgContent = str_replace('', '', $pkgContent); + file_put_contents($pkgXml, $pkgContent); + echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n"; + } + } + + $topLevelFiles = array_merge( + glob("{$sourceDir}/*.xml") ?: [], + glob("{$sourceDir}/*.php") ?: [] + ); + foreach ($topLevelFiles as $tlFile) { + if (!$this->isExcluded(basename($tlFile), $this->excludePatterns)) { + $zip->addFile($tlFile, basename($tlFile)); + } + } + + $topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: []; + foreach ($topLevelDirs as $tlDir) { + $dirName = basename($tlDir); + if ($dirName === 'packages') { + continue; + } + $this->addDirToZip($zip, $tlDir, $dirName, $this->excludePatterns); + echo " Included dir: {$dirName}/\n"; + } + + $zip->close(); + echo "ZIP created: {$zipFile}\n"; + } else { + echo "Building standard extension ZIP...\n"; + + $zip = new \ZipArchive(); + if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create ZIP: {$zipFile}"); + return 1; + } + $this->addDirToZip($zip, $sourceDir, '', $this->excludePatterns); + $zip->close(); + echo "ZIP created: {$zipFile}\n"; + } + + // ── Build tar.gz ───────────────────────────────────────────────────── + $tarExcludeArgs = []; + foreach ($this->excludePatterns as $pattern) { + $tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern); + } + + $tarCommand = sprintf( + 'tar -czf %s -C %s %s .', + escapeshellarg($tarFile), + escapeshellarg($sourceDir), + implode(' ', $tarExcludeArgs) + ); + + $tarReturnCode = 0; + $tarOutputLines = []; + exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode); + + if (!file_exists($tarFile)) { + $this->log('ERROR', "Failed to create tar.gz: {$tarFile}"); + if ($tarOutputLines !== []) { + $this->log('ERROR', implode("\n", $tarOutputLines)); + } + return 1; + } + echo "TAR created: {$tarFile}\n"; + + // ── Compute SHA-256 checksums ──────────────────────────────────────── + $zipHash = hash_file('sha256', $zipFile); + $tarHash = hash_file('sha256', $tarFile); + + if ($zipHash === false || $tarHash === false) { + $this->log('ERROR', "Failed to compute SHA-256 checksums"); + return 1; + } + + $zipSha = "{$zipFile}.sha256"; + $tarSha = "{$tarFile}.sha256"; + + file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n"); + file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n"); + + echo "SHA-256 (ZIP): {$zipHash}\n"; + echo "SHA-256 (TAR): {$tarHash}\n"; + echo "sha256_zip={$zipHash}\n"; + echo "zip_name={$baseName}.zip\n"; + + // Write to GITHUB_OUTPUT if available + $ghOutput = getenv('GITHUB_OUTPUT'); + if ($ghOutput) { + file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND); + } + + // ── Get release ID from tag ────────────────────────────────────────── + $result = $this->giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token); + if ($result['data'] === null || !isset($result['data']['id'])) { + $this->log('ERROR', "No release found for tag: {$tag} (HTTP {$result['code']})"); + return 1; + } + + $releaseId = (int) $result['data']['id']; + echo "Release ID: {$releaseId} (tag: {$tag})\n"; + + // ── Delete existing assets with same names ─────────────────────────── + $assetsResult = $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token); + $existingAssets = $assetsResult['data'] ?? []; + + $uploadNames = [ + "{$baseName}.zip", + "{$baseName}.tar.gz", + "{$baseName}.zip.sha256", + "{$baseName}.tar.gz.sha256", + ]; + + foreach ($existingAssets as $asset) { + if (!is_array($asset)) { + continue; + } + $assetName = $asset['name'] ?? ''; + $assetId = $asset['id'] ?? 0; + if (in_array($assetName, $uploadNames, true) && $assetId > 0) { + $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE'); + echo "Deleted existing asset: {$assetName}\n"; + } + } + + // ── Upload assets ──────────────────────────────────────────────────── + $filesToUpload = [ + "{$baseName}.zip" => $zipFile, + "{$baseName}.tar.gz" => $tarFile, + "{$baseName}.zip.sha256" => $zipSha, + "{$baseName}.tar.gz.sha256" => $tarSha, + ]; + + $uploaded = 0; + foreach ($filesToUpload as $name => $localPath) { + if (!file_exists($localPath)) { + $this->log('ERROR', "File not found, skipping: {$localPath}"); + continue; + } + + $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name); + $httpCode = $this->giteaUploadAsset($uploadUrl, $token, $localPath); + $status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})"; + echo "Upload: {$name} — {$status}\n"; + + if ($httpCode >= 200 && $httpCode < 300) { + $uploaded++; + } + } + + // ── Summary ────────────────────────────────────────────────────────── + echo "\n"; + echo "Package build complete\n"; + echo " Element: {$typePrefix}{$extElement}\n"; + echo " Version: {$version}\n"; + echo " Tag: {$tag}\n"; + echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n"; + + return $uploaded === count($filesToUpload) ? 0 : 1; + } + + /** + * Perform a Gitea API request. + * + * @return array{data: array|null, code: int} + */ + private function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array + { + $ch = curl_init($url); + if ($ch === false) { + return ['data' => null, 'code' => 0]; + } + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') { + return ['data' => null, 'code' => $httpCode]; + } + + $decoded = json_decode($response, true); + return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode]; + } + + /** + * Upload a file as a release asset. + */ + private function giteaUploadAsset(string $url, string $token, string $filePath): int + { + $ch = curl_init($url); + if ($ch === false) { + return 0; + } + $fileContent = file_get_contents($filePath); + if ($fileContent === false) { + return 0; + } + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/octet-stream', + ], + CURLOPT_POSTFIELDS => $fileContent, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + ]); + curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $httpCode; + } + + /** + * Check if a filename matches any exclusion pattern. + */ + private function isExcluded(string $filename, array $patterns): bool + { + $basename = basename($filename); + foreach ($patterns as $pattern) { + if (fnmatch($pattern, $basename)) { + return true; + } + } + return false; + } + + /** + * Recursively add files from a directory to a ZipArchive. + */ + private function addDirToZip(\ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($sourceDir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + if (!$file instanceof \SplFileInfo || !$file->isFile()) { + continue; + } + + $realPath = $file->getRealPath(); + if ($realPath === false) { + continue; + } + + if ($this->isExcluded($file->getFilename(), $excludes)) { + continue; + } + + $relativePath = substr($realPath, strlen($sourceDir) + 1); + // Normalise to forward slashes for ZIP compatibility + $relativePath = str_replace('\\', '/', $relativePath); + $archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath; + $zip->addFile($realPath, $archivePath); + } + } } -/** - * Upload a file as a release asset. - * - * @param string $url Upload endpoint URL - * @param string $token API token - * @param string $filePath Local file path - * - * @return int HTTP status code - */ -function giteaUploadAsset(string $url, string $token, string $filePath): int -{ - $ch = curl_init($url); - if ($ch === false) { - return 0; - } - $fileContent = file_get_contents($filePath); - if ($fileContent === false) { - return 0; - } - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Content-Type: application/octet-stream', - ], - CURLOPT_POSTFIELDS => $fileContent, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 120, - ]); - curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - return $httpCode; -} - -// ── Read platform from .mokogitea/manifest.xml ─────────────────────────────── - -$detectedPlatform = 'generic'; -$detectedEntryPoint = ''; -$mokoManifest = "{$root}/.mokogitea/manifest.xml"; -if (file_exists($mokoManifest)) { - $mokoXml = @simplexml_load_file($mokoManifest); - if ($mokoXml !== false) { - $rawPlatform = (string)($mokoXml->governance->platform ?? ''); - if ($rawPlatform !== '') { - $detectedPlatform = match ($rawPlatform) { - 'waas-component' => 'joomla', - 'crm-module' => 'dolibarr', - default => $rawPlatform, - }; - } - $detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? ''); - } -} - -// ── Detect element metadata from manifest XML ──────────────────────────────── - -$extElement = ''; -$extType = ''; -$extFolder = ''; -$typePrefix = ''; - -$manifestFiles = array_merge( - glob("{$root}/src/pkg_*.xml") ?: [], - glob("{$root}/src/*.xml") ?: [], - glob("{$root}/*.xml") ?: [] -); - -$extManifest = null; -foreach ($manifestFiles as $file) { - $content = file_get_contents($file); - if ($content !== false && strpos($content, ', module= attribute, plugin= attribute, , or filename - if (preg_match('/([^<]+)<\/element>/', $xml, $em)) { - $extElement = $em[1]; - } - if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) { - $extElement = $mm[1]; - } - if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { - $extElement = $pm[1]; - } - // For packages: prefer over filename - if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { - $extElement = $pn[1]; - } - if ($extElement === '') { - $extElement = strtolower(basename($extManifest, '.xml')); - if (in_array($extElement, ['templatedetails', 'manifest'], true)) { - $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); - } - } -} - -// Fallback to repo name -if ($extElement === '') { - $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); -} - -// Strip existing type prefix to prevent duplication -$extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); - -// Compute type prefix -switch ($extType) { - case 'plugin': - $typePrefix = "plg_{$extFolder}_"; - break; - case 'module': - $typePrefix = 'mod_'; - break; - case 'component': - $typePrefix = 'com_'; - break; - case 'template': - $typePrefix = 'tpl_'; - break; - case 'library': - $typePrefix = 'lib_'; - break; - case 'package': - $typePrefix = 'pkg_'; - break; -} - -echo "Element: {$typePrefix}{$extElement}\n"; -echo "Type: {$extType}\n"; - -// ── Compute filenames ──────────────────────────────────────────────────────── - -$baseName = "{$typePrefix}{$extElement}-{$version}"; -$zipFile = "{$outputDir}/{$baseName}.zip"; -$tarFile = "{$outputDir}/{$baseName}.tar.gz"; - -echo "ZIP: {$baseName}.zip\n"; -echo "TAR: {$baseName}.tar.gz\n"; - -// ── Find source directory ──────────────────────────────────────────────────── - -$sourceDir = null; - -// Use entry-point from manifest.xml if available -if ($detectedEntryPoint !== '') { - $entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/'); - if (is_dir("{$root}/{$entryDir}")) { - $sourceDir = "{$root}/{$entryDir}"; - } -} - -// Fallback to common directories -if ($sourceDir === null && is_dir("{$root}/src")) { - $sourceDir = "{$root}/src"; -} elseif ($sourceDir === null && is_dir("{$root}/htdocs")) { - $sourceDir = "{$root}/htdocs"; -} - -if ($sourceDir === null) { - echo "No src/ or htdocs/ directory found — skipping package build\n"; - exit(0); -} - -echo "Source: {$sourceDir}\n"; - -// ── File exclusion patterns ────────────────────────────────────────────────── - -/** @var array */ -$excludePatterns = [ - 'sftp-config*', - '.ftpignore', - '*.ppk', - '*.pem', - '*.key', - '.env*', - '*.local', - '.build-trigger', -]; - -/** - * Check if a filename matches any exclusion pattern. - * - * @param string $filename Filename to check - * @param array $patterns Glob patterns to exclude - * - * @return bool True if the file should be excluded - */ -function isExcluded(string $filename, array $patterns): bool -{ - $basename = basename($filename); - foreach ($patterns as $pattern) { - if (fnmatch($pattern, $basename)) { - return true; - } - } - return false; -} - -/** - * Recursively add files from a directory to a ZipArchive. - * - * @param ZipArchive $zip ZipArchive instance - * @param string $sourceDir Source directory path - * @param string $prefix Path prefix inside the archive - * @param array $excludes Exclusion patterns - */ -function addDirToZip(ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void -{ - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::LEAVES_ONLY - ); - - foreach ($iterator as $file) { - if (!$file instanceof SplFileInfo || !$file->isFile()) { - continue; - } - - $realPath = $file->getRealPath(); - if ($realPath === false) { - continue; - } - - if (isExcluded($file->getFilename(), $excludes)) { - continue; - } - - $relativePath = substr($realPath, strlen($sourceDir) + 1); - // Normalise to forward slashes for ZIP compatibility - $relativePath = str_replace('\\', '/', $relativePath); - $archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath; - $zip->addFile($realPath, $archivePath); - } -} - -// ── Build packages ─────────────────────────────────────────────────────────── - -$isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); - -if ($isJoomlaPackage) { - // ── Joomla package: ZIP each sub-extension, then combine ───────────────── - echo "Building Joomla package (sub-extensions)...\n"; - - $zip = new ZipArchive(); - if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n"); - exit(1); - } - - // ZIP each sub-extension directory - $packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: []; - foreach ($packageDirs as $pkgDir) { - $subName = basename($pkgDir); - $subZipPath = "{$outputDir}/{$subName}.zip"; - - $subZip = new ZipArchive(); - if ($subZip->open($subZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - fwrite(STDERR, "Failed to create sub-package ZIP: {$subZipPath}\n"); - continue; - } - addDirToZip($subZip, $pkgDir, '', $excludePatterns); - $subZip->close(); - - $zip->addFile($subZipPath, "packages/{$subName}.zip"); - echo " Sub-package: {$subName}.zip\n"; - } - - // Ensure package manifest has folder="packages" on element - // since sub-packages are stored in a packages/ subdirectory - $pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: []; - foreach ($pkgManifests as $pkgXml) { - $pkgContent = file_get_contents($pkgXml); - if (strpos($pkgContent, '') !== false && strpos($pkgContent, 'folder="packages"') === false) { - $pkgContent = str_replace('', '', $pkgContent); - file_put_contents($pkgXml, $pkgContent); - echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n"; - } - } - - // Copy top-level XML and PHP files into the package root - $topLevelFiles = array_merge( - glob("{$sourceDir}/*.xml") ?: [], - glob("{$sourceDir}/*.php") ?: [] - ); - foreach ($topLevelFiles as $tlFile) { - if (!isExcluded(basename($tlFile), $excludePatterns)) { - $zip->addFile($tlFile, basename($tlFile)); - } - } - - // Include top-level directories (e.g. language/) that aren't packages/ - $topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: []; - foreach ($topLevelDirs as $tlDir) { - $dirName = basename($tlDir); - if ($dirName === 'packages') { - continue; - } - addDirToZip($zip, $tlDir, $dirName, $excludePatterns); - echo " Included dir: {$dirName}/\n"; - } - - $zip->close(); - echo "ZIP created: {$zipFile}\n"; -} else { - // ── Standard extension: ZIP from source dir ────────────────────────────── - echo "Building standard extension ZIP...\n"; - - $zip = new ZipArchive(); - if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - fwrite(STDERR, "Failed to create ZIP: {$zipFile}\n"); - exit(1); - } - addDirToZip($zip, $sourceDir, '', $excludePatterns); - $zip->close(); - echo "ZIP created: {$zipFile}\n"; -} - -// ── Build tar.gz ───────────────────────────────────────────────────────────── - -$tarExcludeArgs = []; -foreach ($excludePatterns as $pattern) { - $tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern); -} - -$tarCommand = sprintf( - 'tar -czf %s -C %s %s .', - escapeshellarg($tarFile), - escapeshellarg($sourceDir), - implode(' ', $tarExcludeArgs) -); - -$tarReturnCode = 0; -$tarOutputLines = []; -exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode); - -if (!file_exists($tarFile)) { - fwrite(STDERR, "Failed to create tar.gz: {$tarFile}\n"); - if ($tarOutputLines !== []) { - fwrite(STDERR, implode("\n", $tarOutputLines) . "\n"); - } - exit(1); -} -echo "TAR created: {$tarFile}\n"; - -// ── Compute SHA-256 checksums ──────────────────────────────────────────────── - -$zipHash = hash_file('sha256', $zipFile); -$tarHash = hash_file('sha256', $tarFile); - -if ($zipHash === false || $tarHash === false) { - fwrite(STDERR, "Failed to compute SHA-256 checksums\n"); - exit(1); -} - -$zipSha = "{$zipFile}.sha256"; -$tarSha = "{$tarFile}.sha256"; - -file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n"); -file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n"); - -echo "SHA-256 (ZIP): {$zipHash}\n"; -echo "SHA-256 (TAR): {$tarHash}\n"; -echo "sha256_zip={$zipHash}\n"; -echo "zip_name={$baseName}.zip\n"; - -// Write to GITHUB_OUTPUT if available -$ghOutput = getenv('GITHUB_OUTPUT'); -if ($ghOutput) { - file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND); -} - -// ── Get release ID from tag ────────────────────────────────────────────────── - -$result = giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token); -if ($result['data'] === null || !isset($result['data']['id'])) { - fwrite(STDERR, "No release found for tag: {$tag} (HTTP {$result['code']})\n"); - exit(1); -} - -$releaseId = (int) $result['data']['id']; -echo "Release ID: {$releaseId} (tag: {$tag})\n"; - -// ── Delete existing assets with same names ─────────────────────────────────── - -$assetsResult = giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token); -$existingAssets = $assetsResult['data'] ?? []; - -$uploadNames = [ - "{$baseName}.zip", - "{$baseName}.tar.gz", - "{$baseName}.zip.sha256", - "{$baseName}.tar.gz.sha256", -]; - -foreach ($existingAssets as $asset) { - if (!is_array($asset)) { - continue; - } - $assetName = $asset['name'] ?? ''; - $assetId = $asset['id'] ?? 0; - if (in_array($assetName, $uploadNames, true) && $assetId > 0) { - giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE'); - echo "Deleted existing asset: {$assetName}\n"; - } -} - -// ── Upload assets ──────────────────────────────────────────────────────────── - -$filesToUpload = [ - "{$baseName}.zip" => $zipFile, - "{$baseName}.tar.gz" => $tarFile, - "{$baseName}.zip.sha256" => $zipSha, - "{$baseName}.tar.gz.sha256" => $tarSha, -]; - -$uploaded = 0; -foreach ($filesToUpload as $name => $localPath) { - if (!file_exists($localPath)) { - fwrite(STDERR, "File not found, skipping: {$localPath}\n"); - continue; - } - - $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name); - $httpCode = giteaUploadAsset($uploadUrl, $token, $localPath); - $status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})"; - echo "Upload: {$name} — {$status}\n"; - - if ($httpCode >= 200 && $httpCode < 300) { - $uploaded++; - } -} - -// ── Summary ────────────────────────────────────────────────────────────────── - -echo "\n"; -echo "Package build complete\n"; -echo " Element: {$typePrefix}{$extElement}\n"; -echo " Version: {$version}\n"; -echo " Tag: {$tag}\n"; -echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n"; - -exit($uploaded === count($filesToUpload) ? 0 : 1); +$app = new ReleasePackageCli(); +exit($app->execute()); diff --git a/cli/release_promote.php b/cli/release_promote.php index 21d1e3a..4f7a243 100644 --- a/cli/release_promote.php +++ b/cli/release_promote.php @@ -11,306 +11,300 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_promote.php * BRIEF: Promote a Gitea release from one channel to another (rename release, tag, assets) - * - * Usage: - * php release_promote.php --from development --to release-candidate --token TOKEN --api-base URL - * php release_promote.php --from release-candidate --to stable --token TOKEN --api-base URL --path . - * - * When promoting to stable, --path detects extension type prefix for asset renaming. - * When --from is "auto", checks beta > alpha > development and uses the first found. */ declare(strict_types=1); -$from = null; -$to = null; -$token = null; -$apiBase = null; -$path = '.'; -$branch = 'main'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--from' && isset($argv[$i + 1])) { - $from = $argv[$i + 1]; - } - if ($arg === '--to' && isset($argv[$i + 1])) { - $to = $argv[$i + 1]; - } - if ($arg === '--token' && isset($argv[$i + 1])) { - $token = $argv[$i + 1]; - } - if ($arg === '--api-base' && isset($argv[$i + 1])) { - $apiBase = $argv[$i + 1]; - } - if ($arg === '--path' && isset($argv[$i + 1])) { - $path = $argv[$i + 1]; - } - if ($arg === '--branch' && isset($argv[$i + 1])) { - $branch = $argv[$i + 1]; - } -} +use MokoEnterprise\CliFramework; -$token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null)); - -if ($to === null || $token === null || $apiBase === null) { - fwrite(STDERR, "Usage: release_promote.php --from --to --token TOKEN --api-base URL [--path .]\n"); - fwrite(STDERR, " --from auto: checks beta > alpha > development\n"); - exit(1); -} - -// ── Suffix maps ────────────────────────────────────────────────────────────── -$suffixMap = [ - 'development' => '-dev', - 'alpha' => '-alpha', - 'beta' => '-beta', - 'release-candidate' => '-rc', - 'stable' => '', -]; - -// ── Channel hierarchy (highest first) ──────────────────────────────────────── -$channelOrder = ['beta', 'alpha', 'development']; - -// ── Helper: Gitea API request ──────────────────────────────────────────────── -/** @return array|null */ -function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array +class ReleasePromoteCli extends CliFramework { - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Content-Type: application/json', - ], - CURLOPT_TIMEOUT => 30, - CURLOPT_CUSTOMREQUEST => $method, - ]); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + protected function configure(): void + { + $this->setDescription('Promote a Gitea release from one channel to another'); + $this->addArgument('--from', 'Source channel (or "auto")', ''); + $this->addArgument('--to', 'Target channel (required)', ''); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--api-base', 'Gitea API base URL for the repo', ''); + $this->addArgument('--path', 'Repository root for type prefix detection', '.'); + $this->addArgument('--branch', 'Target branch', 'main'); } - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { - return null; - } - return json_decode($response, true) ?: null; -} + protected function run(): int + { + $from = $this->getArgument('--from') ?: null; + $to = $this->getArgument('--to') ?: null; + $token = $this->getArgument('--token') ?: null; + $apiBase = $this->getArgument('--api-base') ?: null; + $path = $this->getArgument('--path'); + $branch = $this->getArgument('--branch'); -function giteaDownload(string $url, string $token, string $dest): bool -{ - $ch = curl_init($url); - $fp = fopen($dest, 'wb'); - curl_setopt_array($ch, [ - CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], - CURLOPT_FILE => $fp, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 120, - ]); - curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - fclose($fp); - return $httpCode >= 200 && $httpCode < 300; -} + $token = $token ?: (getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: null)); -// ── Resolve --from auto ────────────────────────────────────────────────────── -if ($from === 'auto') { - foreach ($channelOrder as $candidate) { - $data = giteaApi("{$apiBase}/releases/tags/{$candidate}", $token); - if ($data && !empty($data['id'])) { - $from = $candidate; - echo "Auto-detected source channel: {$from}\n"; - break; - } - } - if ($from === 'auto') { - echo "No pre-release found to promote\n"; - exit(0); - } -} - -// ── Find source release ────────────────────────────────────────────────────── -$sourceRelease = giteaApi("{$apiBase}/releases/tags/{$from}", $token); -if (!$sourceRelease || empty($sourceRelease['id'])) { - fwrite(STDERR, "No release found with tag: {$from}\n"); - exit(1); -} - -$sourceId = $sourceRelease['id']; -$sourceName = $sourceRelease['name'] ?? ''; -$sourceBody = $sourceRelease['body'] ?? ''; -echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n"; - -// ── Get source assets ──────────────────────────────────────────────────────── -$assets = giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: []; -echo "Assets: " . count($assets) . " file(s)\n"; - -// ── Download assets to temp ────────────────────────────────────────────────── -$tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid(); -@mkdir($tmpDir, 0755, true); - -foreach ($assets as $asset) { - $name = $asset['name']; - $downloadUrl = $asset['browser_download_url']; - echo " Downloading: {$name}\n"; - giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}"); -} - -// ── Detect type prefix for stable promotion ────────────────────────────────── -$typePrefix = ''; -if ($to === 'stable') { - $root = realpath($path) ?: $path; - $manifestFiles = array_merge( - glob("{$root}/src/pkg_*.xml") ?: [], - glob("{$root}/src/*.xml") ?: [], - glob("{$root}/*.xml") ?: [] - ); - foreach ($manifestFiles as $xmlFile) { - $xmlContent = file_get_contents($xmlFile); - if (strpos($xmlContent, 'log('ERROR', "Usage: release_promote.php --from --to --token TOKEN --api-base URL [--path .]"); + $this->log('ERROR', " --from auto: checks beta > alpha > development"); + return 1; } - $extType = ''; - $extFolder = ''; - if (preg_match('/type="([^"]*)"/', $xmlContent, $tm)) { - $extType = $tm[1]; - } - if (preg_match('/group="([^"]*)"/', $xmlContent, $gm)) { - $extFolder = $gm[1]; + // ── Suffix maps ────────────────────────────────────────────────────────────── + $suffixMap = [ + 'development' => '-dev', + 'alpha' => '-alpha', + 'beta' => '-beta', + 'release-candidate' => '-rc', + 'stable' => '', + ]; + + // ── Channel hierarchy (highest first) ──────────────────────────────────────── + $channelOrder = ['beta', 'alpha', 'development']; + + // ── Resolve --from auto ────────────────────────────────────────────────────── + if ($from === 'auto') { + foreach ($channelOrder as $candidate) { + $data = $this->giteaApi("{$apiBase}/releases/tags/{$candidate}", $token); + if ($data && !empty($data['id'])) { + $from = $candidate; + echo "Auto-detected source channel: {$from}\n"; + break; + } + } + if ($from === 'auto') { + echo "No pre-release found to promote\n"; + return 0; + } } - switch ($extType) { - case 'plugin': - $typePrefix = "plg_{$extFolder}_"; - break; - case 'module': - $typePrefix = 'mod_'; - break; - case 'component': - $typePrefix = 'com_'; - break; - case 'template': - $typePrefix = 'tpl_'; - break; - case 'library': - $typePrefix = 'lib_'; - break; - case 'package': - $typePrefix = 'pkg_'; - break; + // ── Find source release ────────────────────────────────────────────────────── + $sourceRelease = $this->giteaApi("{$apiBase}/releases/tags/{$from}", $token); + if (!$sourceRelease || empty($sourceRelease['id'])) { + $this->log('ERROR', "No release found with tag: {$from}"); + return 1; } - if ($typePrefix !== '') { - break; + + $sourceId = $sourceRelease['id']; + $sourceName = $sourceRelease['name'] ?? ''; + $sourceBody = $sourceRelease['body'] ?? ''; + echo "Source: {$from} (id: {$sourceId}) — {$sourceName}\n"; + + // ── Get source assets ──────────────────────────────────────────────────────── + $assets = $this->giteaApi("{$apiBase}/releases/{$sourceId}/assets", $token) ?: []; + echo "Assets: " . count($assets) . " file(s)\n"; + + // ── Download assets to temp ────────────────────────────────────────────────── + $tmpDir = sys_get_temp_dir() . '/moko-promote-' . getmypid(); + @mkdir($tmpDir, 0755, true); + + foreach ($assets as $asset) { + $name = $asset['name']; + $downloadUrl = $asset['browser_download_url']; + echo " Downloading: {$name}\n"; + $this->giteaDownload($downloadUrl, $token, "{$tmpDir}/{$name}"); } + + // ── Detect type prefix for stable promotion ────────────────────────────────── + $typePrefix = ''; + if ($to === 'stable') { + $root = realpath($path) ?: $path; + $manifestFiles = array_merge( + glob("{$root}/src/pkg_*.xml") ?: [], + glob("{$root}/src/*.xml") ?: [], + glob("{$root}/*.xml") ?: [] + ); + foreach ($manifestFiles as $xmlFile) { + $xmlContent = file_get_contents($xmlFile); + if (strpos($xmlContent, ' $oldName, 'new' => $newName]; + if ($oldName !== $newName) { + echo " Rename: {$oldName} → {$newName}\n"; + } + } + + // ── Delete source release + tag ────────────────────────────────────────────── + $this->giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE'); + $this->giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE'); + echo "Deleted source: {$from} release + tag\n"; + + // ── Delete existing target release + tag (if any) ──────────────────────────── + $existingTarget = $this->giteaApi("{$apiBase}/releases/tags/{$to}", $token); + if ($existingTarget && !empty($existingTarget['id'])) { + $this->giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE'); + $this->giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE'); + echo "Deleted existing target: {$to} release + tag\n"; + } + + // ── Create target release ──────────────────────────────────────────────────── + $isPrerelease = ($to !== 'stable'); + $newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName); + if ($newName === $sourceName) { + $newName = str_ireplace($from, $to, $sourceName); + } + + $newBody = str_ireplace($from, $to, $sourceBody); + + $payload = json_encode([ + 'tag_name' => $to, + 'target_commitish' => $branch, + 'name' => $newName, + 'body' => $newBody, + 'prerelease' => $isPrerelease, + ]); + + $newRelease = $this->giteaApi("{$apiBase}/releases", $token, 'POST', $payload); + if (!$newRelease || empty($newRelease['id'])) { + $this->log('ERROR', "Failed to create {$to} release"); + return 1; + } + + $newId = $newRelease['id']; + echo "Created: {$to} release (id: {$newId})\n"; + + // ── Upload renamed assets ──────────────────────────────────────────────────── + foreach ($renamedAssets as $entry) { + $localFile = "{$tmpDir}/{$entry['old']}"; + if (!file_exists($localFile)) { + continue; + } + + $uploadName = urlencode($entry['new']); + $url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}"; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/octet-stream', + ], + CURLOPT_POSTFIELDS => file_get_contents($localFile), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + ]); + curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})"; + echo " Upload: {$entry['new']} — {$status}\n"; + } + + // ── Cleanup temp ───────────────────────────────────────────────────────────── + array_map('unlink', glob("{$tmpDir}/*") ?: []); + @rmdir($tmpDir); + + echo "Promoted: {$from} → {$to}\n"; + return 0; + } + + private function giteaApi(string $url, string $token, string $method = 'GET', ?string $body = null): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300 || empty($response)) { + return null; + } + return json_decode($response, true) ?: null; + } + + private function giteaDownload(string $url, string $token, string $dest): bool + { + $ch = curl_init($url); + $fp = fopen($dest, 'wb'); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_FILE => $fp, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 120, + ]); + curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + fclose($fp); + return $httpCode >= 200 && $httpCode < 300; } } -// ── Rename assets ──────────────────────────────────────────────────────────── -$oldSuffix = $suffixMap[$from] ?? ''; -$newSuffix = $suffixMap[$to] ?? ''; - -$renamedAssets = []; -foreach ($assets as $asset) { - $oldName = $asset['name']; - $newName = $oldName; - - // Strip old suffix - if ($oldSuffix !== '') { - $newName = str_replace($oldSuffix, '', $newName); - } - - // Add type prefix for stable (if not already prefixed) - if ($to === 'stable' && $typePrefix !== '' && strpos($newName, $typePrefix) !== 0) { - // Strip any existing type prefix to prevent duplication - $newName = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $newName); - $newName = $typePrefix . $newName; - } - - // Add new suffix (for non-stable targets) - if ($newSuffix !== '' && strpos($newName, $newSuffix) === false) { - // Insert before extension - $newName = preg_replace('/(\.(zip|tar\.gz|sha256))$/', $newSuffix . '$1', $newName); - } - - $renamedAssets[] = ['old' => $oldName, 'new' => $newName]; - if ($oldName !== $newName) { - echo " Rename: {$oldName} → {$newName}\n"; - } -} - -// ── Delete source release + tag ────────────────────────────────────────────── -giteaApi("{$apiBase}/releases/{$sourceId}", $token, 'DELETE'); -giteaApi("{$apiBase}/tags/{$from}", $token, 'DELETE'); -echo "Deleted source: {$from} release + tag\n"; - -// ── Delete existing target release + tag (if any) ──────────────────────────── -$existingTarget = giteaApi("{$apiBase}/releases/tags/{$to}", $token); -if ($existingTarget && !empty($existingTarget['id'])) { - giteaApi("{$apiBase}/releases/{$existingTarget['id']}", $token, 'DELETE'); - giteaApi("{$apiBase}/tags/{$to}", $token, 'DELETE'); - echo "Deleted existing target: {$to} release + tag\n"; -} - -// ── Create target release ──────────────────────────────────────────────────── -$isPrerelease = ($to !== 'stable'); -$newName = preg_replace('/\(' . preg_quote($from, '/') . '\)/', "({$to})", $sourceName); -if ($newName === $sourceName) { - $newName = str_ireplace($from, $to, $sourceName); -} - -$newBody = str_ireplace($from, $to, $sourceBody); - -$payload = json_encode([ - 'tag_name' => $to, - 'target_commitish' => $branch, - 'name' => $newName, - 'body' => $newBody, - 'prerelease' => $isPrerelease, -]); - -$newRelease = giteaApi("{$apiBase}/releases", $token, 'POST', $payload); -if (!$newRelease || empty($newRelease['id'])) { - fwrite(STDERR, "Failed to create {$to} release\n"); - exit(1); -} - -$newId = $newRelease['id']; -echo "Created: {$to} release (id: {$newId})\n"; - -// ── Upload renamed assets ──────────────────────────────────────────────────── -foreach ($renamedAssets as $entry) { - $localFile = "{$tmpDir}/{$entry['old']}"; - if (!file_exists($localFile)) { - continue; - } - - $uploadName = urlencode($entry['new']); - $url = "{$apiBase}/releases/{$newId}/assets?name={$uploadName}"; - - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Content-Type: application/octet-stream', - ], - CURLOPT_POSTFIELDS => file_get_contents($localFile), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 120, - ]); - curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - $status = ($code >= 200 && $code < 300) ? 'OK' : "FAILED ({$code})"; - echo " Upload: {$entry['new']} — {$status}\n"; -} - -// ── Cleanup temp ───────────────────────────────────────────────────────────── -array_map('unlink', glob("{$tmpDir}/*") ?: []); -@rmdir($tmpDir); - -echo "Promoted: {$from} → {$to}\n"; -exit(0); +$app = new ReleasePromoteCli(); +exit($app->execute()); diff --git a/cli/release_publish.php b/cli/release_publish.php index 939194c..81e5c34 100644 --- a/cli/release_publish.php +++ b/cli/release_publish.php @@ -9,301 +9,287 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_publish.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Publish a release and create copies for all lesser stability streams. - * - * When a release is published at a given stability, copies are created for all - * lower stability streams with the same base version and their respective suffix. - * updates.xml is updated for ALL streams and synced to ALL branches. - * - * Usage: - * php release_publish.php --path . --stability stable --token TOKEN - * php release_publish.php --path . --stability rc --token TOKEN --bump minor - * php release_publish.php --path . --stability dev --token TOKEN --bump patch - * php release_publish.php --path . --stability stable --token TOKEN --dry-run - * - * Options: - * --path Repository root (default: .) - * --stability Target stability: dev|alpha|beta|rc|stable (required) - * --token Gitea API token (required) - * --bump Version bump type before release: patch|minor|none (default: none) - * --branch Current branch (default: auto-detect) - * --gitea-url Gitea URL (default: env GITEA_URL) - * --org Organization (default: env GITEA_ORG) - * --repo Repository name (default: env GITEA_REPO) - * --dry-run Preview without making changes */ declare(strict_types=1); -$path = '.'; -$stability = ''; -$token = ''; -$bumpType = 'none'; -$branch = ''; -$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; -$org = getenv('GITEA_ORG') ?: ''; -$repo = getenv('GITEA_REPO') ?: ''; -$dryRun = false; -$repoUrl = ''; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1]; - if ($arg === '--token' && isset($argv[$i + 1])) $token = $argv[$i + 1]; - if ($arg === '--bump' && isset($argv[$i + 1])) $bumpType = $argv[$i + 1]; - if ($arg === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1]; - if ($arg === '--gitea-url' && isset($argv[$i + 1])) $giteaUrl = $argv[$i + 1]; - if ($arg === '--org' && isset($argv[$i + 1])) $org = $argv[$i + 1]; - if ($arg === '--repo' && isset($argv[$i + 1])) $repo = $argv[$i + 1]; - if ($arg === '--repo-url' && isset($argv[$i + 1])) $repoUrl = $argv[$i + 1]; - if ($arg === '--dry-run') $dryRun = true; -} +use MokoEnterprise\CliFramework; -if (empty($stability) || empty($token)) { - fwrite(STDERR, "Usage: release_publish.php --stability --token TOKEN [options]\n"); - exit(1); -} - -$cli = __DIR__; -$php = '"' . PHP_BINARY . '"'; -$giteaUrl = rtrim($giteaUrl, '/'); - -// Resolve path early for shell commands (Windows needs native paths) -$resolvedPath = realpath($path) ?: $path; - -// Auto-detect org/repo from git remote if not set -if (empty($org) || empty($repo)) { - $remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git remote get-url origin 2>/dev/null")); - if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) { - if (empty($org)) $org = $m[1]; - if (empty($repo)) $repo = $m[2]; - } -} - -// Auto-construct repo URL for git auth if not provided -if (empty($repoUrl) && !empty($token) && !empty($org) && !empty($repo)) { - $host = preg_replace('#^https?://#', '', $giteaUrl); - $repoUrl = "https://x-access-token:{$token}@{$host}/{$org}/{$repo}.git"; -} - -// Auto-detect branch -if (empty($branch)) { - $branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null")); -} - -$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}"; - -// Stability ordering and suffix mapping -$allStabilities = ['dev', 'alpha', 'beta', 'rc', 'stable']; -$suffixMap = [ - 'dev' => '-dev', - 'alpha' => '-alpha', - 'beta' => '-beta', - 'rc' => '-rc', - 'stable' => '', -]; -$releaseTagMap = [ - 'dev' => 'development', - 'alpha' => 'alpha', - 'beta' => 'beta', - 'rc' => 'release-candidate', - 'stable' => 'stable', -]; - -$stabilityIndex = array_search($stability, $allStabilities); -if ($stabilityIndex === false) { - fwrite(STDERR, "Invalid stability: {$stability}\n"); - exit(1); -} - -echo "=== Release Publish ===\n"; -echo "Stability: {$stability} | Bump: {$bumpType} | Branch: {$branch}\n"; -echo "Repo: {$org}/{$repo}\n"; - -// -- Step 1: Version bump (if requested) -- -if ($bumpType !== 'none') { - $bumpFlag = $bumpType === 'minor' ? '--minor' : ''; - echo "\n--- Step 1: Version bump ({$bumpType}) ---\n"; - if (!$dryRun) { - passthru("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " {$bumpFlag} 2>&1"); - } else { - echo "[DRY-RUN] Would run version_bump.php {$bumpFlag}\n"; - } -} - -// -- Step 2: Read version and set stability suffix -- -echo "\n--- Step 2: Set version suffix ---\n"; -$versionOutput = []; -$devNull = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null'; -exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($resolvedPath) . " {$devNull}", $versionOutput); -$version = trim($versionOutput[0] ?? ''); -if (empty($version)) { - fwrite(STDERR, "No version found\n"); - exit(1); -} -// Strip existing suffix to get base version -$baseVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version); - -if (!$dryRun) { - passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) - . " --version " . escapeshellarg($baseVersion) - . " --branch " . escapeshellarg($branch) - . " --stability " . escapeshellarg($stability) . " 2>&1"); - passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null"); -} - -$releaseVersion = $baseVersion . $suffixMap[$stability]; -echo "Release version: {$releaseVersion}\n"; - -// -- Step 2b: Update badges and changelog -- -if (!$dryRun) { - passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); - - $changelogFile = realpath($path) . '/CHANGELOG.md'; - if (file_exists($changelogFile)) { - passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); - passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null"); - } -} - -// -- Step 2c: Commit version changes before building -- -$root = realpath($path) ?: $path; -if (!$dryRun) { - // Configure git - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\""); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\""); - if (!empty($repoUrl)) { - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl)); +class ReleasePublishCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Publish a release and update stability streams'); + $this->addArgument('--path', 'Repository root (default: .)', '.'); + $this->addArgument('--stability', 'Target stability: dev|alpha|beta|rc|stable (required)', ''); + $this->addArgument('--token', 'Gitea API token (required)', ''); + $this->addArgument('--bump', 'Version bump type: patch|minor|none (default: none)', 'none'); + $this->addArgument('--branch', 'Current branch (default: auto-detect)', ''); + $this->addArgument('--gitea-url', 'Gitea URL', ''); + $this->addArgument('--org', 'Organization', ''); + $this->addArgument('--repo', 'Repository name', ''); + $this->addArgument('--repo-url', 'Repository URL for git auth', ''); } - // Ensure we're on the actual branch (not detached HEAD from PR merge) - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git fetch origin " . escapeshellarg($branch) . " 2>/dev/null"); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"); + protected function run(): int + { + $path = $this->getArgument('--path'); + $stability = $this->getArgument('--stability'); + $token = $this->getArgument('--token'); + $bumpType = $this->getArgument('--bump'); + $branch = $this->getArgument('--branch'); + $giteaUrl = $this->getArgument('--gitea-url') ?: (getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'); + $org = $this->getArgument('--org') ?: (getenv('GITEA_ORG') ?: ''); + $repo = $this->getArgument('--repo') ?: (getenv('GITEA_REPO') ?: ''); + $repoUrl = $this->getArgument('--repo-url'); - // Re-apply version changes on the checked-out branch - passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) - . " --version " . escapeshellarg($baseVersion) - . " --branch " . escapeshellarg($branch) - . " --stability " . escapeshellarg($stability) . " 2>/dev/null"); - passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null"); - passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); - - $diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty")); - if ($diffCheck === 'dirty') { - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A"); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]") - . " --author=\"gitea-actions[bot] \""); - $pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); - echo " Committed release changes\n"; - echo " Push: " . trim($pushResult ?? '') . "\n"; - } -} - -// -- Step 3: Build release package -- -echo "\n--- Step 3: Build and upload release ---\n"; -$releaseTag = $releaseTagMap[$stability]; -$sha256 = ''; - -if (!$dryRun) { - // Create release - passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path) - . " --version " . escapeshellarg($releaseVersion) - . " --tag " . escapeshellarg($releaseTag) - . " --token " . escapeshellarg($token) - . " --api-base " . escapeshellarg($apiBase) - . " --repo " . escapeshellarg($repo) - . " --branch " . escapeshellarg($branch) . " 2>&1"); - - // Build and upload package - $packageOutput = []; - exec("{$php} {$cli}/release_package.php --path " . escapeshellarg($path) - . " --version " . escapeshellarg($releaseVersion) - . " --tag " . escapeshellarg($releaseTag) - . " --token " . escapeshellarg($token) - . " --api-base " . escapeshellarg($apiBase) - . " --repo " . escapeshellarg($repo) - . " --output /tmp 2>&1", $packageOutput); - foreach ($packageOutput as $line) { - echo $line . "\n"; - // Extract SHA from output - if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $line, $m)) { - $sha256 = $m[1]; + if (empty($stability) || empty($token)) { + $this->log('ERROR', "Usage: release_publish.php --stability --token TOKEN [options]"); + return 1; } - } - // Also check GITHUB_OUTPUT - $ghOutput = getenv('GITHUB_OUTPUT'); - if ($ghOutput && file_exists($ghOutput)) { - $ghContent = file_get_contents($ghOutput); - if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $ghContent, $m)) { - $sha256 = $m[1]; + + $cli = __DIR__; + $php = '"' . PHP_BINARY . '"'; + $giteaUrl = rtrim($giteaUrl, '/'); + + // Resolve path early for shell commands (Windows needs native paths) + $resolvedPath = realpath($path) ?: $path; + + // Auto-detect org/repo from git remote if not set + if (empty($org) || empty($repo)) { + $remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git remote get-url origin 2>/dev/null")); + if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) { + if (empty($org)) $org = $m[1]; + if (empty($repo)) $repo = $m[2]; + } } - } -} else { - echo "[DRY-RUN] Would build and upload {$releaseVersion} to {$releaseTag}\n"; -} -// -- Step 4: No lesser stream copies -- -// Joomla picks the HIGHEST version across ALL entries via version_compare(). -// Since dev < alpha < beta < rc < stable, lesser stream copies at the same -// base version would never be selected by sites at lower stability levels. -// Each stream updates independently: dev via auto-bump, rc via promote-rc, -// stable via release. The dev stream naturally gets a higher patch after -// auto-bump runs on the recreated dev branch (e.g. 02.17.01-dev > 02.17.00). -echo "\n--- Step 4: Skipped (no lesser stream copies) ---\n"; + // Auto-construct repo URL for git auth if not provided + if (empty($repoUrl) && !empty($token) && !empty($org) && !empty($repo)) { + $host = preg_replace('#^https?://#', '', $giteaUrl); + $repoUrl = "https://x-access-token:{$token}@{$host}/{$org}/{$repo}.git"; + } -// -- Step 5: Update ONLY this stream in updates.xml -- -echo "\n--- Step 5: Update {$stability} stream in updates.xml ---\n"; -$streamsToWrite = [$stability]; + // Auto-detect branch + if (empty($branch)) { + $branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null")); + } -foreach ($streamsToWrite as $stream) { - $streamVersion = $releaseVersion; - $shaFlag = !empty($sha256) ? "--sha {$sha256}" : ''; + $apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}"; - echo " Writing {$stream} stream: {$streamVersion}\n"; - if (!$dryRun) { - passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path) - . " --version " . escapeshellarg($streamVersion) - . " --stability " . escapeshellarg($stream) - . " --gitea-url " . escapeshellarg($giteaUrl) - . " --org " . escapeshellarg($org) - . " --repo " . escapeshellarg($repo) - . " {$shaFlag} 2>&1"); + // Stability ordering and suffix mapping + $allStabilities = ['dev', 'alpha', 'beta', 'rc', 'stable']; + $suffixMap = [ + 'dev' => '-dev', + 'alpha' => '-alpha', + 'beta' => '-beta', + 'rc' => '-rc', + 'stable' => '', + ]; + $releaseTagMap = [ + 'dev' => 'development', + 'alpha' => 'alpha', + 'beta' => 'beta', + 'rc' => 'release-candidate', + 'stable' => 'stable', + ]; + + $stabilityIndex = array_search($stability, $allStabilities); + if ($stabilityIndex === false) { + $this->log('ERROR', "Invalid stability: {$stability}"); + return 1; + } + + echo "=== Release Publish ===\n"; + echo "Stability: {$stability} | Bump: {$bumpType} | Branch: {$branch}\n"; + echo "Repo: {$org}/{$repo}\n"; + + // -- Step 1: Version bump (if requested) -- + if ($bumpType !== 'none') { + $bumpFlag = $bumpType === 'minor' ? '--minor' : ''; + echo "\n--- Step 1: Version bump ({$bumpType}) ---\n"; + if (!$this->dryRun) { + passthru("{$php} {$cli}/version_bump.php --path " . escapeshellarg($path) . " {$bumpFlag} 2>&1"); + } else { + echo "[DRY-RUN] Would run version_bump.php {$bumpFlag}\n"; + } + } + + // -- Step 2: Read version and set stability suffix -- + echo "\n--- Step 2: Set version suffix ---\n"; + $versionOutput = []; + $devNull = PHP_OS_FAMILY === 'Windows' ? '2>NUL' : '2>/dev/null'; + exec("{$php} {$cli}/version_read.php --path " . escapeshellarg($resolvedPath) . " {$devNull}", $versionOutput); + $version = trim($versionOutput[0] ?? ''); + if (empty($version)) { + $this->log('ERROR', 'No version found'); + return 1; + } + // Strip existing suffix to get base version + $baseVersion = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $version); + + if (!$this->dryRun) { + passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) + . " --version " . escapeshellarg($baseVersion) + . " --branch " . escapeshellarg($branch) + . " --stability " . escapeshellarg($stability) . " 2>&1"); + passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null"); + } + + $releaseVersion = $baseVersion . $suffixMap[$stability]; + echo "Release version: {$releaseVersion}\n"; + + // -- Step 2b: Update badges and changelog -- + if (!$this->dryRun) { + passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); + + $changelogFile = realpath($path) . '/CHANGELOG.md'; + if (file_exists($changelogFile)) { + passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); + passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null"); + } + } + + // -- Step 2c: Commit version changes before building -- + $root = realpath($path) ?: $path; + if (!$this->dryRun) { + // Configure git + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\""); + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\""); + if (!empty($repoUrl)) { + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl)); + } + + // Ensure we're on the actual branch (not detached HEAD from PR merge) + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git fetch origin " . escapeshellarg($branch) . " 2>/dev/null"); + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"); + + // Re-apply version changes on the checked-out branch + passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) + . " --version " . escapeshellarg($baseVersion) + . " --branch " . escapeshellarg($branch) + . " --stability " . escapeshellarg($stability) . " 2>/dev/null"); + passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null"); + passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); + + $diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty")); + if ($diffCheck === 'dirty') { + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A"); + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]") + . " --author=\"gitea-actions[bot] \""); + $pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); + echo " Committed release changes\n"; + echo " Push: " . trim($pushResult ?? '') . "\n"; + } + } + + // -- Step 3: Build release package -- + echo "\n--- Step 3: Build and upload release ---\n"; + $releaseTag = $releaseTagMap[$stability]; + $sha256 = ''; + + if (!$this->dryRun) { + // Create release + passthru("{$php} {$cli}/release_create.php --path " . escapeshellarg($path) + . " --version " . escapeshellarg($releaseVersion) + . " --tag " . escapeshellarg($releaseTag) + . " --token " . escapeshellarg($token) + . " --api-base " . escapeshellarg($apiBase) + . " --repo " . escapeshellarg($repo) + . " --branch " . escapeshellarg($branch) . " 2>&1"); + + // Build and upload package + $packageOutput = []; + exec("{$php} {$cli}/release_package.php --path " . escapeshellarg($path) + . " --version " . escapeshellarg($releaseVersion) + . " --tag " . escapeshellarg($releaseTag) + . " --token " . escapeshellarg($token) + . " --api-base " . escapeshellarg($apiBase) + . " --repo " . escapeshellarg($repo) + . " --output /tmp 2>&1", $packageOutput); + foreach ($packageOutput as $line) { + echo $line . "\n"; + // Extract SHA from output + if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $line, $m)) { + $sha256 = $m[1]; + } + } + // Also check GITHUB_OUTPUT + $ghOutput = getenv('GITHUB_OUTPUT'); + if ($ghOutput && file_exists($ghOutput)) { + $ghContent = file_get_contents($ghOutput); + if (preg_match('/sha256_zip=([a-f0-9]{64})/i', $ghContent, $m)) { + $sha256 = $m[1]; + } + } + } else { + echo "[DRY-RUN] Would build and upload {$releaseVersion} to {$releaseTag}\n"; + } + + // -- Step 4: No lesser stream copies -- + echo "\n--- Step 4: Skipped (no lesser stream copies) ---\n"; + + // -- Step 5: Update ONLY this stream in updates.xml -- + echo "\n--- Step 5: Update {$stability} stream in updates.xml ---\n"; + $streamsToWrite = [$stability]; + + foreach ($streamsToWrite as $stream) { + $streamVersion = $releaseVersion; + $shaFlag = !empty($sha256) ? "--sha {$sha256}" : ''; + + echo " Writing {$stream} stream: {$streamVersion}\n"; + if (!$this->dryRun) { + passthru("{$php} {$cli}/updates_xml_build.php --path " . escapeshellarg($path) + . " --version " . escapeshellarg($streamVersion) + . " --stability " . escapeshellarg($stream) + . " --gitea-url " . escapeshellarg($giteaUrl) + . " --org " . escapeshellarg($org) + . " --repo " . escapeshellarg($repo) + . " {$shaFlag} 2>&1"); + } + } + + // -- Step 6: Commit updates.xml and sync to all branches -- + echo "\n--- Step 6: Commit and sync updates.xml ---\n"; + $root = realpath($path) ?: $path; + + if (!$this->dryRun) { + $diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet updates.xml 2>&1 && echo clean || echo dirty")); + if ($diffCheck === 'dirty') { + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml"); + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore: update channels for {$releaseVersion} [skip ci]") + . " --author=\"gitea-actions[bot] \""); + @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); + echo " Committed updates.xml\n"; + } + + // Sync to all branches + passthru("{$php} {$cli}/updates_xml_sync.php --path " . escapeshellarg($path) + . " --current " . escapeshellarg($branch) . " --all" + . " --version " . escapeshellarg($releaseVersion) + . " --token " . escapeshellarg($token) + . " --gitea-url " . escapeshellarg($giteaUrl) + . " --org " . escapeshellarg($org) + . " --repo " . escapeshellarg($repo) . " 2>&1"); + } else { + echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n"; + } + + echo "\n=== Release published: {$releaseVersion} ===\n"; + + // Output for CI + $ghOutput = getenv('GITHUB_OUTPUT'); + if ($ghOutput) { + file_put_contents($ghOutput, "version={$releaseVersion}\nbase_version={$baseVersion}\n", FILE_APPEND); + } + + return 0; } } -// -- Step 6: Commit updates.xml and sync to all branches -- -echo "\n--- Step 6: Commit and sync updates.xml ---\n"; -$root = realpath($path) ?: $path; - -if (!$dryRun) { - $diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet updates.xml 2>&1 && echo clean || echo dirty")); - if ($diffCheck === 'dirty') { - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml"); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore: update channels for {$releaseVersion} [skip ci]") - . " --author=\"gitea-actions[bot] \""); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); - echo " Committed updates.xml\n"; - } - - // Sync to all branches - passthru("{$php} {$cli}/updates_xml_sync.php --path " . escapeshellarg($path) - . " --current " . escapeshellarg($branch) . " --all" - . " --version " . escapeshellarg($releaseVersion) - . " --token " . escapeshellarg($token) - . " --gitea-url " . escapeshellarg($giteaUrl) - . " --org " . escapeshellarg($org) - . " --repo " . escapeshellarg($repo) . " 2>&1"); -} else { - echo "[DRY-RUN] Would commit updates.xml and sync to all branches\n"; -} - -echo "\n=== Release published: {$releaseVersion} ===\n"; - -// Output for CI -$ghOutput = getenv('GITHUB_OUTPUT'); -if ($ghOutput) { - file_put_contents($ghOutput, "version={$releaseVersion}\nbase_version={$baseVersion}\n", FILE_APPEND); -} - -exit(0); +$app = new ReleasePublishCli(); +exit($app->execute()); diff --git a/cli/release_validate.php b/cli/release_validate.php index e758aa2..234cf68 100644 --- a/cli/release_validate.php +++ b/cli/release_validate.php @@ -10,248 +10,82 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_validate.php - * BRIEF: Pre-release validation — version consistency, required files, manifest checks - * - * Usage: - * php release_validate.php --path /repo --version 04.01.00 - * php release_validate.php --path /repo --version 04.01.00 --platform joomla --output-summary - * - * Options: - * --path Repository root (default: .) - * --version Expected version string (required) - * --platform joomla|dolibarr|generic (default: joomla) - * --output-summary Write markdown table to $GITHUB_STEP_SUMMARY + * BRIEF: Pre-release validation -- version consistency, required files, manifest checks */ declare(strict_types=1); -$path = '.'; -$version = null; -$platform = null; -$outputSummary = false; -$githubOutput = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) { - $path = $argv[$i + 1]; - } - if ($arg === '--version' && isset($argv[$i + 1])) { - $version = $argv[$i + 1]; - } - if ($arg === '--platform' && isset($argv[$i + 1])) { - $platform = $argv[$i + 1]; - } - if ($arg === '--output-summary') { - $outputSummary = true; - } - if ($arg === '--github-output') { - $githubOutput = true; - } -} +use MokoEnterprise\CliFramework; -if ($version === null) { - fwrite(STDERR, "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]\n"); - exit(1); -} - -$root = realpath($path) ?: $path; - -// Auto-detect platform from manifest.xml if not specified -if ($platform === null) { - $manifestXml = "{$root}/.mokogitea/manifest.xml"; - if (file_exists($manifestXml)) { - $mContent = file_get_contents($manifestXml); - if (preg_match('/([^<]+)<\/platform>/', $mContent, $pm)) { - $platform = trim($pm[1]); - } - } - // Normalize platform aliases - if (in_array($platform, ['waas-component'], true)) { - $platform = 'joomla'; - } - if (in_array($platform, ['crm-module'], true)) { - $platform = 'dolibarr'; - } - if ($platform === null) { - $platform = 'generic'; - } -} - -$pass = 0; -$fail = 0; -$warn = 0; -/** @var array */ -$results = []; - -/** - * Record a validation result. - * - * @param string $check Check name - * @param string $status PASS, FAIL, or WARN - * @param string $details Human-readable details - */ -function addResult(string $check, string $status, string $details): void +class ReleaseValidateCli extends CliFramework { - global $pass, $fail, $warn, $results; - $results[] = ['check' => $check, 'status' => $status, 'details' => $details]; - if ($status === 'PASS') { - $pass++; - } elseif ($status === 'FAIL') { - $fail++; - } elseif ($status === 'WARN') { - $warn++; - } + private int $pass = 0; + private int $fail = 0; + private int $warn = 0; + private array $results = []; + + protected function configure(): void + { + $this->setDescription('Pre-release validation -- version consistency, required files, manifest checks'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--version', 'Expected version string', null); + $this->addArgument('--platform', 'joomla|dolibarr|generic', null); + $this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false); + $this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); $version = $this->getArgument('--version'); + $platform = $this->getArgument('--platform'); + $outputSummary = (bool) $this->getArgument('--output-summary'); + $githubOutput = (bool) $this->getArgument('--github-output'); + if ($version === null) { $this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]"); return 1; } + $root = realpath($path) ?: $path; + if ($platform === null) { + $manifestXml = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($manifestXml)) { $mContent = file_get_contents($manifestXml); if (preg_match('/([^<]+)<\/platform>/', $mContent, $pm)) { $platform = trim($pm[1]); } } + if (in_array($platform, ['waas-component'], true)) { $platform = 'joomla'; } + if (in_array($platform, ['crm-module'], true)) { $platform = 'dolibarr'; } + if ($platform === null) { $platform = 'generic'; } + } + $hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs"); + $this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory'); + if (!file_exists("{$root}/README.md")) { $this->addVResult('README.md', 'FAIL', 'Not found'); } + else { $readme = file_get_contents("{$root}/README.md"); $this->addVResult('README.md version', (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || strpos($readme, $version) !== false) ? 'PASS' : 'FAIL', (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || strpos($readme, $version) !== false) ? "`{$version}` found" : "`{$version}` not found"); } + if (!file_exists("{$root}/CHANGELOG.md")) { $this->addVResult('CHANGELOG.md', 'WARN', 'Not found'); } + else { $cl = file_get_contents("{$root}/CHANGELOG.md"); $this->addVResult('CHANGELOG.md version', preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl) ? 'PASS' : 'WARN', preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl) ? "Section found" : "No section header"); } + $licenseFound = false; foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) { if (file_exists("{$root}/{$lf}")) { $licenseFound = true; break; } } + $this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); + if ($platform === 'joomla') { + $manifest = null; foreach (["{$root}/src", $root] as $dir) { if (!is_dir($dir)) continue; foreach (glob("{$dir}/*.xml") as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, 'addVResult('XML manifest', 'FAIL', 'No Joomla manifest found'); } + else { if (preg_match('/([^<]+)<\/version>/', file_get_contents($manifest), $m)) { $mVer = trim($m[1]); $this->addVResult('Manifest version', $mVer === $version ? 'PASS' : 'FAIL', $mVer === $version ? "`{$mVer}` matches" : "`{$mVer}` != `{$version}`"); } else { $this->addVResult('Manifest version', 'FAIL', 'No tag'); } } + if (!file_exists("{$root}/updates.xml")) { $this->addVResult('updates.xml', 'WARN', 'Not found'); } + else { $ux = file_get_contents("{$root}/updates.xml"); $this->addVResult('updates.xml version', preg_match('/' . preg_quote($version, '/') . '<\/version>/', $ux) ? 'PASS' : 'FAIL', preg_match('/' . preg_quote($version, '/') . '<\/version>/', $ux) ? "`{$version}` found" : "`{$version}` not found"); } + } elseif ($platform === 'dolibarr') { + $modFile = null; foreach (['src', 'htdocs'] as $sd) { $matches = glob("{$root}/{$sd}/mod*.class.php"); if (!empty($matches)) { $modFile = $matches[0]; break; } } + if ($modFile === null) { $this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found'); } + else { $mc = file_get_contents($modFile); $this->addVResult('Dolibarr version', preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc) ? 'PASS' : 'FAIL', preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc) ? "`{$version}` matches" : "`{$version}` not found"); } + } + if (file_exists("{$root}/composer.json")) { $composer = json_decode(file_get_contents("{$root}/composer.json"), true); if (isset($composer['version'])) { $this->addVResult('composer.json version', $composer['version'] === $version ? 'PASS' : 'WARN', $composer['version'] === $version ? "`{$version}` matches" : "`{$composer['version']}` != `{$version}`"); } } + $table = "| Check | Result | Details |\n|-------|--------|--------|\n"; + foreach ($this->results as $r) { $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; } + $table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n"; + echo $table; + if ($outputSummary) { $summaryFile = getenv('GITHUB_STEP_SUMMARY'); if ($summaryFile) { file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND); } } + if ($githubOutput) { $ghOutput = getenv('GITHUB_OUTPUT'); if ($ghOutput) { file_put_contents($ghOutput, "validation_pass={$this->pass}\nvalidation_fail={$this->fail}\nvalidation_warn={$this->warn}\nvalidation_platform={$platform}\n", FILE_APPEND); } } + return $this->fail > 0 ? 1 : 0; + } + + private function addVResult(string $check, string $status, string $details): void + { + $this->results[] = ['check' => $check, 'status' => $status, 'details' => $details]; + if ($status === 'PASS') { $this->pass++; } elseif ($status === 'FAIL') { $this->fail++; } elseif ($status === 'WARN') { $this->warn++; } + } } -// 0. Source directory check -$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs"); -if ($hasSource) { - addResult('Source directory', 'PASS', 'src/ or htdocs/ found'); -} else { - addResult('Source directory', 'WARN', 'No src/ or htdocs/ directory'); -} - -// 1. README.md exists and contains VERSION -if (!file_exists("{$root}/README.md")) { - addResult('README.md', 'FAIL', 'Not found'); -} else { - $readme = file_get_contents("{$root}/README.md"); - if ( - preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || - strpos($readme, $version) !== false - ) { - addResult('README.md version', 'PASS', "`{$version}` found"); - } else { - addResult('README.md version', 'FAIL', "`{$version}` not found in README.md"); - } -} - -// 2. CHANGELOG.md exists with matching section -if (!file_exists("{$root}/CHANGELOG.md")) { - addResult('CHANGELOG.md', 'WARN', 'Not found'); -} else { - $cl = file_get_contents("{$root}/CHANGELOG.md"); - if (preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl)) { - addResult('CHANGELOG.md version', 'PASS', "Section for `{$version}` found"); - } else { - addResult('CHANGELOG.md version', 'WARN', "No section header for `{$version}`"); - } -} - -// 3. LICENSE file exists -$licenseFound = false; -foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) { - if (file_exists("{$root}/{$lf}")) { - $licenseFound = true; - break; - } -} -addResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); - -// 4. Platform-specific checks -if ($platform === 'joomla') { - // Find XML manifest - $manifest = null; - $searchDirs = ["{$root}/src", $root]; - foreach ($searchDirs as $dir) { - if (!is_dir($dir)) { - continue; - } - foreach (glob("{$dir}/*.xml") as $xmlFile) { - $content = file_get_contents($xmlFile); - if (strpos($content, '([^<]+)<\/version>/', file_get_contents($manifest), $m)) { - $mVer = trim($m[1]); - if ($mVer === $version) { - addResult('Manifest version', 'PASS', "`{$mVer}` matches"); - } else { - addResult('Manifest version', 'FAIL', "`{$mVer}` != `{$version}`"); - } - } else { - addResult('Manifest version', 'FAIL', 'No tag in manifest'); - } - } - - // updates.xml - if (!file_exists("{$root}/updates.xml")) { - addResult('updates.xml', 'WARN', 'Not found'); - } else { - $ux = file_get_contents("{$root}/updates.xml"); - if (preg_match('/' . preg_quote($version, '/') . '<\/version>/', $ux)) { - addResult('updates.xml version', 'PASS', "`{$version}` found"); - } else { - addResult('updates.xml version', 'FAIL', "`{$version}` not in updates.xml"); - } - } -} elseif ($platform === 'dolibarr') { - $modFile = null; - foreach (['src', 'htdocs'] as $sd) { - $pattern = "{$root}/{$sd}/mod*.class.php"; - $matches = glob($pattern); - if (!empty($matches)) { - $modFile = $matches[0]; - break; - } - } - if ($modFile === null) { - addResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found'); - } else { - $mc = file_get_contents($modFile); - if (preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc)) { - addResult('Dolibarr version', 'PASS', "`{$version}` matches"); - } else { - addResult('Dolibarr version', 'FAIL', "`{$version}` not found in " . basename($modFile)); - } - } -} - -// 5. composer.json version (if present) -if (file_exists("{$root}/composer.json")) { - $composer = json_decode(file_get_contents("{$root}/composer.json"), true); - if (isset($composer['version'])) { - if ($composer['version'] === $version) { - addResult('composer.json version', 'PASS', "`{$version}` matches"); - } else { - addResult('composer.json version', 'WARN', "`{$composer['version']}` != `{$version}`"); - } - } -} - -// Output -$table = "| Check | Result | Details |\n|-------|--------|--------|\n"; -foreach ($results as $r) { - $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; -} -$table .= "\n**Validation: {$pass} passed, {$fail} failed, {$warn} warnings**\n"; - -echo $table; - -if ($outputSummary) { - $summaryFile = getenv('GITHUB_STEP_SUMMARY'); - if ($summaryFile) { - file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND); - } -} - -if ($githubOutput) { - $ghOutput = getenv('GITHUB_OUTPUT'); - $lines = [ - "validation_pass={$pass}", - "validation_fail={$fail}", - "validation_warn={$warn}", - "validation_platform={$platform}", - ]; - if ($ghOutput) { - file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); - } -} - -exit($fail > 0 ? 1 : 0); +$app = new ReleaseValidateCli(); +exit($app->execute()); diff --git a/cli/release_verify.php b/cli/release_verify.php index 7fe0363..59752b8 100644 --- a/cli/release_verify.php +++ b/cli/release_verify.php @@ -10,179 +10,187 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/release_verify.php * BRIEF: Verify a built release artifact — version, SHA256, disallowed files - * - * Usage: - * php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 - * php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --updates-xml updates.xml - * php release_verify.php --zip-path /tmp/pkg.zip --version 04.01.00 --output-summary - * - * Options: - * --zip-path Path to ZIP file (required) - * --version Expected version string (required) - * --platform joomla|dolibarr|generic (default: joomla) - * --updates-xml Path to updates.xml for SHA256 comparison - * --github-output Export verify_pass, verify_fail to $GITHUB_OUTPUT - * --output-summary Write markdown table to $GITHUB_STEP_SUMMARY */ declare(strict_types=1); -$zipPath = null; -$version = null; -$platform = 'joomla'; -$updatesXml = null; -$githubOutput = false; -$outputSummary = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--zip-path' && isset($argv[$i + 1])) $zipPath = $argv[$i + 1]; - if ($arg === '--version' && isset($argv[$i + 1])) $version = $argv[$i + 1]; - if ($arg === '--platform' && isset($argv[$i + 1])) $platform = $argv[$i + 1]; - if ($arg === '--updates-xml' && isset($argv[$i + 1])) $updatesXml = $argv[$i + 1]; - if ($arg === '--github-output') $githubOutput = true; - if ($arg === '--output-summary') $outputSummary = true; +use MokoEnterprise\CliFramework; + +class ReleaseVerifyCli extends CliFramework +{ + private int $pass = 0; + private int $fail = 0; + private int $warn = 0; + private array $results = []; + + protected function configure(): void + { + $this->setDescription('Verify a built release artifact — version, SHA256, disallowed files'); + $this->addArgument('--zip-path', 'Path to ZIP file (required)', ''); + $this->addArgument('--version', 'Expected version string (required)', ''); + $this->addArgument('--platform', 'joomla|dolibarr|generic', 'joomla'); + $this->addArgument('--updates-xml', 'Path to updates.xml for SHA256 comparison', ''); + $this->addArgument('--github-output', 'Export verify_pass, verify_fail to $GITHUB_OUTPUT', false); + $this->addArgument('--output-summary', 'Write markdown table to $GITHUB_STEP_SUMMARY', false); + } + + protected function run(): int + { + $zipPath = $this->getArgument('--zip-path'); + $version = $this->getArgument('--version'); + $platform = $this->getArgument('--platform'); + $updatesXml = $this->getArgument('--updates-xml'); + $githubOutput = $this->getArgument('--github-output'); + $outputSummary = $this->getArgument('--output-summary'); + + if ($zipPath === '' || $version === '') { + $this->log('ERROR', 'Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]'); + return 1; + } + + // 1. ZIP exists and is readable + if (!file_exists($zipPath) || !is_readable($zipPath)) { + $this->addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}"); + } else { + $this->addResult('ZIP exists', 'PASS', basename($zipPath)); + + // 2. Extract ZIP + $tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid(); + mkdir($tmpDir, 0755, true); + + $zip = new \ZipArchive(); + if ($zip->open($zipPath) !== true) { + $this->addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file'); + } else { + $zip->extractTo($tmpDir); + $zip->close(); + $this->addResult('ZIP extract', 'PASS', 'Extracted successfully'); + + // 3. Manifest version check (Joomla) + if ($platform === 'joomla') { + $manifest = null; + foreach (glob("{$tmpDir}/*.xml") as $xmlFile) { + $content = file_get_contents($xmlFile); + if (strpos($content, '([^<]+)<\/version>/', file_get_contents($manifest), $m)) { + $manifestVer = trim($m[1]); + if ($manifestVer === $version) { + $this->addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release"); + } else { + $this->addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`"); + } + } else { + $this->addResult('Manifest version', 'WARN', 'No tag in manifest'); + } + } else { + $this->addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP'); + } + } + + // 4. SHA256 vs updates.xml + $zipSha = hash_file('sha256', $zipPath); + if ($updatesXml !== '' && file_exists($updatesXml)) { + $uxContent = file_get_contents($updatesXml); + if (preg_match('/([^<]+)<\/sha256>/', $uxContent, $m)) { + $expectedSha = trim($m[1]); + if ($zipSha === $expectedSha) { + $this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`'); + } else { + $this->addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`"); + } + } else { + $this->addResult('SHA256 vs updates.xml', 'WARN', 'No in updates.xml'); + } + } + + // 5. Disallowed files + $disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env']; + $found = []; + $rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($rit as $file) { + $name = $file->getFilename(); + if (in_array($name, $disallowed, true)) { + $found[] = $name; + } + } + if (count($found) > 0) { + $this->addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found))); + } else { + $this->addResult('Disallowed files', 'PASS', 'None found'); + } + + // 6. Non-vendor .min files + $minCount = 0; + $rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($rit as $file) { + $rel = str_replace($tmpDir . '/', '', $file->getPathname()); + if (strpos($rel, 'vendor/') !== false) { + continue; + } + if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) { + $minCount++; + } + } + if ($minCount > 0) { + $this->addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime"); + } else { + $this->addResult('Non-vendor .min files', 'PASS', 'None shipped'); + } + + // Clean up + $rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($rit as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($tmpDir); + } + } + + // Output + $table = "| Check | Result | Details |\n|-------|--------|--------|\n"; + foreach ($this->results as $r) { + $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; + } + $table .= "\n**Verification: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n"; + + echo $table; + + if ($outputSummary) { + $summaryFile = getenv('GITHUB_STEP_SUMMARY'); + if ($summaryFile) { + file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND); + } + } + + if ($githubOutput) { + $outputFile = getenv('GITHUB_OUTPUT'); + if ($outputFile) { + file_put_contents($outputFile, "verify_pass={$this->pass}\nverify_fail={$this->fail}\nverify_warn={$this->warn}\n", FILE_APPEND); + } + } + + return $this->fail > 0 ? 1 : 0; + } + + private function addResult(string $check, string $status, string $details): void + { + $this->results[] = ['check' => $check, 'status' => $status, 'details' => $details]; + if ($status === 'PASS') { + $this->pass++; + } elseif ($status === 'FAIL') { + $this->fail++; + } elseif ($status === 'WARN') { + $this->warn++; + } + } } -if ($zipPath === null || $version === null) { - fwrite(STDERR, "Usage: release_verify.php --zip-path FILE --version XX.YY.ZZ [--platform joomla] [--updates-xml FILE]\n"); - exit(1); -} - -$pass = 0; -$fail = 0; -$warn = 0; -$results = []; - -function addResult(string $check, string $status, string $details): void { - global $pass, $fail, $warn, $results; - $results[] = ['check' => $check, 'status' => $status, 'details' => $details]; - if ($status === 'PASS') $pass++; - elseif ($status === 'FAIL') $fail++; - elseif ($status === 'WARN') $warn++; -} - -// 1. ZIP exists and is readable -if (!file_exists($zipPath) || !is_readable($zipPath)) { - addResult('ZIP exists', 'FAIL', "Not found or not readable: {$zipPath}"); -} else { - addResult('ZIP exists', 'PASS', basename($zipPath)); - - // 2. Extract ZIP - $tmpDir = sys_get_temp_dir() . '/release-verify-' . uniqid(); - mkdir($tmpDir, 0755, true); - - $zip = new ZipArchive(); - if ($zip->open($zipPath) !== true) { - addResult('ZIP extract', 'FAIL', 'ZipArchive could not open file'); - } else { - $zip->extractTo($tmpDir); - $zip->close(); - addResult('ZIP extract', 'PASS', 'Extracted successfully'); - - // 3. Manifest version check (Joomla) - if ($platform === 'joomla') { - $manifest = null; - foreach (glob("{$tmpDir}/*.xml") as $xmlFile) { - $content = file_get_contents($xmlFile); - if (strpos($content, '([^<]+)<\/version>/', file_get_contents($manifest), $m)) { - $manifestVer = trim($m[1]); - if ($manifestVer === $version) { - addResult('Manifest version', 'PASS', "`{$manifestVer}` matches release"); - } else { - addResult('Manifest version', 'FAIL', "`{$manifestVer}` != `{$version}`"); - } - } else { - addResult('Manifest version', 'WARN', 'No tag in manifest'); - } - } else { - addResult('Manifest version', 'WARN', 'No XML manifest found in ZIP'); - } - } - - // 4. SHA256 vs updates.xml - $zipSha = hash_file('sha256', $zipPath); - if ($updatesXml !== null && file_exists($updatesXml)) { - $uxContent = file_get_contents($updatesXml); - if (preg_match('/([^<]+)<\/sha256>/', $uxContent, $m)) { - $expectedSha = trim($m[1]); - if ($zipSha === $expectedSha) { - addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`'); - } else { - addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`"); - } - } else { - addResult('SHA256 vs updates.xml', 'WARN', 'No in updates.xml'); - } - } - - // 5. Disallowed files - $disallowed = ['.claude', '.mcp.json', 'TODO.md', 'todo.md', '.git', 'node_modules', '.env']; - $found = []; - $rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS)); - foreach ($rit as $file) { - $name = $file->getFilename(); - if (in_array($name, $disallowed, true)) { - $found[] = $name; - } - } - if (count($found) > 0) { - addResult('Disallowed files', 'FAIL', 'Found: ' . implode(', ', array_unique($found))); - } else { - addResult('Disallowed files', 'PASS', 'None found'); - } - - // 6. Non-vendor .min files - $minCount = 0; - $rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS)); - foreach ($rit as $file) { - $rel = str_replace($tmpDir . '/', '', $file->getPathname()); - if (strpos($rel, 'vendor/') !== false) continue; - if (preg_match('/\.(min\.css|min\.js)$/', $file->getFilename())) { - $minCount++; - } - } - if ($minCount > 0) { - addResult('Non-vendor .min files', 'WARN', "{$minCount} file(s) — should be generated at runtime"); - } else { - addResult('Non-vendor .min files', 'PASS', 'None shipped'); - } - - // Clean up - $rit = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmpDir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); - foreach ($rit as $file) { - $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); - } - rmdir($tmpDir); - } -} - -// Output -$table = "| Check | Result | Details |\n|-------|--------|--------|\n"; -foreach ($results as $r) { - $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; -} -$table .= "\n**Verification: {$pass} passed, {$fail} failed, {$warn} warnings**\n"; - -echo $table; - -if ($outputSummary) { - $summaryFile = getenv('GITHUB_STEP_SUMMARY'); - if ($summaryFile) { - file_put_contents($summaryFile, "### Release Verification\n\n{$table}\n", FILE_APPEND); - } -} - -if ($githubOutput) { - $outputFile = getenv('GITHUB_OUTPUT'); - if ($outputFile) { - file_put_contents($outputFile, "verify_pass={$pass}\nverify_fail={$fail}\nverify_warn={$warn}\n", FILE_APPEND); - } -} - -exit($fail > 0 ? 1 : 0); +$app = new ReleaseVerifyCli(); +exit($app->execute()); diff --git a/cli/scaffold_client.php b/cli/scaffold_client.php index 46bbe13..bc2bec7 100644 --- a/cli/scaffold_client.php +++ b/cli/scaffold_client.php @@ -11,240 +11,73 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/scaffold_client.php - * VERSION: 09.21.00 + * VERSION: 09.21.07 * BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings */ declare(strict_types=1); -final class ScaffoldClient +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; + +use MokoEnterprise\CliFramework; + +class ScaffoldClientCli extends CliFramework { - private string $name = ''; - private string $org = ''; - private string $giteaUrl = 'https://git.mokoconsulting.tech'; - private string $token = ''; - private bool $dryRun = false; - - public function run(): int + protected function configure(): void { - $this->parseArgs(); + $this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS'); + $this->addArgument('--name', 'Client name', ''); + $this->addArgument('--org', 'Gitea organization', ''); + $this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech'); + $this->addArgument('--token', 'Gitea API token', ''); + } - if ($this->name === '' || $this->org === '' || $this->token === '') - { - $this->log('ERROR: --name, --org, and --token are required.'); - $this->printUsage(); - return 1; - } - - $repoName = 'client-waas-' . $this->name; - - $this->log("Scaffolding client repo: {$this->org}/{$repoName}"); - $this->log("Gitea URL: {$this->giteaUrl}"); - - if ($this->dryRun) - { - $this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); - $this->log("[DRY RUN] Repo: {$this->org}/{$repoName}"); - $this->log("[DRY RUN] Description: \"{$this->name} WaaS site\""); - $this->log('[DRY RUN] Would create dev branch from main'); - $this->printPostSetupInstructions($repoName); + protected function run(): int + { + $name = $this->getArgument('--name'); $org = $this->getArgument('--org'); + $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $token = $this->getArgument('--token'); + if ($name === '' || $org === '' || $token === '') { $this->log('ERROR', '--name, --org, and --token are required.'); return 1; } + $repoName = 'client-waas-' . $name; + $this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}"); + $this->log('INFO', "Gitea URL: {$giteaUrl}"); + if ($this->dryRun) { + $this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); + $this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}"); + $this->printPostSetupInstructions($repoName, $giteaUrl, $org); return 0; } - - // Step 1: Create repo from template - $this->log('Step 1: Creating repo from template...'); - - $createPayload = json_encode([ - 'owner' => $this->org, - 'name' => $repoName, - 'description' => "{$this->name} WaaS site", - 'private' => true, - 'git_content' => true, - 'topics' => true, - 'labels' => true, - ]); - - $response = $this->apiRequest( - 'POST', - "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", - $createPayload - ); - - if ($response['code'] < 200 || $response['code'] >= 300) - { - $this->log("ERROR: Failed to create repo (HTTP {$response['code']})."); - $this->log("Response: {$response['body']}"); - return 1; - } - - $this->log("Repo created: {$this->org}/{$repoName}"); - - // Step 2: Set repo description (already set via generate, but confirm) - $this->log('Step 2: Updating repo description...'); - - $updatePayload = json_encode([ - 'description' => "{$this->name} WaaS site", - ]); - - $response = $this->apiRequest( - 'PATCH', - "/api/v1/repos/{$this->org}/{$repoName}", - $updatePayload - ); - - if ($response['code'] >= 200 && $response['code'] < 300) - { - $this->log('Description updated.'); - } - else - { - $this->log("WARNING: Could not update description (HTTP {$response['code']})."); - } - - // Step 3: Create dev branch from main - $this->log('Step 3: Creating dev branch from main...'); - - $branchPayload = json_encode([ - 'new_branch_name' => 'dev', - 'old_branch_name' => 'main', - ]); - - $response = $this->apiRequest( - 'POST', - "/api/v1/repos/{$this->org}/{$repoName}/branches", - $branchPayload - ); - - if ($response['code'] >= 200 && $response['code'] < 300) - { - $this->log('Branch "dev" created from "main".'); - } - else - { - $this->log("WARNING: Could not create dev branch (HTTP {$response['code']})."); - $this->log("Response: {$response['body']}"); - } - - // Step 4: Print post-setup instructions - $this->printPostSetupInstructions($repoName); - - $this->log('Scaffold complete.'); - + $this->log('INFO', 'Step 1: Creating repo from template...'); + $createPayload = json_encode(['owner' => $org, 'name' => $repoName, 'description' => "{$name} WaaS site", 'private' => true, 'git_content' => true, 'topics' => true, 'labels' => true]); + $response = $this->apiRequest('POST', "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", $giteaUrl, $token, $createPayload); + if ($response['code'] < 200 || $response['code'] >= 300) { $this->log('ERROR', "Failed to create repo (HTTP {$response['code']})."); return 1; } + $this->log('INFO', "Repo created: {$org}/{$repoName}"); + $this->log('INFO', 'Step 2: Updating repo description...'); + $this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"])); + $this->log('INFO', 'Step 3: Creating dev branch from main...'); + $response = $this->apiRequest('POST', "/api/v1/repos/{$org}/{$repoName}/branches", $giteaUrl, $token, json_encode(['new_branch_name' => 'dev', 'old_branch_name' => 'main'])); + if ($response['code'] >= 200 && $response['code'] < 300) { $this->log('INFO', 'Branch "dev" created from "main".'); } + else { $this->log('WARN', "Could not create dev branch (HTTP {$response['code']})."); } + $this->printPostSetupInstructions($repoName, $giteaUrl, $org); + $this->log('INFO', 'Scaffold complete.'); return 0; } - private function parseArgs(): void + private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) - { - switch ($args[$i]) - { - case '--name': - $this->name = $args[++$i] ?? ''; - break; - case '--org': - $this->org = $args[++$i] ?? ''; - break; - case '--gitea-url': - $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); - break; - case '--token': - $this->token = $args[++$i] ?? ''; - break; - case '--dry-run': - $this->dryRun = true; - break; - case '--help': - case '-h': - $this->printUsage(); - exit(0); - default: - $this->log("WARNING: Unknown argument: {$args[$i]}"); - break; - } - } + fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\nNavigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\nSet REPO VARIABLES:\n DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\nSet REPO SECRETS:\n DEV_SYNC_KEY, LIVE_SSH_KEY\n\n================================\n"); } - private function printUsage(): void + private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array { - $this->log('Usage: scaffold_client.php --name --org --token [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --name Client name (e.g., "clarksvillefurs")'); - $this->log(' --org Gitea organization (e.g., "ClarksvilleFurs")'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --token Gitea API token'); - $this->log(' --dry-run Show what would be done without making changes'); - $this->log(' --help, -h Show this help'); - } - - private function printPostSetupInstructions(string $repoName): void - { - $this->log(''); - $this->log('=== POST-SETUP INSTRUCTIONS ==='); - $this->log(''); - $this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings"); - $this->log(''); - $this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):'); - $this->log(' DEV_SYNC_HOST - Dev server hostname or IP'); - $this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)'); - $this->log(' DEV_SYNC_USER - Dev server SSH username'); - $this->log(' DEV_SYNC_PATH - Dev server deploy path'); - $this->log(' LIVE_SSH_HOST - Live server hostname or IP'); - $this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)'); - $this->log(' LIVE_SSH_USER - Live server SSH username'); - $this->log(' LIVE_SYNC_PATH - Live server deploy path'); - $this->log(''); - $this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):'); - $this->log(' DEV_SYNC_KEY - Private SSH key for dev server'); - $this->log(' LIVE_SSH_KEY - Private SSH key for live server'); - $this->log(''); - $this->log('================================'); - } - - private function apiRequest(string $method, string $endpoint, ?string $body = null): array - { - $url = $this->giteaUrl . $endpoint; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Content-Type: application/json', - 'Accept: application/json', - "Authorization: token {$this->token}", - ]); - - if ($body !== null) - { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - - $responseBody = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if (curl_errno($ch)) - { - $error = curl_error($ch); - curl_close($ch); - - return ['code' => 0, 'body' => "cURL error: {$error}"]; - } - - curl_close($ch); - - return ['code' => $httpCode, 'body' => $responseBody]; - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); + $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]); + if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } + $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + if (curl_errno($ch)) { $error = curl_error($ch); curl_close($ch); return ['code' => 0, 'body' => "cURL error: {$error}"]; } + curl_close($ch); return ['code' => $httpCode, 'body' => $responseBody]; } } -$app = new ScaffoldClient(); -exit($app->run()); +$app = new ScaffoldClientCli(); +exit($app->execute()); diff --git a/cli/sync_rulesets.php b/cli/sync_rulesets.php index 845f492..9aae686 100644 --- a/cli/sync_rulesets.php +++ b/cli/sync_rulesets.php @@ -12,170 +12,174 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/sync_rulesets.php * BRIEF: Apply branch protection rules to all repos via platform adapter - * - * USAGE - * php cli/sync_rulesets.php # Apply to all repos - * php cli/sync_rulesets.php --repo MokoCRM # Single repo - * php cli/sync_rulesets.php --dry-run # Preview only - * php cli/sync_rulesets.php --delete # Remove then re-apply - * - * NOTE: On GitHub, this creates rulesets via the rulesets API. - * On Gitea, this creates branch_protections via the branch protection API. */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; +use MokoEnterprise\CliFramework; use MokoEnterprise\Config; use MokoEnterprise\PlatformAdapterFactory; -$dryRun = in_array('--dry-run', $argv); -$deleteOld = in_array('--delete', $argv); +class SyncRulesetsCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Apply branch protection rules to all repos via platform adapter'); + $this->addArgument('--repo', 'Single repository name (default: all repos)', ''); + $this->addArgument('--delete', 'Remove existing protections before re-applying', false); + } -$repoName = null; + protected function run(): int + { + $repoName = $this->getArgument('--repo'); + $deleteOld = $this->getArgument('--delete'); -foreach ($argv as $i => $arg) { - if ($arg === '--repo' && isset($argv[$i + 1])) { $repoName = $argv[$i + 1]; } + $config = Config::load(); + $adapter = PlatformAdapterFactory::create($config); + $org = $config->getString( + $adapter->getPlatformName() . '.organization', + 'mokoconsulting-tech' + ); + + $platformName = $adapter->getPlatformName(); + $ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; + + // -- Protection rules (platform-agnostic format) -- + $PROTECTIONS = [ + [ + 'name' => 'MAIN — protect default branch', + 'branch' => 'main', + 'rules' => [ + 'required_reviews' => 1, + 'dismiss_stale' => true, + 'enforce_admins' => true, + 'block_on_rejected' => true, + 'whitelist_actions_user' => true, + ], + ], + [ + 'name' => 'VERSION — immutable snapshots', + 'branch' => 'version/*', + 'rules' => [ + 'required_reviews' => 0, + 'enforce_admins' => true, + 'whitelist_actions_user' => true, + ], + ], + [ + 'name' => 'DEV — prevent branch deletion', + 'branch' => 'dev/*', + 'rules' => [ + 'required_reviews' => 0, + 'enforce_admins' => true, + 'whitelist_actions_user' => true, + ], + ], + [ + 'name' => 'RC — prevent branch deletion', + 'branch' => 'rc/*', + 'rules' => [ + 'required_reviews' => 0, + 'enforce_admins' => true, + 'whitelist_actions_user' => true, + ], + ], + ]; + + // -- Build repo list -- + $repos = []; + if ($repoName !== '') { + $repos = [$repoName]; + } else { + echo "Fetching repositories from {$org} ({$platformName})...\n"; + $allRepos = $adapter->listOrgRepos($org, true); // skip archived + foreach ($allRepos as $r) { + if (!in_array($r['name'], $ALWAYS_EXCLUDE, true)) { + $repos[] = $r['name']; + } + } + sort($repos); + echo "Found " . count($repos) . " repositories\n\n"; + } + + $created = 0; + $skipped = 0; + $failed = 0; + + foreach ($repos as $repo) { + echo "Processing {$repo}...\n"; + + // Check existing protections + $existing = $adapter->listBranchProtections($org, $repo); + $existingNames = []; + if (is_array($existing)) { + foreach ($existing as $bp) { + $bpName = $bp['name'] ?? $bp['branch_name'] ?? $bp['rule_name'] ?? ''; + $bpId = $bp['id'] ?? null; + if ($bpName !== '') { + $existingNames[$bpName] = $bpId; + } + } + } + + foreach ($PROTECTIONS as $protection) { + $pName = $protection['name']; + + if ($deleteOld && isset($existingNames[$pName])) { + if (!$this->dryRun) { + try { + // Platform-specific deletion via raw API + $adapter->getApiClient()->delete( + "/repos/{$org}/{$repo}/" . + ($platformName === 'github' ? 'rulesets' : 'branch_protections') . + "/{$existingNames[$pName]}" + ); + } catch (\Exception $e) { + /* ignore delete errors */ + } + } + echo " Deleted: {$pName}\n"; + unset($existingNames[$pName]); + } + + if (isset($existingNames[$pName])) { + echo " Exists: {$pName}\n"; + $skipped++; + continue; + } + + if ($this->dryRun) { + echo " (dry-run) would create: {$pName}\n"; + $created++; + continue; + } + + try { + $adapter->setBranchProtection($org, $repo, $protection['branch'], $protection['rules']); + echo " Created: {$pName}\n"; + $created++; + } catch (\Exception $e) { + $msg = $e->getMessage(); + if (str_contains($msg, '403')) { + echo " Skipped (needs Pro/paid plan): {$pName}\n"; + $skipped++; + } else { + echo " Failed: {$pName} — {$msg}\n"; + $failed++; + } + } + } + echo "\n"; + } + + echo str_repeat('-', 50) . "\n"; + echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n"; + return $failed > 0 ? 1 : 0; + } } -$config = Config::load(); -$adapter = PlatformAdapterFactory::create($config); -$org = $config->getString( - $adapter->getPlatformName() . '.organization', - 'mokoconsulting-tech' -); - -$platformName = $adapter->getPlatformName(); -$ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; - -// ── Protection rules (platform-agnostic format) ───────────────────────── -// On GitHub → rulesets API. On Gitea → branch_protections API. -$PROTECTIONS = [ - [ - 'name' => 'MAIN — protect default branch', - 'branch' => 'main', - 'rules' => [ - 'required_reviews' => 1, - 'dismiss_stale' => true, - 'enforce_admins' => true, - 'block_on_rejected' => true, - 'whitelist_actions_user' => true, - ], - ], - [ - 'name' => 'VERSION — immutable snapshots', - 'branch' => 'version/*', - 'rules' => [ - 'required_reviews' => 0, - 'enforce_admins' => true, - 'whitelist_actions_user' => true, - ], - ], - [ - 'name' => 'DEV — prevent branch deletion', - 'branch' => 'dev/*', - 'rules' => [ - 'required_reviews' => 0, - 'enforce_admins' => true, - 'whitelist_actions_user' => true, - ], - ], - [ - 'name' => 'RC — prevent branch deletion', - 'branch' => 'rc/*', - 'rules' => [ - 'required_reviews' => 0, - 'enforce_admins' => true, - 'whitelist_actions_user' => true, - ], - ], -]; - -// ── Build repo list ───────────────────────────────────────────────────── -$repos = []; -if ($repoName) { - $repos = [$repoName]; -} else { - echo "Fetching repositories from {$org} ({$platformName})...\n"; - $allRepos = $adapter->listOrgRepos($org, true); // skip archived - foreach ($allRepos as $r) { - if (!in_array($r['name'], $ALWAYS_EXCLUDE, true)) { - $repos[] = $r['name']; - } - } - sort($repos); - echo "Found " . count($repos) . " repositories\n\n"; -} - -$created = 0; -$skipped = 0; -$failed = 0; - -foreach ($repos as $repo) { - echo "Processing {$repo}...\n"; - - // Check existing protections - $existing = $adapter->listBranchProtections($org, $repo); - $existingNames = []; - if (is_array($existing)) { - foreach ($existing as $bp) { - $bpName = $bp['name'] ?? $bp['branch_name'] ?? $bp['rule_name'] ?? ''; - $bpId = $bp['id'] ?? null; - if ($bpName !== '') { - $existingNames[$bpName] = $bpId; - } - } - } - - foreach ($PROTECTIONS as $protection) { - $pName = $protection['name']; - - if ($deleteOld && isset($existingNames[$pName])) { - if (!$dryRun) { - try { - // Platform-specific deletion via raw API - $adapter->getApiClient()->delete( - "/repos/{$org}/{$repo}/" . - ($platformName === 'github' ? 'rulesets' : 'branch_protections') . - "/{$existingNames[$pName]}" - ); - } catch (\Exception $e) { /* ignore delete errors */ } - } - echo " Deleted: {$pName}\n"; - unset($existingNames[$pName]); - } - - if (isset($existingNames[$pName])) { - echo " Exists: {$pName}\n"; - $skipped++; - continue; - } - - if ($dryRun) { - echo " (dry-run) would create: {$pName}\n"; - $created++; - continue; - } - - try { - $adapter->setBranchProtection($org, $repo, $protection['branch'], $protection['rules']); - echo " Created: {$pName}\n"; - $created++; - } catch (\Exception $e) { - $msg = $e->getMessage(); - if (str_contains($msg, '403')) { - echo " Skipped (needs Pro/paid plan): {$pName}\n"; - $skipped++; - } else { - echo " Failed: {$pName} — {$msg}\n"; - $failed++; - } - } - } - echo "\n"; -} - -echo str_repeat('-', 50) . "\n"; -echo "Done: {$created} created, {$skipped} skipped, {$failed} failed\n"; -exit($failed > 0 ? 1 : 0); +$app = new SyncRulesetsCli(); +exit($app->execute()); diff --git a/cli/theme_lint.php b/cli/theme_lint.php index 3160fa5..1692249 100644 --- a/cli/theme_lint.php +++ b/cli/theme_lint.php @@ -9,201 +9,171 @@ * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/theme_lint.php - * BRIEF: Lint theme files — CSS syntax, image sizes, hardcoded URLs - * - * Usage: - * php theme_lint.php --path /repo - * php theme_lint.php --path /repo --max-image-kb 500 - * php theme_lint.php --path /repo --github-output - * - * Options: - * --path Repository root (default: .) - * --max-image-kb Maximum image file size in KB (default: 500) - * --github-output Export results to $GITHUB_OUTPUT - * --strict Exit 1 on any warning (default: only on errors) + * BRIEF: Lint theme files -- CSS syntax, image sizes, hardcoded URLs */ declare(strict_types=1); -$path = '.'; -$maxImageKb = 500; -$ghOutput = false; -$strict = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) $path = $argv[$i + 1]; - if ($arg === '--max-image-kb' && isset($argv[$i + 1])) $maxImageKb = (int)$argv[$i + 1]; - if ($arg === '--github-output') $ghOutput = true; - if ($arg === '--strict') $strict = true; -} +use MokoEnterprise\CliFramework; -$root = realpath($path) ?: $path; -$errors = 0; -$warnings = 0; - -// ── Find source directory ─────────────────────────────────────────────── -$srcDir = null; -foreach (['src', 'htdocs'] as $d) { - if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; } -} -if ($srcDir === null) { - fwrite(STDERR, "No src/ or htdocs/ directory in {$root}\n"); - exit(1); -} - -echo "Theme Lint: {$srcDir}\n\n"; - -// ── Check 1: CSS syntax validation ────────────────────────────────────── -echo "--- CSS Syntax ---\n"; -$cssFiles = findFiles($srcDir, '*.css'); -$cssMinFiles = findFiles($srcDir, '*.min.css'); -$cssToCheck = array_diff($cssFiles, $cssMinFiles); - -if (empty($cssToCheck)) { - echo " No CSS files to check\n"; -} else { - foreach ($cssToCheck as $file) { - $content = file_get_contents($file); - $relPath = str_replace($root . '/', '', $file); - - // Check for unmatched braces - $openBraces = substr_count($content, '{'); - $closeBraces = substr_count($content, '}'); - if ($openBraces !== $closeBraces) { - echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n"; - $errors++; - } - - // Check for empty rules - if (preg_match_all('/\{[\s]*\}/', $content, $m)) { - $count = count($m[0]); - echo " WARN: {$relPath}: {$count} empty rule(s)\n"; - $warnings++; - } - - // Check for !important abuse (more than 10 in one file) - $importantCount = substr_count($content, '!important'); - if ($importantCount > 10) { - echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n"; - $warnings++; - } - } - - if ($errors === 0) { - echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n"; - } -} - -// ── Check 2: Image file sizes ─────────────────────────────────────────── -echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n"; -$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp']; -$images = []; -foreach ($imageExts as $ext) { - $images = array_merge($images, findFiles($srcDir, $ext)); -} -// Also check root images/ directory -if (is_dir("{$root}/images")) { - foreach ($imageExts as $ext) { - $images = array_merge($images, findFiles("{$root}/images", $ext)); - } -} - -$oversized = 0; -$totalSize = 0; -foreach ($images as $file) { - $size = filesize($file); - $totalSize += $size; - $relPath = str_replace($root . '/', '', $file); - $sizeKb = round($size / 1024); - - if ($sizeKb > $maxImageKb) { - echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n"; - $oversized++; - $warnings++; - } -} - -$totalMb = round($totalSize / 1024 / 1024, 1); -echo " " . count($images) . " image(s), {$totalMb}MB total"; -if ($oversized > 0) { - echo ", {$oversized} oversized"; -} -echo "\n"; - -// ── Check 3: Hardcoded URLs in CSS/JS ─────────────────────────────────── -echo "\n--- Hardcoded URLs ---\n"; -$codeFiles = array_merge( - findFiles($srcDir, '*.css'), - findFiles($srcDir, '*.js') -); -// Exclude minified files -$codeFiles = array_filter($codeFiles, function($f) { - return !preg_match('/\.min\.(css|js)$/', $f); -}); - -$urlPatterns = [ - '/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL', - '/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL', - '/https?:\/\/localhost/' => 'localhost reference', -]; - -$urlIssues = 0; -foreach ($codeFiles as $file) { - $content = file_get_contents($file); - $relPath = str_replace($root . '/', '', $file); - - foreach ($urlPatterns as $pattern => $desc) { - if (preg_match_all($pattern, $content, $matches)) { - $count = count($matches[0]); - echo " WARN: {$relPath}: {$count} {$desc}\n"; - $urlIssues++; - $warnings++; - } - } -} - -if ($urlIssues === 0) { - echo " OK: No hardcoded URLs found\n"; -} - -// ── Summary ───────────────────────────────────────────────────────────── -echo "\n=== Summary ===\n"; -echo "Errors: {$errors}\n"; -echo "Warnings: {$warnings}\n"; - -if ($ghOutput) { - $ghFile = getenv('GITHUB_OUTPUT'); - if ($ghFile) { - file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND); - file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND); - file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND); - file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND); - } -} - -if ($errors > 0) { - exit(1); -} -if ($strict && $warnings > 0) { - exit(1); -} -exit(0); - -// ── Helper: recursively find files matching a glob pattern ────────────── -function findFiles(string $dir, string $pattern): array +class ThemeLintCli extends CliFramework { - $results = []; - if (!is_dir($dir)) return $results; - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) - ); - - foreach ($iterator as $file) { - if (fnmatch($pattern, $file->getFilename())) { - $results[] = $file->getPathname(); - } + protected function configure(): void + { + $this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500'); + $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); + $this->addArgument('--strict', 'Exit 1 on any warning', false); } - return $results; + protected function run(): int + { + $path = $this->getArgument('--path'); + $maxImageKb = (int) $this->getArgument('--max-image-kb'); + $ghOutput = (bool) $this->getArgument('--github-output'); + $strict = (bool) $this->getArgument('--strict'); + + $root = realpath($path) ?: $path; + $errors = 0; + $warnings = 0; + + $srcDir = null; + foreach (['src', 'htdocs'] as $d) { + if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; } + } + if ($srcDir === null) { + $this->log('ERROR', "No src/ or htdocs/ directory in {$root}"); + return 1; + } + + echo "Theme Lint: {$srcDir}\n\n"; + + echo "--- CSS Syntax ---\n"; + $cssFiles = $this->findFiles($srcDir, '*.css'); + $cssMinFiles = $this->findFiles($srcDir, '*.min.css'); + $cssToCheck = array_diff($cssFiles, $cssMinFiles); + + if (empty($cssToCheck)) { + echo " No CSS files to check\n"; + } else { + foreach ($cssToCheck as $file) { + $content = file_get_contents($file); + $relPath = str_replace($root . '/', '', $file); + $openBraces = substr_count($content, '{'); + $closeBraces = substr_count($content, '}'); + if ($openBraces !== $closeBraces) { + echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n"; + $errors++; + } + if (preg_match_all('/\{[\s]*\}/', $content, $m)) { + $count = count($m[0]); + echo " WARN: {$relPath}: {$count} empty rule(s)\n"; + $warnings++; + } + $importantCount = substr_count($content, '!important'); + if ($importantCount > 10) { + echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n"; + $warnings++; + } + } + if ($errors === 0) { + echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n"; + } + } + + echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n"; + $imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp']; + $images = []; + foreach ($imageExts as $ext) { + $images = array_merge($images, $this->findFiles($srcDir, $ext)); + } + if (is_dir("{$root}/images")) { + foreach ($imageExts as $ext) { + $images = array_merge($images, $this->findFiles("{$root}/images", $ext)); + } + } + + $oversized = 0; + $totalSize = 0; + foreach ($images as $file) { + $size = filesize($file); + $totalSize += $size; + $relPath = str_replace($root . '/', '', $file); + $sizeKb = round($size / 1024); + if ($sizeKb > $maxImageKb) { + echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n"; + $oversized++; + $warnings++; + } + } + + $totalMb = round($totalSize / 1024 / 1024, 1); + echo " " . count($images) . " image(s), {$totalMb}MB total"; + if ($oversized > 0) { echo ", {$oversized} oversized"; } + echo "\n"; + + echo "\n--- Hardcoded URLs ---\n"; + $codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js')); + $codeFiles = array_filter($codeFiles, function ($f) { + return !preg_match('/\.min\.(css|js)$/', $f); + }); + $urlPatterns = [ + '/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL', + '/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL', + '/https?:\/\/localhost/' => 'localhost reference', + ]; + $urlIssues = 0; + foreach ($codeFiles as $file) { + $content = file_get_contents($file); + $relPath = str_replace($root . '/', '', $file); + foreach ($urlPatterns as $pattern => $desc) { + if (preg_match_all($pattern, $content, $matches)) { + $count = count($matches[0]); + echo " WARN: {$relPath}: {$count} {$desc}\n"; + $urlIssues++; + $warnings++; + } + } + } + if ($urlIssues === 0) { echo " OK: No hardcoded URLs found\n"; } + + echo "\n=== Summary ===\n"; + echo "Errors: {$errors}\n"; + echo "Warnings: {$warnings}\n"; + + if ($ghOutput) { + $ghFile = getenv('GITHUB_OUTPUT'); + if ($ghFile) { + file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND); + file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND); + file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND); + file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND); + } + } + + if ($errors > 0) { return 1; } + if ($strict && $warnings > 0) { return 1; } + return 0; + } + + private function findFiles(string $dir, string $pattern): array + { + $results = []; + if (!is_dir($dir)) { return $results; } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (fnmatch($pattern, $file->getFilename())) { + $results[] = $file->getPathname(); + } + } + return $results; + } } + +$app = new ThemeLintCli(); +exit($app->execute()); diff --git a/cli/updates_xml_build.php b/cli/updates_xml_build.php index 4de0cae..46ff8bd 100644 --- a/cli/updates_xml_build.php +++ b/cli/updates_xml_build.php @@ -11,473 +11,362 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/updates_xml_build.php * BRIEF: Generate Joomla updates.xml from extension manifest metadata - * - * Usage: - * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable - * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --sha SHA256 - * php updates_xml_build.php --path /repo --version 04.01.00 --stability stable --github-output - * - * Options: - * --path Repository root (default: .) - * --version Version string (required) - * --stability One of: stable, rc, beta, alpha, development (default: stable) - * --sha SHA-256 hash of the ZIP package (optional) - * --gitea-url Gitea instance URL (default: env GITEA_URL or https://git.mokoconsulting.tech) - * --org Organization (default: env GITEA_ORG) - * --repo Repository name (default: env GITEA_REPO) - * --output Output file path (default: updates.xml in --path) - * --github-output Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT */ declare(strict_types=1); -// -- Argument parsing --------------------------------------------------------- -$path = '.'; -$version = null; -$stability = 'stable'; -$sha = null; -$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; -$org = getenv('GITEA_ORG') ?: ''; -$repo = getenv('GITEA_REPO') ?: ''; -$outputFile = null; -$githubOutput = false; +require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; -foreach ($argv as $i => $arg) { - if ($arg === '--path' && isset($argv[$i + 1])) { - $path = $argv[$i + 1]; - } - if ($arg === '--version' && isset($argv[$i + 1])) { - $version = $argv[$i + 1]; - } - if ($arg === '--stability' && isset($argv[$i + 1])) { - $stability = $argv[$i + 1]; - } - if ($arg === '--sha' && isset($argv[$i + 1])) { - $sha = $argv[$i + 1]; - } - if ($arg === '--gitea-url' && isset($argv[$i + 1])) { - $giteaUrl = $argv[$i + 1]; - } - if ($arg === '--org' && isset($argv[$i + 1])) { - $org = $argv[$i + 1]; - } - if ($arg === '--repo' && isset($argv[$i + 1])) { - $repo = $argv[$i + 1]; - } - if ($arg === '--output' && isset($argv[$i + 1])) { - $outputFile = $argv[$i + 1]; - } - if ($arg === '--github-output') { - $githubOutput = true; - } -} +use MokoEnterprise\CliFramework; -if ($version === null) { - fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n"); - exit(1); -} +class UpdatesXmlBuildCli extends CliFramework +{ + protected function configure(): void + { + $this->setDescription('Generate Joomla updates.xml from extension manifest metadata'); + $this->addArgument('--path', 'Repository root (default: .)', '.'); + $this->addArgument('--version', 'Version string (required)', ''); + $this->addArgument('--stability', 'One of: stable, rc, beta, alpha, development (default: stable)', 'stable'); + $this->addArgument('--sha', 'SHA-256 hash of the ZIP package', ''); + $this->addArgument('--gitea-url', 'Gitea instance URL', ''); + $this->addArgument('--org', 'Organization', ''); + $this->addArgument('--repo', 'Repository name', ''); + $this->addArgument('--output', 'Output file path (default: updates.xml in --path)', ''); + $this->addArgument('--github-output', 'Export ext_element, ext_name, ext_type, ext_folder to $GITHUB_OUTPUT', false); + } -// Strip any existing stability suffix from version (e.g. 01.02.20-dev → 01.02.20) -// so per-channel suffixes are applied cleanly without doubling -$version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version); + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $stability = $this->getArgument('--stability'); + $sha = $this->getArgument('--sha') ?: null; + $giteaUrl = $this->getArgument('--gitea-url') ?: (getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'); + $org = $this->getArgument('--org') ?: (getenv('GITEA_ORG') ?: ''); + $repo = $this->getArgument('--repo') ?: (getenv('GITEA_REPO') ?: ''); + $outputFile = $this->getArgument('--output') ?: null; + $githubOutput = $this->getArgument('--github-output'); -$root = realpath($path) ?: $path; - -// -- Read platform from .mokogitea/manifest.xml -------------------------------- -$detectedPlatform = 'joomla'; // default for backward compat -$detectedName = $repo; -$detectedPackageType = ''; -$mokoManifest = "{$root}/.mokogitea/manifest.xml"; -if (file_exists($mokoManifest)) { - $mokoXml = @simplexml_load_file($mokoManifest); - if ($mokoXml !== false) { - $rawPlatform = (string)($mokoXml->governance->platform ?? ''); - if ($rawPlatform !== '') { - $detectedPlatform = match ($rawPlatform) { - 'waas-component' => 'joomla', - 'crm-module' => 'dolibarr', - default => $rawPlatform, - }; + if ($version === '') { + $this->log('ERROR', 'Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]'); + return 1; } - $detectedName = (string)($mokoXml->identity->name ?? $repo); - // is the human-friendly name for releases and updates.xml - $detectedDisplayName = (string)($mokoXml->identity->{"display-name"} ?? ''); - $detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? ''); - // Auto-detect org from manifest if not provided via CLI/env - if (empty($org)) { - $manifestOrg = (string)($mokoXml->identity->org ?? ''); - if ($manifestOrg !== '') { - $org = $manifestOrg; + // Strip any existing stability suffix from version + $version = preg_replace('/-(dev|alpha|beta|rc)$/', '', $version); + + $root = realpath($path) ?: $path; + + // -- Read platform from .mokogitea/manifest.xml -------------------------------- + $detectedPlatform = 'joomla'; + $detectedName = $repo; + $detectedPackageType = ''; + $detectedDisplayName = ''; + $mokoManifest = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($mokoManifest)) { + $mokoXml = @simplexml_load_file($mokoManifest); + if ($mokoXml !== false) { + $rawPlatform = (string)($mokoXml->governance->platform ?? ''); + if ($rawPlatform !== '') { + $detectedPlatform = match ($rawPlatform) { + 'waas-component' => 'joomla', + 'crm-module' => 'dolibarr', + default => $rawPlatform, + }; + } + $detectedName = (string)($mokoXml->identity->name ?? $repo); + $detectedDisplayName = (string)($mokoXml->identity->{"display-name"} ?? ''); + $detectedPackageType = (string)($mokoXml->build->{"package-type"} ?? ''); + + if (empty($org)) { + $manifestOrg = (string)($mokoXml->identity->org ?? ''); + if ($manifestOrg !== '') { + $org = $manifestOrg; + } + } + if (empty($repo)) { + $manifestName = (string)($mokoXml->identity->name ?? ''); + if ($manifestName !== '') { + $repo = $manifestName; + } + } } } - // Auto-detect repo from manifest if not provided via CLI/env - if (empty($repo)) { - $manifestName = (string)($mokoXml->identity->name ?? ''); - if ($manifestName !== '') { - $repo = $manifestName; + + // -- Fallback: detect org/repo from git remote -------------------------------- + if (empty($org) || empty($repo)) { + $remoteUrl = trim(shell_exec("git -C " . escapeshellarg($root) . " remote get-url origin 2>/dev/null") ?? ''); + if (preg_match('#[/:]([^/:]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) { + if (empty($org)) { + $org = $m[1]; + } + if (empty($repo)) { + $repo = $m[2]; + } } } - } -} -// -- Fallback: detect org/repo from git remote -------------------------------- -if (empty($org) || empty($repo)) { - $remoteUrl = trim(shell_exec("git -C " . escapeshellarg($root) . " remote get-url origin 2>/dev/null") ?? ''); - // Match patterns: https://host/org/repo.git or git@host:org/repo.git - if (preg_match('#[/:]([^/:]+)/([^/]+?)(?:\.git)?$#', $remoteUrl, $m)) { - if (empty($org)) { - $org = $m[1]; - } - if (empty($repo)) { - $repo = $m[2]; - } - } -} + // -- Locate Joomla manifest --------------------------------------------------- + $manifest = null; -// -- Locate Joomla manifest --------------------------------------------------- -$manifest = null; - -// Priority: pkg_*.xml in src/ > any extension XML in src/ > any in root -$candidates = glob("{$root}/src/pkg_*.xml") ?: []; -foreach ($candidates as $f) { - if (strpos(file_get_contents($f), 'log('ERROR', "No Joomla XML manifest found in {$root}"); + return 1; + } -if ($manifest !== null) { - // Joomla manifest found — parse extension metadata from it - $xml = file_get_contents($manifest); + // -- Parse extension metadata ------------------------------------------------- + $extName = ''; + $extType = ''; + $extElement = ''; + $extClient = ''; + $extFolder = ''; + $targetPlatform = ''; + $phpMinimum = ''; - if (preg_match('/([^<]+)<\/name>/', $xml, $m)) { - $extName = $m[1]; - } - if (preg_match('/]*type="([^"]+)"/', $xml, $m)) { - $extType = $m[1]; - } - if (preg_match('/([^<]+)<\/element>/', $xml, $m)) { - $extElement = $m[1]; - } - if (empty($extElement) && preg_match('/([^<]+)<\/packagename>/', $xml, $m)) { - $extElement = $m[1]; - } - if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) { - $extElement = $m[1]; - } - if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) { - $extElement = $m[1]; - } - if (empty($extElement)) { - $fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME)); - if (in_array($fname, ['templatedetails', 'manifest'])) { - $extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root))); + if ($manifest !== null) { + $xml = file_get_contents($manifest); + + if (preg_match('/([^<]+)<\/name>/', $xml, $m)) { + $extName = $m[1]; + } + if (preg_match('/]*type="([^"]+)"/', $xml, $m)) { + $extType = $m[1]; + } + if (preg_match('/([^<]+)<\/element>/', $xml, $m)) { + $extElement = $m[1]; + } + if (empty($extElement) && preg_match('/([^<]+)<\/packagename>/', $xml, $m)) { + $extElement = $m[1]; + } + if (empty($extElement) && preg_match('/plugin="([^"]+)"/', $xml, $m)) { + $extElement = $m[1]; + } + if (empty($extElement) && preg_match('/module="([^"]+)"/', $xml, $m)) { + $extElement = $m[1]; + } + if (empty($extElement)) { + $fname = strtolower(pathinfo($manifest, PATHINFO_FILENAME)); + if (in_array($fname, ['templatedetails', 'manifest'])) { + $extElement = strtolower(str_replace([' ', '-'], '', $repo ?: basename($root))); + } else { + $extElement = $fname; + } + } + $extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement); + + if (preg_match('/]*client="([^"]+)"/', $xml, $m)) { + $extClient = $m[1]; + } + if (preg_match('/]*group="([^"]+)"/', $xml, $m)) { + $extFolder = $m[1]; + } + if (preg_match('/()/', $xml, $m)) { + $targetPlatform = $m[1]; + } + if (empty($targetPlatform)) { + $targetPlatform = ''; + } + if (preg_match('/([^<]+)<\/php_minimum>/', $xml, $m)) { + $phpMinimum = $m[1]; + } } else { - $extElement = $fname; + $extName = $detectedName ?: ($repo ?: basename($root)); + $extElement = strtolower(str_replace([' ', '-'], '', $extName)); + $extType = $detectedPackageType ?: 'generic'; + $targetPlatform = ""; } - } - $extElement = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $extElement); - if (preg_match('/]*client="([^"]+)"/', $xml, $m)) { - $extClient = $m[1]; - } - if (preg_match('/]*group="([^"]+)"/', $xml, $m)) { - $extFolder = $m[1]; - } - if (preg_match('/()/', $xml, $m)) { - $targetPlatform = $m[1]; - } - if (empty($targetPlatform)) { - $targetPlatform = ''; - } - if (preg_match('/([^<]+)<\/php_minimum>/', $xml, $m)) { - $phpMinimum = $m[1]; - } -} else { - // Non-Joomla platform — derive metadata from .mokogitea/manifest.xml - $extName = $detectedName ?: ($repo ?: basename($root)); - $extElement = strtolower(str_replace([' ', '-'], '', $extName)); - $extType = $detectedPackageType ?: 'generic'; - $targetPlatform = ""; -} - -// Display name resolution moved to manifest.xml (below) - -// Fallbacks -if (empty($extName)) { - $extName = $repo ?: basename($root); -} -if (empty($extType)) { - $extType = 'component'; -} - -// Display name: use from manifest.xml if available -// This is the canonical human-friendly name — no type prefix added -if (!empty($detectedDisplayName)) { - $displayName = $detectedDisplayName; -} elseif (!empty($detectedName)) { - $displayName = $detectedName; -} else { - $displayName = $extName; -} - -// -- Build type prefix -------------------------------------------------------- -$typePrefix = ''; -switch ($extType) { - case 'plugin': - $typePrefix = "plg_{$extFolder}_"; - break; - case 'module': - $typePrefix = 'mod_'; - break; - case 'component': - $typePrefix = 'com_'; - break; - case 'template': - $typePrefix = 'tpl_'; - break; - case 'library': - $typePrefix = 'lib_'; - break; - case 'package': - $typePrefix = 'pkg_'; - break; -} - -// -- Export to GITHUB_OUTPUT if requested ------------------------------------- -if ($githubOutput) { - $ghOutput = getenv('GITHUB_OUTPUT'); - $lines = [ - "ext_element={$extElement}", - "ext_name={$extName}", - "ext_type={$extType}", - "ext_folder={$extFolder}", - "type_prefix={$typePrefix}", - ]; - if ($ghOutput) { - file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); - fwrite(STDERR, "Exported " . count($lines) . " fields to GITHUB_OUTPUT\n"); - } else { - foreach ($lines as $line) { - echo "{$line}\n"; + if (empty($extName)) { + $extName = $repo ?: basename($root); + } + if (empty($extType)) { + $extType = 'component'; } - } -} -// -- Stability suffix map ----------------------------------------------------- -$stabilitySuffixMap = [ - 'stable' => '', - 'rc' => '-rc', - 'beta' => '-beta', - 'alpha' => '-alpha', - 'development' => '-dev', - 'dev' => '-dev', -]; + if (!empty($detectedDisplayName)) { + $displayName = $detectedDisplayName; + } elseif (!empty($detectedName)) { + $displayName = $detectedName; + } else { + $displayName = $extName; + } -// Joomla values — maps to Joomla's stabilityTagToInteger() -$stabilityTagMap = [ - 'stable' => 'stable', - 'rc' => 'rc', - 'beta' => 'beta', - 'alpha' => 'alpha', - 'development' => 'dev', - 'dev' => 'dev', -]; + // -- Build type prefix -------------------------------------------------------- + $typePrefix = ''; + switch ($extType) { + case 'plugin': + $typePrefix = "plg_{$extFolder}_"; + break; + case 'module': + $typePrefix = 'mod_'; + break; + case 'component': + $typePrefix = 'com_'; + break; + case 'template': + $typePrefix = 'tpl_'; + break; + case 'library': + $typePrefix = 'lib_'; + break; + case 'package': + $typePrefix = 'pkg_'; + break; + } -// Gitea release tag names (used in download/info URLs) -$releaseTagMap = [ - 'stable' => 'stable', - 'rc' => 'release-candidate', - 'beta' => 'beta', - 'alpha' => 'alpha', - 'development' => 'development', - 'dev' => 'development', -]; - -// -- Build update entries ----------------------------------------------------- -// For the primary entry: apply suffix if not stable -$primarySuffix = $stabilitySuffixMap[$stability] ?? ''; -$primaryVersion = $version . $primarySuffix; - -// Build client tag — Joomla requires site to match updates -// to installed extensions. Without it, extension_id=0 in #__updates. -$clientTag = ''; -if (!empty($extClient)) { - $clientTag = " {$extClient}"; -} else { - $clientTag = ' site'; -} - -// Build folder tag -$folderTag = ''; -if (!empty($extFolder) && $extType === 'plugin') { - $folderTag = " {$extFolder}"; -} - -// PHP minimum tag -$phpTag = ''; -if (!empty($phpMinimum)) { - $phpTag = " {$phpMinimum}"; -} - -// SHA tag -$shaTag = ''; -if (!empty($sha)) { - $shaTag = " {$sha}"; -} - -/** - * Build a single entry for a given stability tag - */ -function buildEntry( - string $tagName, - string $entryVersion, - string $entryDownloadUrl, - string $displayName, - string $stabilityLabel, - string $extElement, - string $extType, - string $clientTag, - string $folderTag, - string $infoUrl, - string $targetPlatform, - string $phpTag, - string $shaTag, - string $changelogUrl = '' -): string { - $lines = []; - $lines[] = ' '; - $lines[] = " {$displayName}"; - $lines[] = " {$displayName} {$stabilityLabel} build."; - // Element in updates.xml must match what Joomla stores in #__extensions. - // Plugins and templates are stored as bare element (no prefix). - // Other types need their prefix: mod_, com_, pkg_, lib_. - $prefixMap = [ - 'package' => 'pkg_', - 'module' => 'mod_', - 'component' => 'com_', - 'library' => 'lib_', - ]; - $dbElement = isset($prefixMap[$extType]) ? $prefixMap[$extType] . $extElement : $extElement; - $lines[] = " {$dbElement}"; - $lines[] = " {$extType}"; - $lines[] = $clientTag; - $lines[] = " {$entryVersion}"; - $lines[] = " " . date('Y-m-d') . ""; - if (!empty($folderTag)) { - $lines[] = $folderTag; - } - $lines[] = " {$infoUrl}"; - $lines[] = ' '; - $lines[] = " {$entryDownloadUrl}"; - $lines[] = ' '; - if (!empty($shaTag)) { - $lines[] = $shaTag; - } - $lines[] = " {$tagName}"; - if (!empty($changelogUrl)) { - $lines[] = " {$changelogUrl}"; - } - $lines[] = ' Moko Consulting'; - $lines[] = ' https://mokoconsulting.tech'; - $lines[] = " {$targetPlatform}"; - if (!empty($phpTag)) { - $lines[] = $phpTag; - } - $lines[] = ' '; - return implode("\n", $lines); -} - -// -- Write ONLY the single channel being released -------------------------------- -// No cascading. Each update stream is independent. -// When dev releases, only the dev entry is written/updated. -// When stable releases, only the stable entry is written/updated. -// All other channel entries are preserved exactly as-is. -$entries = []; -$giteaTag = $releaseTagMap[$stability] ?? $stability; -$channelVersion = $version . ($stabilitySuffixMap[$stability] ?? ''); -$channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip"; -$channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}"; -$joomlaTag = $stabilityTagMap[$stability] ?? $stability; -$changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md"; - -$entries[] = buildEntry( - $joomlaTag, - $channelVersion, - $channelDownloadUrl, - $displayName, - $stability, - $extElement, - $extType, - $clientTag, - $folderTag, - $channelInfoUrl, - $targetPlatform, - $phpTag, - $shaTag, - $changelogUrl -); - -// -- Preserve existing entries for channels not being updated ----------------- -$dest = $outputFile ?? "{$root}/updates.xml"; -$preservedEntries = []; - -if (file_exists($dest)) { - $existingXml = @simplexml_load_file($dest); - if ($existingXml) { - // Only the channel we're writing gets replaced — everything else is preserved - $writtenTag = $joomlaTag; - // Also match legacy alternate (e.g. 'development' = 'dev') - $writtenAliases = [$writtenTag]; - if ($writtenTag === 'dev') $writtenAliases[] = 'development'; - if ($writtenTag === 'development') $writtenAliases[] = 'dev'; - - foreach ($existingXml->update as $existingUpdate) { - $existingTag = ''; - if (isset($existingUpdate->tags->tag)) { - $existingTag = (string) $existingUpdate->tags->tag; - } - // Keep ALL entries except the one channel we're overwriting - if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) { - $preservedEntries[] = ' ' . trim($existingUpdate->asXML()); + // -- Export to GITHUB_OUTPUT if requested ------------------------------------- + if ($githubOutput) { + $ghOutput = getenv('GITHUB_OUTPUT'); + $lines = [ + "ext_element={$extElement}", + "ext_name={$extName}", + "ext_type={$extType}", + "ext_folder={$extFolder}", + "type_prefix={$typePrefix}", + ]; + if ($ghOutput) { + file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); + $this->log('INFO', "Exported " . count($lines) . " fields to GITHUB_OUTPUT"); + } else { + foreach ($lines as $line) { + echo "{$line}\n"; + } } } - } -} -// -- Write updates.xml -------------------------------------------------------- -$year = date('Y'); -$output = << '', + 'rc' => '-rc', + 'beta' => '-beta', + 'alpha' => '-alpha', + 'development' => '-dev', + 'dev' => '-dev', + ]; + + $stabilityTagMap = [ + 'stable' => 'stable', + 'rc' => 'rc', + 'beta' => 'beta', + 'alpha' => 'alpha', + 'development' => 'dev', + 'dev' => 'dev', + ]; + + $releaseTagMap = [ + 'stable' => 'stable', + 'rc' => 'release-candidate', + 'beta' => 'beta', + 'alpha' => 'alpha', + 'development' => 'development', + 'dev' => 'development', + ]; + + $primarySuffix = $stabilitySuffixMap[$stability] ?? ''; + $primaryVersion = $version . $primarySuffix; + + $clientTag = ''; + if (!empty($extClient)) { + $clientTag = " {$extClient}"; + } else { + $clientTag = ' site'; + } + + $folderTag = ''; + if (!empty($extFolder) && $extType === 'plugin') { + $folderTag = " {$extFolder}"; + } + + $phpTag = ''; + if (!empty($phpMinimum)) { + $phpTag = " {$phpMinimum}"; + } + + $shaTag = ''; + if (!empty($sha)) { + $shaTag = " {$sha}"; + } + + // -- Write ONLY the single channel being released -------------------------------- + $entries = []; + $giteaTag = $releaseTagMap[$stability] ?? $stability; + $channelVersion = $version . ($stabilitySuffixMap[$stability] ?? ''); + $channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$giteaTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip"; + $channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$giteaTag}"; + $joomlaTag = $stabilityTagMap[$stability] ?? $stability; + $changelogUrl = "{$giteaUrl}/{$org}/{$repo}/raw/branch/main/CHANGELOG.md"; + + $entries[] = $this->buildEntry( + $joomlaTag, + $channelVersion, + $channelDownloadUrl, + $displayName, + $stability, + $extElement, + $extType, + $clientTag, + $folderTag, + $channelInfoUrl, + $targetPlatform, + $phpTag, + $shaTag, + $changelogUrl + ); + + // -- Preserve existing entries for channels not being updated ----------------- + $dest = $outputFile ?? "{$root}/updates.xml"; + $preservedEntries = []; + + if (file_exists($dest)) { + $existingXml = @simplexml_load_file($dest); + if ($existingXml) { + $writtenTag = $joomlaTag; + $writtenAliases = [$writtenTag]; + if ($writtenTag === 'dev') { + $writtenAliases[] = 'development'; + } + if ($writtenTag === 'development') { + $writtenAliases[] = 'dev'; + } + + foreach ($existingXml->update as $existingUpdate) { + $existingTag = ''; + if (isset($existingUpdate->tags->tag)) { + $existingTag = (string) $existingUpdate->tags->tag; + } + if (!empty($existingTag) && !in_array($existingTag, $writtenAliases, true)) { + $preservedEntries[] = ' ' . trim($existingUpdate->asXML()); + } + } + } + } + + // -- Write updates.xml -------------------------------------------------------- + $year = date('Y'); + $output = <<