#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Scripts.Deploy * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /deploy/deploy-joomla.php * BRIEF: Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest * * Parses the extension's XML manifest to determine type (component, module, * plugin, template, library, package) and deploys each section directly to * its correct location on the Joomla server. No reinstall needed for code * changes — only XML manifest changes require a Joomla reinstall. * * USAGE * php deploy/deploy-joomla.php --path . --config /tmp/sftp-config.json * php deploy/deploy-joomla.php --path . --config /tmp/sftp-config.json --dry-run * php deploy/deploy-joomla.php --path . --env dev */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use phpseclib3\Net\SFTP; use phpseclib3\Crypt\PublicKeyLoader; /** * Joomla-aware SFTP deployment. * * Reads the extension XML manifest to determine: * - Extension type (component, module, plugin, template, library) * - Element name (com_xxx, mod_xxx, plg_group_name, tpl_xxx) * - Client (site or administrator) * - Plugin group (system, content, etc.) * * Then maps src/ subdirectories to their correct Joomla server paths: * * Component: * src/admin/ → administrator/components/{element}/ * src/site/ → components/{element}/ * src/media/ → media/{element}/ * src/api/ → api/components/{element}/ * * Module: * src/ → modules/{element}/ (site) or administrator/modules/{element}/ (admin) * src/media/ → media/{element}/ * * Plugin: * src/ → plugins/{group}/{name}/ * src/media/ → media/{element}/ * * Template: * src/ → templates/{name}/ (site) or administrator/templates/{name}/ (admin) * src/media/ → media/templates/site/{name}/ or media/templates/administrator/{name}/ * * Library: * src/ → libraries/{name}/ * src/media/ → media/{element}/ */ class DeployJoomla { private string $repoPath; private string $srcDir; private array $config = []; private bool $dryRun = false; private bool $verbose = false; private int $uploaded = 0; private int $unchanged = 0; private int $skipped = 0; private int $deleted = 0; private array $ignorePatterns = []; public function run(): int { $this->parseArgs(); $manifest = $this->findManifest(); if ($manifest === null) { $this->log("No Joomla XML manifest found in {$this->srcDir}", 'ERROR'); return 1; } $ext = $this->parseManifest($manifest); if ($ext === null) { $this->log("Failed to parse manifest: {$manifest}", 'ERROR'); return 1; } $this->log("Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})"); $deployMap = $this->buildDeployMap($ext); if (empty($deployMap)) { $this->log("No deploy mappings for extension type: {$ext['type']}", 'ERROR'); return 1; } $this->log("Deploy mappings:"); foreach ($deployMap as $map) { $this->log(" {$map['local']} → {$map['remote']}"); } // Load ignore patterns $this->ignorePatterns = $this->loadFtpIgnore(); // Check if manifest changed (warn user about reinstall) $this->checkManifestChange($ext, $manifest); if ($this->dryRun) { $this->log("[DRY RUN] Would deploy " . count($deployMap) . " mappings"); foreach ($deployMap as $map) { if (is_dir($map['local'])) { $count = iterator_count( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS) ) ); $this->log(" {$map['local']} ({$count} files) → {$map['remote']}"); } } return 0; } // Connect $sftp = $this->connect(); if ($sftp === null) { return 1; } // Deploy each mapping $errors = 0; foreach ($deployMap as $map) { if (!is_dir($map['local'])) { $this->log(" SKIP: {$map['local']} (directory not found)", 'DEBUG'); continue; } // Ensure remote directory exists $stat = @$sftp->stat($map['remote']); if ($stat === false) { $this->log(" MKDIR: {$map['remote']}"); $sftp->mkdir($map['remote'], -1, true); } $result = $this->uploadDirectory($sftp, $map['local'], $map['remote']); if ($result !== 0) { $errors++; } } // Also deploy the manifest file itself to the admin component directory if ($ext['type'] === 'component' && file_exists($manifest)) { $adminRemote = $this->getRemotePath($ext, 'admin'); $manifestName = basename($manifest); $remoteDest = "{$adminRemote}/{$manifestName}"; $this->uploadFile($sftp, $manifest, $remoteDest); $this->log(" Manifest: {$manifestName} → {$remoteDest}"); } $this->log("Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}"); return $errors > 0 ? 1 : 0; } /** * Find the Joomla XML manifest file. */ private function findManifest(): ?string { $searchDirs = [$this->srcDir, $this->repoPath]; foreach ($searchDirs as $dir) { $iterator = new \DirectoryIterator($dir); foreach ($iterator as $file) { if ($file->isFile() && $file->getExtension() === 'xml') { $content = file_get_contents($file->getPathname()); if ($content !== false && str_contains($content, 'getPathname(); } } } // Also check one level deep foreach (new \DirectoryIterator($dir) as $subdir) { if ($subdir->isDir() && !$subdir->isDot()) { foreach (new \DirectoryIterator($subdir->getPathname()) as $file) { if ($file->isFile() && $file->getExtension() === 'xml') { $content = file_get_contents($file->getPathname()); if ($content !== false && str_contains($content, 'getPathname(); } } } } } } return null; } /** * Parse extension metadata from the XML manifest. * * @return array{type: string, element: string, client: string, group: string, name: string}|null */ private function parseManifest(string $path): ?array { $xml = @simplexml_load_file($path); if ($xml === false || $xml->getName() !== 'extension') { return null; } $type = (string) ($xml['type'] ?? 'component'); $client = (string) ($xml['client'] ?? 'site'); $group = (string) ($xml['group'] ?? ''); $element = (string) ($xml->element ?? ''); $name = (string) ($xml->name ?? ''); // Derive element from type + name if not explicit if (empty($element)) { $cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name)); $element = match ($type) { 'component' => "com_{$cleanName}", 'module' => "mod_{$cleanName}", 'plugin' => "plg_{$group}_{$cleanName}", 'template' => "tpl_{$cleanName}", 'library' => "lib_{$cleanName}", default => $cleanName, }; } // For plugins, derive the short name (without plg_group_ prefix) $shortName = $element; if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) { $shortName = $m[1]; } elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) { $shortName = $m[1]; } return [ 'type' => $type, 'element' => $element, 'client' => $client, 'group' => $group, 'name' => $name, 'shortName' => $shortName, ]; } /** * Build the local→remote deploy mapping based on extension type. * * @return array */ private function buildDeployMap(array $ext): array { $remotePath = rtrim((string) $this->config['remote_path'], '/'); $src = $this->srcDir; $map = []; switch ($ext['type']) { case 'component': // Admin files if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) { $adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator"; $map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"]; } // Site files if (is_dir("{$src}/site")) { $map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"]; } // Media files if (is_dir("{$src}/media")) { $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; } // API files (Joomla 4+) if (is_dir("{$src}/api")) { $map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"]; } // Language files (admin) if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) { $langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language"; $map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"]; } // Language files (site) if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) { $langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language"; $map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"]; } break; case 'module': $base = $ext['client'] === 'administrator' ? "{$remotePath}/administrator/modules/{$ext['element']}" : "{$remotePath}/modules/{$ext['element']}"; $map[] = ['local' => $src, 'remote' => $base]; if (is_dir("{$src}/media")) { $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; } break; case 'plugin': $map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"]; if (is_dir("{$src}/media")) { $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; } break; case 'template': $clientDir = $ext['client'] === 'administrator' ? 'administrator/' : ''; $map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"]; if (is_dir("{$src}/media")) { $mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site'; $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"]; } break; case 'library': $map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"]; if (is_dir("{$src}/media")) { $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; } break; case 'package': // Packages deploy their sub-extensions individually // For now, deploy to administrator/manifests/packages/ $map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"]; break; } return $map; } /** * Get the remote path for a specific section of the extension. */ private function getRemotePath(array $ext, string $section): string { $remotePath = rtrim((string) $this->config['remote_path'], '/'); return match ($section) { 'admin' => "{$remotePath}/administrator/components/{$ext['element']}", 'site' => "{$remotePath}/components/{$ext['element']}", 'media' => "{$remotePath}/media/{$ext['element']}", default => $remotePath, }; } /** * Check if the XML manifest has changed and warn about reinstall. */ private function checkManifestChange(array $ext, string $manifestPath): void { $manifestName = basename($manifestPath); $this->log(""); $this->log("NOTE: If {$manifestName} has changed (new fields, permissions, menu items,"); $this->log(" database schema), you must reinstall the extension through Joomla."); $this->log(" Code changes (PHP, JS, CSS, language) do NOT require reinstall."); $this->log(""); } /** * Upload a directory recursively to the remote server. */ private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int { $errors = 0; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $item) { $relativePath = substr($item->getPathname(), strlen($localDir) + 1); $relativePath = str_replace('\\', '/', $relativePath); $remotePath = "{$remoteDir}/{$relativePath}"; // Check ignore patterns if ($this->shouldIgnore($relativePath)) { $this->skipped++; continue; } if ($item->isDir()) { $stat = @$sftp->stat($remotePath); if ($stat === false) { $sftp->mkdir($remotePath, -1, true); } } else { $result = $this->uploadFile($sftp, $item->getPathname(), $remotePath); if (!$result) { $errors++; } } } return $errors; } /** * Upload a single file with smart diff (skip if unchanged). */ private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool { $localSize = filesize($localPath); $remoteStat = @$sftp->stat($remotePath); // Smart diff: skip if same size and hash if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) { $remoteContent = @$sftp->get($remotePath); if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) { $this->unchanged++; return true; } } // Ensure parent directory exists $parentDir = dirname($remotePath); $parentStat = @$sftp->stat($parentDir); if ($parentStat === false) { $sftp->mkdir($parentDir, -1, true); } $result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE); if ($result) { $this->uploaded++; if ($this->verbose) { $this->log(" UPLOAD: {$remotePath}"); } } else { $this->log(" FAIL: {$remotePath}", 'ERROR'); } return $result; } /** * Check if a relative path should be ignored. */ private function shouldIgnore(string $relativePath): bool { foreach ($this->ignorePatterns as $pattern) { if (preg_match($pattern, $relativePath)) { return true; } } // Always skip dotfiles and common non-deploy files $basename = basename($relativePath); if (str_starts_with($basename, '.') && $basename !== '.htaccess') { return true; } return false; } /** * Load .ftpignore patterns. * * @return string[] Regex patterns */ private function loadFtpIgnore(): array { $patterns = []; foreach ([$this->srcDir, $this->repoPath] as $dir) { $file = "{$dir}/.ftpignore"; if (!file_exists($file)) { continue; } foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '#')) { continue; } // Convert glob to regex $regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line); $patterns[] = "#^{$regex}(/|$)#i"; } } return $patterns; } /** * Connect to the SFTP server. */ private function connect(): ?SFTP { $host = (string) $this->config['host']; $port = (int) ($this->config['port'] ?? 22); $user = (string) $this->config['user']; $this->log("Connecting to {$user}@{$host}:{$port}..."); $sftp = new SFTP($host, $port, 30); // Try key auth first if (!empty($this->config['ssh_key_file'])) { $keyPath = $this->config['ssh_key_file']; if (!file_exists($keyPath)) { $keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}"; } if (file_exists($keyPath)) { $passphrase = $this->config['key_passphrase'] ?? ''; $key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase); if ($sftp->login($user, $key)) { $this->log("Connected via SSH key"); return $sftp; } $this->log("Key auth failed", 'WARN'); } } // Fallback to password if (!empty($this->config['password'])) { if ($sftp->login($user, $this->config['password'])) { $this->log("Connected via password"); return $sftp; } } $this->log("Authentication failed", 'ERROR'); return null; } /** * Parse CLI arguments. */ private function parseArgs(): void { global $argv; $this->repoPath = '.'; $this->srcDir = 'src'; $configPath = null; foreach ($argv as $i => $arg) { if ($arg === '--path' && isset($argv[$i + 1])) { $this->repoPath = $argv[$i + 1]; } if ($arg === '--src-dir' && isset($argv[$i + 1])) { $this->srcDir = $argv[$i + 1]; } if ($arg === '--config' && isset($argv[$i + 1])) { $configPath = $argv[$i + 1]; } if ($arg === '--key-passphrase' && isset($argv[$i + 1])) { $this->config['key_passphrase'] = $argv[$i + 1]; } if ($arg === '--dry-run') { $this->dryRun = true; } if ($arg === '--verbose') { $this->verbose = true; } } $this->repoPath = realpath($this->repoPath) ?: $this->repoPath; // Resolve src dir if (!str_starts_with($this->srcDir, '/')) { $this->srcDir = "{$this->repoPath}/{$this->srcDir}"; } // Try htdocs/ as fallback if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) { $this->srcDir = "{$this->repoPath}/htdocs"; } // Load config if ($configPath && file_exists($configPath)) { $json = file_get_contents($configPath); $json = preg_replace('#^\s*//.*$#m', '', $json); $json = preg_replace('#,\s*([\]}])#', '$1', $json); $parsed = json_decode($json, true); if (is_array($parsed)) { $this->config = array_merge($this->config, $parsed); } } } private function log(string $msg, string $level = 'INFO'): void { $prefix = match ($level) { 'ERROR' => 'ERROR: ', 'WARN' => 'WARN: ', 'DEBUG' => $this->verbose ? '' : null, default => '', }; if ($prefix === null) { return; } fwrite($level === 'ERROR' ? STDERR : STDOUT, "{$prefix}{$msg}\n"); } } $deploy = new DeployJoomla(); exit($deploy->run());