#!/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.Scripts.Deploy * INGROUP: MokoPlatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /deploy/sync-joomla.php * VERSION: 09.22.00 * BRIEF: Sync Joomla site directories between two servers via rsync over SSH */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class SyncJoomlaCli extends CliFramework { private string $sourceConfig = ''; private string $destConfig = ''; private bool $rsyncMode = false; private bool $fullMode = false; /** @var string[] */ private array $excludes = []; 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', ]; protected function configure(): void { $this->setDescription('Sync Joomla site directories between two servers via rsync over SSH'); $this->addArgument('--source', 'sftp-config.json for source server', ''); $this->addArgument('--dest', 'sftp-config.json for dest server', ''); $this->addArgument('--rsync', 'Sync standard Joomla directories', false); $this->addArgument('--full', 'Sync everything under the remote path', false); $this->addArgument('--exclude', 'Additional exclude pattern (repeatable)', ''); } protected function run(): int { $this->sourceConfig = $this->getArgument('--source'); $this->destConfig = $this->getArgument('--dest'); $this->rsyncMode = $this->getArgument('--rsync'); $this->fullMode = $this->getArgument('--full'); // Handle repeatable --exclude from raw argv $rawArgs = $_SERVER['argv'] ?? []; for ($i = 1; $i < count($rawArgs); $i++) { if ($rawArgs[$i] === '--exclude' && isset($rawArgs[$i + 1])) { $this->excludes[] = $rawArgs[$i + 1]; $i++; } } if (!$this->validate()) { return 1; } $source = $this->loadConfig($this->sourceConfig); $dest = $this->loadConfig($this->destConfig); if ($source === null || $dest === null) { return 1; } echo "Source: {$source['user']}@{$source['host']}:{$source['remote_path']}\n"; echo "Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}\n"; if ($this->dryRun) { echo "[DRY-RUN] No files will be transferred.\n"; } $this->prepareRelayDir(); $dirs = $this->resolveDirs(); $totalFiles = 0; $syncedDirs = 0; foreach ($dirs as $dir) { echo "--- Syncing: {$dir}\n"; $pulled = $this->pullFromSource($source, $dir); if ($pulled === false) { echo " WARNING: pull failed for {$dir}, skipping.\n"; continue; } $pushed = $this->pushToDest($dest, $dir); if ($pushed === false) { echo " WARNING: push failed for {$dir}, skipping.\n"; continue; } $totalFiles += $pulled + $pushed; $syncedDirs++; } $this->cleanup(); echo "\n"; echo "=== Sync Summary ===\n"; echo "Directories synced: {$syncedDirs}/" . count($dirs) . "\n"; echo "Rsync operations: " . ($syncedDirs * 2) . " (pull + push)\n"; if ($this->dryRun) { echo "Mode: dry-run (no files were transferred)\n"; } return 0; } private function validate(): bool { if ($this->sourceConfig === '' || $this->destConfig === '') { $this->log('ERROR', '--source and --dest are required.'); return false; } if (!$this->rsyncMode && !$this->fullMode) { $this->log('ERROR', 'Either --rsync or --full must be specified.'); 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; } 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; } /** @return string[] */ private function resolveDirs(): array { if ($this->fullMode) { return ['.']; } return $this->joomlaDirs; } private function prepareRelayDir(): void { if (is_dir($this->relayDir)) { shell_exec("rm -rf " . escapeshellarg($this->relayDir)); } mkdir($this->relayDir, 0755, true); echo "Relay directory: {$this->relayDir}\n"; } private function buildExcludes(): string { $excludes = ['configuration.php']; $excludes = array_merge($excludes, $this->excludes); $flags = ''; foreach ($excludes as $pattern) { $flags .= ' --exclude=' . escapeshellarg($pattern); } return $flags; } 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"; } /** @return int|false */ 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"; if ($this->verbose) { echo " PULL: {$cmd}\n"; } $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); if ($exitCode !== 0) { echo " ERROR (exit {$exitCode}): " . implode("\n", $output) . "\n"; return false; } $fileCount = count($output); if ($this->verbose) { echo " Pulled {$fileCount} line(s) of output.\n"; } return $fileCount; } /** @return int|false */ 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"; if ($this->verbose) { echo " PUSH: {$cmd}\n"; } $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); if ($exitCode !== 0) { echo " ERROR (exit {$exitCode}): " . implode("\n", $output) . "\n"; return false; } $fileCount = count($output); if ($this->verbose) { echo " Pushed {$fileCount} line(s) of output.\n"; } return $fileCount; } private function cleanup(): void { if (is_dir($this->relayDir)) { shell_exec("rm -rf " . escapeshellarg($this->relayDir)); if ($this->verbose) { echo "Cleaned up relay directory.\n"; } } } } $app = new SyncJoomlaCli(); exit($app->execute());