#!/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/sync-joomla.php * VERSION: 01.00.00 * BRIEF: Sync Joomla site directories between two servers via rsync over SSH */ declare(strict_types=1); class SyncJoomla { /** @var string Path to source sftp-config.json */ private string $sourceConfig = ''; /** @var string Path to dest sftp-config.json */ private string $destConfig = ''; /** @var bool Sync standard Joomla directories only */ private bool $rsyncMode = false; /** @var bool Sync everything under remote_path */ private bool $fullMode = false; /** @var bool Dry-run (preview only) */ private bool $dryRun = false; /** @var bool Verbose output */ private bool $verbose = false; /** @var string[] Additional exclude patterns */ private array $excludes = []; /** @var string Local relay directory */ private string $relayDir = '/tmp/sync/'; /** @var string[] Standard Joomla directories to sync */ private array $joomlaDirs = [ 'administrator/components', 'administrator/language', 'administrator/modules', 'administrator/templates', 'components', 'language', 'layouts', 'libraries', 'media', 'modules', 'plugins', 'templates', ]; /** * Main entry point. * * @return int Exit code */ public function run(): int { $this->parseArgs(); if (!$this->validate()) { return 1; } $source = $this->loadConfig($this->sourceConfig); $dest = $this->loadConfig($this->destConfig); if ($source === null || $dest === null) { return 1; } $this->log("Source: {$source['user']}@{$source['host']}:{$source['remote_path']}"); $this->log("Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}"); if ($this->dryRun) { $this->log('[DRY-RUN] No files will be transferred.'); } $this->prepareRelayDir(); $dirs = $this->resolveDirs(); $totalFiles = 0; $syncedDirs = 0; foreach ($dirs as $dir) { $this->log("--- Syncing: {$dir}"); $pulled = $this->pullFromSource($source, $dir); if ($pulled === false) { $this->log(" WARNING: pull failed for {$dir}, skipping."); continue; } $pushed = $this->pushToDest($dest, $dir); if ($pushed === false) { $this->log(" WARNING: push failed for {$dir}, skipping."); continue; } $totalFiles += $pulled + $pushed; $syncedDirs++; } $this->cleanup(); $this->log(''); $this->log('=== Sync Summary ==='); $this->log("Directories synced: {$syncedDirs}/" . count($dirs)); $this->log("Rsync operations: " . ($syncedDirs * 2) . " (pull + push)"); if ($this->dryRun) { $this->log('Mode: dry-run (no files were transferred)'); } return 0; } /** * Parse command-line arguments. */ private function parseArgs(): void { global $argv; $i = 1; while ($i < count($argv)) { switch ($argv[$i]) { case '--source': $this->sourceConfig = $argv[++$i] ?? ''; break; case '--dest': $this->destConfig = $argv[++$i] ?? ''; break; case '--rsync': $this->rsyncMode = true; break; case '--full': $this->fullMode = true; break; case '--dry-run': $this->dryRun = true; break; case '--verbose': $this->verbose = true; break; case '--exclude': $this->excludes[] = $argv[++$i] ?? ''; break; default: $this->log("Unknown argument: {$argv[$i]}"); break; } $i++; } } /** * Validate required arguments. * * @return bool True if valid */ private function validate(): bool { if ($this->sourceConfig === '' || $this->destConfig === '') { $this->log('ERROR: --source and --dest are required.'); $this->printUsage(); return false; } if (!$this->rsyncMode && !$this->fullMode) { $this->log('ERROR: Either --rsync or --full must be specified.'); $this->printUsage(); return false; } if ($this->rsyncMode && $this->fullMode) { $this->log('ERROR: --rsync and --full are mutually exclusive.'); return false; } if (!file_exists($this->sourceConfig)) { $this->log("ERROR: Source config not found: {$this->sourceConfig}"); return false; } if (!file_exists($this->destConfig)) { $this->log("ERROR: Dest config not found: {$this->destConfig}"); return false; } return true; } /** * Load and decode an sftp-config.json file. * * @param string $path Path to the config file * @return array|null Parsed config or null on error */ private function loadConfig(string $path): ?array { $json = file_get_contents($path); if ($json === false) { $this->log("ERROR: Cannot read config: {$path}"); return null; } // Strip // comments (Sublime Text SFTP format) $json = preg_replace('#^\s*//.*$#m', '', $json); $json = preg_replace('#,\s*([\]}])#', '$1', $json); $config = json_decode($json, true); if (!is_array($config)) { $this->log("ERROR: Invalid JSON in config: {$path}"); return null; } $required = ['host', 'user', 'remote_path', 'ssh_key_file']; foreach ($required as $key) { if (empty($config[$key])) { $this->log("ERROR: Missing '{$key}' in config: {$path}"); return null; } } if (!isset($config['port'])) { $config['port'] = 22; } return $config; } /** * Resolve the list of directories to sync. * * @return string[] Directory paths (relative to remote_path) */ private function resolveDirs(): array { if ($this->fullMode) { return ['.']; } return $this->joomlaDirs; } /** * Prepare the local relay directory. */ private function prepareRelayDir(): void { if (is_dir($this->relayDir)) { shell_exec("rm -rf " . escapeshellarg($this->relayDir)); } mkdir($this->relayDir, 0755, true); $this->log("Relay directory: {$this->relayDir}"); } /** * Build common rsync exclude flags. * * configuration.php is always excluded — it contains per-environment * database credentials and settings that must never be synced. * * @return string Exclude arguments for rsync */ private function buildExcludes(): string { $excludes = ['configuration.php']; $excludes = array_merge($excludes, $this->excludes); $flags = ''; foreach ($excludes as $pattern) { $flags .= ' --exclude=' . escapeshellarg($pattern); } return $flags; } /** * Build SSH command fragment for rsync. * * @param array $config Server config * @return string The -e flag value for rsync */ private function buildSshCmd(array $config): string { $keyPath = escapeshellarg($config['ssh_key_file']); $port = (int) $config['port']; return "ssh -i {$keyPath} -p {$port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; } /** * Pull a directory from the source server to the local relay. * * @param array $config Source server config * @param string $dir Relative directory to sync * @return int|false Number of files or false on failure */ private function pullFromSource(array $config, string $dir): int|false { $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); $localPath = $this->relayDir . ltrim($dir, './'); if (!is_dir($localPath)) { mkdir($localPath, 0755, true); } $sshCmd = $this->buildSshCmd($config); $excludes = $this->buildExcludes(); $dryFlag = $this->dryRun ? ' --dry-run' : ''; $verboseFlag = $this->verbose ? ' -v' : ''; $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); $local = escapeshellarg("{$localPath}/"); $cmd = "rsync -az --delete" . $dryFlag . $verboseFlag . $excludes . " -e " . escapeshellarg($sshCmd) . " {$remote} {$local}" . " 2>&1"; $this->logVerbose(" PULL: {$cmd}"); $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); if ($exitCode !== 0) { $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); return false; } $fileCount = count($output); $this->logVerbose(" Pulled {$fileCount} line(s) of output."); return $fileCount; } /** * Push a directory from the local relay to the destination server. * * @param array $config Dest server config * @param string $dir Relative directory to sync * @return int|false Number of files or false on failure */ private function pushToDest(array $config, string $dir): int|false { $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); $localPath = $this->relayDir . ltrim($dir, './'); $sshCmd = $this->buildSshCmd($config); $excludes = $this->buildExcludes(); $dryFlag = $this->dryRun ? ' --dry-run' : ''; $verboseFlag = $this->verbose ? ' -v' : ''; $local = escapeshellarg("{$localPath}/"); $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); $cmd = "rsync -az --delete" . $dryFlag . $verboseFlag . $excludes . " -e " . escapeshellarg($sshCmd) . " {$local} {$remote}" . " 2>&1"; $this->logVerbose(" PUSH: {$cmd}"); $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); if ($exitCode !== 0) { $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); return false; } $fileCount = count($output); $this->logVerbose(" Pushed {$fileCount} line(s) of output."); return $fileCount; } /** * Clean up the relay directory. */ private function cleanup(): void { if (is_dir($this->relayDir)) { shell_exec("rm -rf " . escapeshellarg($this->relayDir)); $this->logVerbose("Cleaned up relay directory."); } } /** * Print usage information. */ private function printUsage(): void { $this->log(''); $this->log('Usage: sync-joomla.php --source --dest [--rsync|--full] [options]'); $this->log(''); $this->log('Required:'); $this->log(' --source sftp-config.json for source server'); $this->log(' --dest sftp-config.json for dest server'); $this->log(' --rsync Sync standard Joomla directories'); $this->log(' --full Sync everything under the remote path'); $this->log(''); $this->log('Options:'); $this->log(' --dry-run Preview only, no files transferred'); $this->log(' --verbose Verbose output'); $this->log(' --exclude Additional exclude pattern (repeatable)'); } /** * Log a message to stdout. * * @param string $message Message to log */ private function log(string $message): void { echo $message . PHP_EOL; } /** * Log a verbose message (only when --verbose is set). * * @param string $message Message to log */ private function logVerbose(string $message): void { if ($this->verbose) { $this->log($message); } } } $sync = new SyncJoomla(); exit($sync->run());