diff --git a/.gitignore b/.gitignore index 6a600a0..9af5194 100644 --- a/.gitignore +++ b/.gitignore @@ -687,6 +687,7 @@ modulebuilder.txt !/bin/moko /cache/* /cli/* +!/cli/*.php /components/com_ajax/* /components/com_banners/* /components/com_config/* diff --git a/cli/badge_update.php b/cli/badge_update.php new file mode 100644 index 0000000..87a1e8b --- /dev/null +++ b/cli/badge_update.php @@ -0,0 +1,68 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * 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; + +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 ($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); diff --git a/cli/changelog_promote.php b/cli/changelog_promote.php new file mode 100644 index 0000000..1419a15 --- /dev/null +++ b/cli/changelog_promote.php @@ -0,0 +1,82 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * 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'); + +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]; +} + +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); diff --git a/cli/package_build.php b/cli/package_build.php new file mode 100644 index 0000000..5b3f519 --- /dev/null +++ b/cli/package_build.php @@ -0,0 +1,288 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * 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)); +} + +$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(); + mkdir($stagingDir, 0755, true); + + // ZIP each sub-extension + foreach (glob("{$sourceDir}/packages/*/") ?: [] as $extDir) { + $subName = basename($extDir); + echo " Packaging sub-extension: {$subName}\n"; + + $subZip = new ZipArchive(); + $subZipPath = "{$stagingDir}/{$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(); + } + + // Copy package-level files + foreach (array_merge(glob("{$sourceDir}/*.xml") ?: [], glob("{$sourceDir}/*.php") ?: []) as $f) { + copy($f, "{$stagingDir}/" . basename($f)); + } + + // 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); + } + } +} diff --git a/cli/release_cascade.php b/cli/release_cascade.php new file mode 100644 index 0000000..5f7670a --- /dev/null +++ b/cli/release_cascade.php @@ -0,0 +1,116 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/release_cascade.php + * BRIEF: Delete lesser pre-release channels from Gitea when promoting stability + * + * Usage: + * php release_cascade.php --stability stable --token TOKEN --api-base URL + * php release_cascade.php --stability rc --token TOKEN --api-base URL + * + * Cascade rules: + * stable -> deletes development, alpha, beta, release-candidate + * rc -> deletes development, alpha, beta + * beta -> deletes development, alpha + * alpha -> deletes development + */ + +declare(strict_types=1); + +$stability = null; +$token = null; +$apiBase = null; + +foreach ($argv as $i => $arg) { + if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $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]; +} + +// Allow token from environment +if ($token === null) { + $token = getenv('GA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; +} + +if ($stability === null || $token === null || $apiBase === null) { + fwrite(STDERR, "Usage: release_cascade.php --stability [stable|rc|beta|alpha] --token TOKEN --api-base URL\n"); + fwrite(STDERR, " --api-base: e.g. https://git.mokoconsulting.tech/api/v1/repos/Org/Repo\n"); + fwrite(STDERR, " Token can also be set via GA_TOKEN or GITEA_TOKEN env var\n"); + exit(1); +} + +// Define cascade hierarchy +$cascadeMap = [ + 'stable' => ['development', 'alpha', 'beta', 'release-candidate'], + 'rc' => ['development', 'alpha', 'beta'], + 'beta' => ['development', 'alpha'], + 'alpha' => ['development'], +]; + +if (!isset($cascadeMap[$stability])) { + fwrite(STDERR, "Unknown stability level: {$stability}\n"); + fwrite(STDERR, "Valid options: stable, rc, beta, alpha\n"); + exit(1); +} + +$tagsToDelete = $cascadeMap[$stability]; +$deleted = 0; + +foreach ($tagsToDelete as $tag) { + // Get release by tag + $ch = curl_init("{$apiBase}/releases/tags/{$tag}"); + 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)) { + continue; + } + + $data = json_decode($response, true); + $releaseId = $data['id'] ?? null; + + if ($releaseId === null) { + continue; + } + + // Delete release + $ch = curl_init("{$apiBase}/releases/{$releaseId}"); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + curl_exec($ch); + curl_close($ch); + + // Delete tag + $ch = curl_init("{$apiBase}/tags/{$tag}"); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], + CURLOPT_TIMEOUT => 30, + ]); + curl_exec($ch); + curl_close($ch); + + echo "Deleted: {$tag} (release id: {$releaseId})\n"; + $deleted++; +} + +echo "Cleaned up {$deleted} pre-release channel(s)\n"; +exit(0); diff --git a/cli/release_manage.php b/cli/release_manage.php new file mode 100644 index 0000000..1d9eb00 --- /dev/null +++ b/cli/release_manage.php @@ -0,0 +1,239 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * 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 GA_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; + +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]; +} + +// Allow token from environment +if ($token === null) { + $token = getenv('GA_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 giteaApi(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); + + $data = json_decode($response ?: '{}', true) ?: []; + return ['code' => $httpCode, 'data' => $data]; +} + +/** + * Get release by tag + */ +function getReleaseByTag(string $apiBase, string $tag, string $token): ?array +{ + $result = giteaApi("{$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']; + giteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token); + giteaApi("{$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 = giteaApi("{$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 = giteaApi("{$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) { + giteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token); + echo "Deleted existing asset: {$fileName}\n"; + break; + } + } + + // Upload + $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName); + $result = giteaApi($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 = getReleaseByTag($apiBase, $tag, $token); + if ($release === null) { + fwrite(STDERR, "No release found for tag: {$tag}\n"); + exit(1); + } + $releaseId = $release['id']; + + $payload = json_encode(['body' => $body ?? '']); + $result = giteaApi("{$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) { + giteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token); + giteaApi("{$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); +} + +exit(0); diff --git a/cli/updates_xml_build.php b/cli/updates_xml_build.php new file mode 100644 index 0000000..8217a6e --- /dev/null +++ b/cli/updates_xml_build.php @@ -0,0 +1,334 @@ +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.CLI + * INGROUP: MokoStandards + * 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; + +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; +} + +if ($version === null) { + fwrite(STDERR, "Usage: updates_xml_build.php --path . --version XX.YY.ZZ [--stability stable] [--sha SHA]\n"); + exit(1); +} + +$root = realpath($path) ?: $path; + +// -- 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), '([^<]+)<\/name>/', $xml, $m)) $extName = $m[1]; + +$extType = ''; +if (preg_match('/]*type="([^"]+)"/', $xml, $m)) $extType = $m[1]; + +$extElement = ''; +if (preg_match('/([^<]+)<\/element>/', $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; + } +} + +$extClient = ''; +if (preg_match('/]*client="([^"]+)"/', $xml, $m)) $extClient = $m[1]; + +$extFolder = ''; +if (preg_match('/]*group="([^"]+)"/', $xml, $m)) $extFolder = $m[1]; + +$targetPlatform = ''; +if (preg_match('/()/', $xml, $m)) $targetPlatform = $m[1]; +if (empty($targetPlatform)) { + $targetPlatform = ''; +} + +$phpMinimum = ''; +if (preg_match('/([^<]+)<\/php_minimum>/', $xml, $m)) $phpMinimum = $m[1]; + +// Resolve language key names (e.g. PLG_SYSTEM_MOKOJOOMTOS) +if (preg_match('/^[A-Z_]+$/', $extName)) { + $iniFiles = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (preg_match('/\.sys\.ini$/i', $file->getFilename())) { + $iniFiles[] = $file->getPathname(); + } + } + foreach ($iniFiles as $ini) { + $content = file_get_contents($ini); + if (preg_match('/^' . preg_quote($extName, '/') . '="([^"]+)"/m', $content, $m)) { + $extName = $m[1]; + break; + } + } +} + +// Fallbacks +if (empty($extName)) $extName = $repo ?: basename($root); +if (empty($extType)) $extType = 'component'; + +// -- 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"; + } +} + +// -- Stability suffix map ----------------------------------------------------- +$stabilitySuffixMap = [ + 'stable' => '', + 'rc' => '-rc', + 'beta' => '-beta', + 'alpha' => '-alpha', + 'development' => '-dev', +]; + +$stabilityTagMap = [ + 'stable' => 'stable', + 'rc' => 'rc', + 'beta' => 'beta', + 'alpha' => 'alpha', + 'development' => 'dev', +]; + +// -- Build update entries ----------------------------------------------------- +$releaseTag = $stabilityTagMap[$stability] ?? $stability; + +// For the primary entry: apply suffix if not stable +$primarySuffix = $stabilitySuffixMap[$stability] ?? ''; +$primaryVersion = $version . $primarySuffix; + +$downloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$releaseTag}/{$typePrefix}{$extElement}-{$primaryVersion}.zip"; +$infoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$releaseTag}"; + +// Build client tag +$clientTag = ''; +if (!empty($extClient)) { + $clientTag = " {$extClient}"; +} elseif ($extType === 'module' || $extType === 'plugin') { + $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 $extName, + string $extElement, + string $extType, + string $clientTag, + string $folderTag, + string $infoUrl, + string $targetPlatform, + string $phpTag, + string $shaTag +): string { + $lines = []; + $lines[] = ' '; + $lines[] = " {$extName}"; + $lines[] = " {$extName} update"; + $lines[] = " {$extElement}"; + $lines[] = " {$extType}"; + $lines[] = " {$entryVersion}"; + if (!empty($clientTag)) $lines[] = $clientTag; + if (!empty($folderTag)) $lines[] = $folderTag; + $lines[] = " {$tagName}"; + $lines[] = " {$infoUrl}"; + $lines[] = ' '; + $lines[] = " {$entryDownloadUrl}"; + $lines[] = ' '; + if (!empty($shaTag)) $lines[] = $shaTag; + $lines[] = " {$targetPlatform}"; + if (!empty($phpTag)) $lines[] = $phpTag; + $lines[] = ' Moko Consulting'; + $lines[] = ' https://mokoconsulting.tech'; + $lines[] = ' '; + return implode("\n", $lines); +} + +// -- Determine which channels to write ---------------------------------------- +// Stable cascades to all channels; pre-releases only write their level and below +// Each channel gets its own suffixed version: +// development -> 04.01.00-dev +// alpha -> 04.01.00-alpha +// beta -> 04.01.00-beta +// rc -> 04.01.00-rc +// stable -> 04.01.00 +$allChannels = ['development', 'alpha', 'beta', 'rc', 'stable']; +$stabilityIndex = array_search($stability === 'development' ? 'development' : $stability, $allChannels); +if ($stabilityIndex === false) $stabilityIndex = 4; // default to stable + +// Write entries for this stability and all below it +$entries = []; +for ($i = 0; $i <= $stabilityIndex; $i++) { + $channelName = $allChannels[$i]; + $channelSuffix = $stabilitySuffixMap[$channelName] ?? ''; + $channelVersion = $version . $channelSuffix; + $channelTag = $stabilityTagMap[$channelName] ?? $channelName; + $channelDownloadUrl = "{$giteaUrl}/{$org}/{$repo}/releases/download/{$channelTag}/{$typePrefix}{$extElement}-{$channelVersion}.zip"; + $channelInfoUrl = "{$giteaUrl}/{$org}/{$repo}/releases/tag/{$channelTag}"; + + $entries[] = buildEntry( + $channelName, + $channelVersion, + $channelDownloadUrl, + $extName, + $extElement, + $extType, + $clientTag, + $folderTag, + $channelInfoUrl, + $targetPlatform, + $phpTag, + $shaTag + ); +} + +// -- Write updates.xml -------------------------------------------------------- +$year = date('Y'); +$output = << + + + +XML; +$output .= "\n" . implode("\n", $entries) . "\n\n"; + +$dest = $outputFile ?? "{$root}/updates.xml"; +file_put_contents($dest, $output); + +$channelCount = count($entries); +echo "updates.xml: {$primaryVersion} ({$channelCount} channel(s), stability={$stability})\n"; +echo "Output: {$dest}\n"; +exit(0); diff --git a/cli/version_set_platform.php b/cli/version_set_platform.php index ea3c32a..852a669 100644 --- a/cli/version_set_platform.php +++ b/cli/version_set_platform.php @@ -10,6 +10,13 @@ * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/version_set_platform.php * BRIEF: Set version in platform-specific files (Dolibarr $this->version, Joomla ) + * + * Usage: + * php version_set_platform.php --path . --version 04.01.00 + * php version_set_platform.php --path . --version 04.01.00 --stability alpha + * + * When --stability is set to anything other than "stable", the suffix is + * appended to the version (e.g. 04.01.00-dev, 04.01.00-alpha, 04.01.00-rc). */ declare(strict_types=1); @@ -17,10 +24,13 @@ declare(strict_types=1); $path = '.'; $version = null; $branch = null; +$stability = 'stable'; + 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 === '--branch' && isset($argv[$i + 1])) $branch = $argv[$i + 1]; + if ($arg === '--stability' && isset($argv[$i + 1])) $stability = $argv[$i + 1]; } // Auto-detect branch from git or GitHub env @@ -32,10 +42,26 @@ if ($branch === null) { } if ($version === null) { - fwrite(STDERR, "Usage: version_set_platform.php --path . --version development\n"); + fwrite(STDERR, "Usage: version_set_platform.php --path . --version 04.01.00 [--stability dev]\n"); exit(1); } +// Append stability suffix for non-stable releases +$stabilitySuffixMap = [ + 'stable' => '', + 'development' => '-dev', + 'dev' => '-dev', + 'alpha' => '-alpha', + 'beta' => '-beta', + 'rc' => '-rc', + 'release-candidate' => '-rc', +]; +$suffix = $stabilitySuffixMap[$stability] ?? ''; +if ($suffix !== '' && !str_ends_with($version, $suffix)) { + $version .= $suffix; + echo "Version with stability suffix: {$version}\n"; +} + $root = realpath($path) ?: $path; // Detect platform