#!/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/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; 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 === '--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); } $root = realpath($path) ?: $path; // -- 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), '([^<]+)<\/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) { $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); } } } // 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; } } if ($skip) { continue; } // Normalize path separators for ZIP $relativePath = str_replace('\\', '/', $relativePath); if ($file->isDir()) { $zip->addEmptyDir($relativePath); } else { $zip->addFile($filePath, $relativePath); } } }