#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoPlatform.Automation * INGROUP: MokoPlatform.Scripts * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /automation/bulk_joomla_template.php * BRIEF: Bulk scaffold and sync Joomla template repositories * * USAGE * php automation/bulk_joomla_template.php --scaffold --name=MokoTheme * php automation/bulk_joomla_template.php --scaffold --name=MokoTheme --client=administrator * php automation/bulk_joomla_template.php --sync --repos=MokoTheme,MokoDarkTheme * php automation/bulk_joomla_template.php --sync --all * php automation/bulk_joomla_template.php --list */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\{ AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory }; /** * Bulk Joomla Template Manager * * Provides three operations for Joomla template projects: * --scaffold: Create a new template repository with the full directory structure * --sync: Push moko-platform files to existing template repositories * --list: List all repositories tagged as joomla-template * * Works with both GitHub and Gitea via the PlatformAdapterFactory. */ class BulkJoomlaTemplate extends CliFramework { public const DEFAULT_ORG = 'MokoConsulting'; public const VERSION = '09.23.00'; private GitPlatformAdapter $adapter; private Config $config; protected function configure(): void { $this->setDescription('Bulk Joomla template management'); $this->addArgument('--org', 'Organization', self::DEFAULT_ORG); $this->addArgument('--scaffold', 'Create new template repo', false); $this->addArgument('--sync', 'Sync files to template repos', false); $this->addArgument('--list', 'List template repos', false); $this->addArgument('--name', 'Template name for scaffold', ''); $this->addArgument('--client', 'Joomla client: site or admin', 'site'); $this->addArgument('--repos', 'Target repos (comma-separated)', ''); $this->addArgument('--all', 'Sync all tagged repos', false); $this->addArgument('--sync-updates', 'Sync updates.xml', false); $this->addArgument('--private', 'Create as private repo', false); $this->addArgument('--yes', 'Auto-confirm', false); } protected function run(): int { $this->log("šŸŽØ Joomla Template Manager v" . self::VERSION, 'INFO'); $this->config = Config::load(); try { $this->adapter = PlatformAdapterFactory::create($this->config); } catch (\Exception $e) { $this->log("āŒ Failed to initialize: " . $e->getMessage(), 'ERROR'); return 1; } $org = $this->getArgument('--org', self::DEFAULT_ORG); $platform = $this->adapter->getPlatformName(); $this->log("Platform: {$platform} | Organization: {$org}", 'INFO'); if ($this->getArgument('--list', false)) { return $this->listTemplateRepos($org); } if ($this->getArgument('--scaffold', false)) { return $this->scaffoldTemplate($org); } if ($this->getArgument('--sync', false)) { return $this->syncTemplates($org); } if ($this->getArgument('--sync-updates', false)) { return $this->syncUpdatesBetweenPlatforms($org); } $this->log("āŒ Specify --scaffold, --sync, --sync-updates, or --list", 'ERROR'); return 1; } // ── List ───────────────────────────────────────────────────────────── private function listTemplateRepos(string $org): int { $repos = $this->findTemplateRepos($org); if (empty($repos)) { $this->log("No joomla-template repositories found in {$org}", 'INFO'); return 0; } $this->log("\nJoomla template repositories in {$org}:", 'INFO'); foreach ($repos as $repo) { $vis = ($repo['private'] ?? false) ? 'private' : 'public'; $url = $this->adapter->getRepoWebUrl($org, $repo['name']); $this->log(" - {$repo['name']} ({$vis}) {$url}", 'INFO'); } $this->log("\nTotal: " . count($repos), 'INFO'); return 0; } // ── Scaffold ───────────────────────────────────────────────────────── private function scaffoldTemplate(string $org): int { $name = $this->getArgument('--name', ''); $client = $this->getArgument('--client', 'site'); $dryRun = $this->dryRun; if (empty($name)) { $this->log("āŒ --name is required for --scaffold", 'ERROR'); $this->log(" Example: --name=MokoTheme", 'ERROR'); return 1; } if (!in_array($client, ['site', 'administrator'], true)) { $this->log("āŒ --client must be 'site' or 'administrator'", 'ERROR'); return 1; } $shortName = $this->deriveShortName($name); $this->log("\nScaffolding Joomla template:", 'INFO'); $this->log(" Name: {$name}", 'INFO'); $this->log(" Short name: {$shortName}", 'INFO'); $this->log(" Client: {$client}", 'INFO'); $this->log(" Element: tpl_{$shortName}", 'INFO'); if ($dryRun) { $this->log("\n[DRY RUN] Would create repository and scaffold files", 'INFO'); $this->printScaffoldPlan($shortName); return 0; } // Check if repo already exists try { $this->adapter->getRepo($org, $name); $this->log("āŒ Repository {$org}/{$name} already exists", 'ERROR'); return 1; } catch (\Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } // Confirm if (!$this->getArgument('--yes', false)) { echo "\nCreate repository {$org}/{$name}? [y/N]: "; $handle = fopen('php://stdin', 'r'); $line = fgets($handle); if ($handle) { fclose($handle); } if (!is_string($line) || strtolower(trim($line)) !== 'y') { $this->log("Cancelled.", 'INFO'); return 0; } } // Create repository $this->log("\nCreating repository...", 'INFO'); try { $isPrivate = $this->getArgument('--private', false); $this->adapter->createOrgRepo($org, $name, [ 'description' => "Joomla {$client} template — {$name}", 'private' => $isPrivate, 'auto_init' => true, ]); $this->log(" āœ“ Repository created: {$org}/{$name}", 'INFO'); } catch (\Exception $e) { $this->log("āŒ Failed to create repository: " . $e->getMessage(), 'ERROR'); return 1; } // Set topics try { $this->adapter->setRepoTopics($org, $name, [ 'joomla', 'joomla-template', 'template', "joomla-{$client}", ]); $this->log(" āœ“ Topics set", 'INFO'); } catch (\Exception $e) { $this->log(" āš ļø Could not set topics: " . $e->getMessage(), 'WARN'); $this->adapter->getApiClient()->resetCircuitBreaker(); } // Scaffold files $this->log("\nScaffolding template files...", 'INFO'); $files = $this->getScaffoldFiles($name, $shortName, $client, $org); $created = 0; foreach ($files as $path => $content) { try { $this->adapter->createOrUpdateFile( $org, $name, $path, $content, "chore: scaffold {$path}" ); $this->log(" āœ“ {$path}", 'INFO'); $created++; } catch (\Exception $e) { $this->log(" āœ— {$path}: " . $e->getMessage(), 'ERROR'); $this->adapter->getApiClient()->resetCircuitBreaker(); } } // Apply branch protection try { $this->adapter->setBranchProtection($org, $name, 'main', [ 'required_reviews' => 1, 'dismiss_stale' => true, 'block_on_rejected' => true, ]); $this->log(" āœ“ Branch protection applied", 'INFO'); } catch (\Exception $e) { $this->log(" āš ļø Branch protection: " . $e->getMessage(), 'WARN'); $this->adapter->getApiClient()->resetCircuitBreaker(); } $url = $this->adapter->getRepoWebUrl($org, $name); $this->log("\nāœ… Template scaffolded: {$url}", 'INFO'); $this->log(" {$created} files created", 'INFO'); return 0; } // ── Sync ───────────────────────────────────────────────────────────── private function syncTemplates(string $org): int { $repos = []; if ($this->getArgument('--all', false)) { $repos = $this->findTemplateRepos($org); } else { $reposArg = $this->getArgument('--repos', ''); if (empty($reposArg)) { $this->log("āŒ --repos or --all required for --sync", 'ERROR'); return 1; } $names = array_filter(array_map('trim', explode(',', $reposArg))); foreach ($names as $name) { $repos[] = ['name' => $name]; } } if (empty($repos)) { $this->log("No template repositories to sync", 'INFO'); return 0; } $this->log("\nSyncing " . count($repos) . " template repo(s)...", 'INFO'); $dryRun = $this->dryRun; $success = 0; $failed = 0; foreach ($repos as $repo) { $name = $repo['name']; $this->log("\n[{$name}]", 'INFO'); try { $repoData = $this->adapter->getRepo($org, $name); $shortName = $this->deriveShortName($name); $branch = $repoData['default_branch'] ?? 'main'; $syncFiles = $this->getSyncFiles($name, $shortName); $updated = 0; foreach ($syncFiles as $path => $content) { if ($dryRun) { $this->log(" (dry-run) {$path}", 'INFO'); $updated++; continue; } // Check if file exists $existingSha = null; try { $existing = $this->adapter->getFileContents($org, $name, $path, $branch); $existingSha = $existing['sha'] ?? null; } catch (\Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } try { $this->adapter->createOrUpdateFile( $org, $name, $path, $content, "chore: update {$path} from moko-platform", $existingSha, $branch ); $this->log(" āœ“ {$path}", 'INFO'); $updated++; } catch (\Exception $e) { $this->log(" āœ— {$path}: " . $e->getMessage(), 'ERROR'); $this->adapter->getApiClient()->resetCircuitBreaker(); } } $this->log(" {$updated} file(s) synced", 'INFO'); $success++; } catch (\Exception $e) { $this->log(" āœ— {$name}: " . $e->getMessage(), 'ERROR'); $failed++; $this->adapter->getApiClient()->resetCircuitBreaker(); } } $this->log("\n" . str_repeat('=', 50), 'INFO'); $this->log("Sync complete: {$success} succeeded, {$failed} failed", 'INFO'); return $failed > 0 ? 1 : 0; } // ── Helpers ────────────────────────────────────────────────────────── private function findTemplateRepos(string $org): array { $allRepos = $this->adapter->listOrgRepos($org, true); $templates = []; foreach ($allRepos as $repo) { try { $topics = $this->adapter->getRepoTopics($org, $repo['name']); if (in_array('joomla-template', $topics, true)) { $templates[] = $repo; } } catch (\Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } } return $templates; } private function deriveShortName(string $name): string { // MokoTheme → mokotheme, Moko-Dark-Theme → mokodarktheme return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $name)); } private function printScaffoldPlan(string $shortName): void { $files = [ 'templateDetails.xml', 'updates.xml', 'src/index.php', 'src/error.php', 'src/offline.php', 'src/component.php', 'src/html/index.html', 'src/css/.gitkeep', 'src/js/.gitkeep', 'src/images/.gitkeep', "src/language/en-GB/tpl_{$shortName}.ini", "src/language/en-GB/tpl_{$shortName}.sys.ini", 'media/css/.gitkeep', 'media/js/.gitkeep', 'media/images/.gitkeep', 'media/scss/.gitkeep', '.editorconfig', ]; $this->log("\nFiles that would be created:", 'INFO'); foreach ($files as $f) { $this->log(" + {$f}", 'INFO'); } } /** * Generate the full set of scaffold files for a new template. * * @return array path => content */ private function getScaffoldFiles(string $name, string $shortName, string $client, string $org): array { $element = "tpl_{$shortName}"; $now = date('Y-m-d'); $files = []; // templateDetails.xml $files['templateDetails.xml'] = << {$name} {$now} Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later 1.0.0 {$name} — Joomla {$client} template by Moko Consulting index.php component.php error.php offline.php templateDetails.xml html css js images language css js images scss topbar navbar hero breadcrumbs sidebar-left sidebar-right main-top main-bottom footer debug https://git.mokoconsulting.tech/{$org}/{$name}/raw/branch/main/updates.xml https://raw.githubusercontent.com/{$org}/{$name}/main/updates.xml
XML; $files['templateDetails.xml'] = preg_replace('/^\t\t/m', '', $files['templateDetails.xml']); // updates.xml — dual-platform download URLs (Gitea primary, GitHub secondary) $files['updates.xml'] = << {$name} {$name} — Moko Consulting Joomla template tpl_{$shortName} template 1.0.0 https://git.mokoconsulting.tech/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip https://github.com/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip 8.1 XML; $files['updates.xml'] = preg_replace('/^\t\t/m', '', $files['updates.xml']); // src/index.php $files['src/index.php'] = <<<'PHP' getWebAssetManager(); ?>
PHP; $files['src/index.php'] = str_replace('TEMPLATE_SHORT_NAME', $shortName, $files['src/index.php']); $files['src/index.php'] = preg_replace('/^\t\t/m', '', $files['src/index.php']); // src/error.php $files['src/error.php'] = <<<'PHP' error->getCode(); $message = htmlspecialchars($this->error->getMessage(), ENT_QUOTES, 'UTF-8'); ?> <?php echo $code; ?> — <?php echo $message; ?> PHP; $files['src/error.php'] = preg_replace('/^\t\t/m', '', $files['src/error.php']); // src/offline.php $files['src/offline.php'] = <<<'PHP' <?php echo htmlspecialchars($app->get('sitename')); ?> — Maintenance

