#!/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/joomla_build.php * VERSION: 09.23.00 * BRIEF: Build a Joomla extension ZIP from manifest — all types supported * NOTE: Called by pre-release and auto-release workflows. */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class JoomlaBuildCli extends CliFramework { 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); } } $app = new JoomlaBuildCli(); exit($app->execute());