get('sitename')); ?>

get('offline_message', 'This site is currently undergoing maintenance. Please check back soon.'); ?>

PHP; $files['src/offline.php'] = preg_replace('/^\t\t/m', '', $files['src/offline.php']); // src/component.php $files['src/component.php'] = <<<'PHP' PHP; $files['src/component.php'] = preg_replace('/^\t\t/m', '', $files['src/component.php']); // Directory keepfiles $files['src/html/index.html'] = ''; $files['src/css/.gitkeep'] = ''; $files['src/js/.gitkeep'] = ''; $files['src/images/.gitkeep'] = ''; $files['media/css/.gitkeep'] = ''; $files['media/js/.gitkeep'] = ''; $files['media/images/.gitkeep'] = ''; $files['media/scss/.gitkeep'] = ''; // Language files $files["src/language/en-GB/{$element}.ini"] = "; {$name} language strings\n"; $files["src/language/en-GB/{$element}.sys.ini"] = "; {$name} system language strings\n" . "{$element}=\"{$name}\"\n" . "{$element}_XML_DESCRIPTION=\"{$name} Joomla template by Moko Consulting\"\n"; // .editorconfig $repoRoot = dirname(__DIR__, 2); $editorConfig = "{$repoRoot}/templates/configs/.editorconfig"; if (file_exists($editorConfig)) { $files['.editorconfig'] = file_get_contents($editorConfig) ?: ''; } return $files; } /** * Get files to sync to existing template repos (standards-only, no template code). * * @return array path => content */ private function getSyncFiles(string $name, string $shortName): array { $repoRoot = dirname(__DIR__, 2); $files = []; // Sync standards files from templates/ $standardsFiles = [ 'SECURITY.md' => 'templates/docs/required/template-SECURITY.md', 'CODE_OF_CONDUCT.md' => 'templates/docs/extra/template-CODE_OF_CONDUCT.md', 'CONTRIBUTING.md' => 'templates/docs/required/template-CONTRIBUTING.md', '.editorconfig' => 'templates/configs/.editorconfig', ]; foreach ($standardsFiles as $dest => $source) { $fullPath = "{$repoRoot}/{$source}"; if (file_exists($fullPath)) { $files[$dest] = file_get_contents($fullPath) ?: ''; } } return $files; } // ── Sync updates.xml between platforms ─────────────────────────────── /** * Sync updates.xml (or updates.xml) between Gitea and GitHub for Joomla repos. * * Reads the file from both platforms, compares by latest tag, * and pushes the newer one to the stale platform. * * Designed to be called from a CI workflow via: * php automation/bulk_joomla_template.php --sync-updates --repos=MokoCassiopeia */ private function syncUpdatesBetweenPlatforms(string $org): int { $repos = []; if ($this->getArgument('--all', false)) { $repos = $this->findTemplateRepos($org); // Also include waas-component repos $allRepos = $this->adapter->listOrgRepos($org, true); foreach ($allRepos as $repo) { try { $topics = $this->adapter->getRepoTopics($org, $repo['name']); if (in_array('joomla', $topics, true) || in_array('joomla-extension', $topics, true)) { $repos[] = $repo; } } catch (\Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } } // Deduplicate $seen = []; $repos = array_filter($repos, function ($r) use (&$seen) { if (isset($seen[$r['name']])) { return false; } $seen[$r['name']] = true; return true; }); } else { $reposArg = $this->getArgument('--repos', ''); if (empty($reposArg)) { $this->log("āŒ --repos or --all required for --sync-updates", 'ERROR'); return 1; } $names = array_filter(array_map('trim', explode(',', $reposArg))); foreach ($names as $name) { $repos[] = ['name' => $name]; } } if (empty($repos)) { $this->log("No Joomla repositories to sync updates for", 'INFO'); return 0; } // Create both platform adapters try { $adapters = PlatformAdapterFactory::createBoth($this->config); } catch (\Exception $e) { $this->log("āŒ Both platform tokens required for --sync-updates: " . $e->getMessage(), 'ERROR'); return 1; } $gitea = $adapters['gitea']; $github = $adapters['github']; $dryRun = $this->dryRun; $this->log("\nSyncing updates.xml across Gitea <-> GitHub for " . count($repos) . " repo(s)...", 'INFO'); $synced = 0; $failed = 0; foreach ($repos as $repo) { $name = $repo['name']; $this->log("\n[{$name}]", 'INFO'); // Try both updates.xml and updates.xml filenames $updateFile = $this->resolveUpdateFile($gitea, $github, $org, $name); if ($updateFile === null) { $this->log(" ⊘ No update(s).xml found on either platform", 'INFO'); continue; } $fileName = $updateFile['name']; $source = $updateFile['source']; // 'gitea' or 'github' $content = $updateFile['content']; $target = $source === 'gitea' ? 'github' : 'gitea'; $targetAdapter = $source === 'gitea' ? $github : $gitea; $this->log(" Source: {$source} ({$fileName})", 'INFO'); if ($dryRun) { $this->log(" (dry-run) Would push {$fileName} to {$target}", 'INFO'); $synced++; continue; } // Push to the other platform try { $existingSha = null; try { $existing = $targetAdapter->getFileContents($org, $name, $fileName); $existingSha = $existing['sha'] ?? null; // Compare content — skip if identical $existingContent = base64_decode($existing['content'] ?? ''); if (trim($existingContent) === trim($content)) { $this->log(" āœ“ Already in sync", 'INFO'); $synced++; continue; } } catch (\Exception $e) { $targetAdapter->getApiClient()->resetCircuitBreaker(); } $targetAdapter->createOrUpdateFile( $org, $name, $fileName, $content, "chore: sync {$fileName} from {$source}", $existingSha ); $this->log(" āœ“ Pushed to {$target}", 'INFO'); $synced++; } catch (\Exception $e) { $this->log(" āœ— Failed to push to {$target}: " . $e->getMessage(), 'ERROR'); $targetAdapter->getApiClient()->resetCircuitBreaker(); $failed++; } } $this->log("\n" . str_repeat('=', 50), 'INFO'); $this->log("Updates sync complete: {$synced} synced, {$failed} failed", 'INFO'); return $failed > 0 ? 1 : 0; } /** * Find the updates file on both platforms, return the one with the higher version. * * Checks both `updates.xml` and `updates.xml` filenames. * Returns the content from the platform with the newer . * Gitea wins ties (primary platform). * * @return array{name: string, source: string, content: string}|null */ private function resolveUpdateFile( GitPlatformAdapter $gitea, GitPlatformAdapter $github, string $org, string $name ): ?array { $candidates = ['updates.xml', 'updates.xml']; $found = []; // platform => [name, content, version] foreach (['gitea' => $gitea, 'github' => $github] as $platform => $adapter) { foreach ($candidates as $fileName) { try { $file = $adapter->getFileContents($org, $name, $fileName); $content = base64_decode($file['content'] ?? ''); // Extract latest version from the XML $version = '0.0.0'; if (preg_match('/([^<]+)<\/version>/', $content, $m)) { $version = trim($m[1]); } $found[$platform] = [ 'name' => $fileName, 'content' => $content, 'version' => $version, ]; break; // Found one — stop checking other filenames for this platform } catch (\Exception $e) { $adapter->getApiClient()->resetCircuitBreaker(); } } } if (empty($found)) { return null; } // If only one platform has it, that's the source if (count($found) === 1) { $platform = array_key_first($found); return [ 'name' => $found[$platform]['name'], 'source' => $platform, 'content' => $found[$platform]['content'], ]; } // Both have it — pick the one with the higher version (Gitea wins ties) $giteaVer = $found['gitea']['version']; $githubVer = $found['github']['version']; $source = version_compare($githubVer, $giteaVer, '>') ? 'github' : 'gitea'; return [ 'name' => $found[$source]['name'], 'source' => $source, 'content' => $found[$source]['content'], ]; } } // Execute if run directly if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { $app = new BulkJoomlaTemplate(); exit($app->execute()); }