#!/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.CLI * INGROUP: MokoPlatform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/deploy_joomla.php * BRIEF: Smart Joomla deploy — routes files to correct server directories by extension type * * Parses the Joomla XML manifest to auto-detect extension type and maps * source directories to their correct Joomla installation paths. Supports * all 8 extension types: component, module, plugin, template, library, * package, file, and language. * * USAGE * php bin/moko deploy:joomla --path /repos/my-extension --env dev * php bin/moko deploy:joomla --path . --config /tmp/sftp-config.json * php bin/moko deploy:joomla --path . --env dev --dry-run --verbose */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; use phpseclib3\Net\SFTP; use phpseclib3\Crypt\PublicKeyLoader; /** * Smart Joomla SFTP Deployment * * Reads the Joomla extension XML manifest to determine type, element name, * client scope, and plugin group, then maps local source directories to their * correct Joomla server paths. Supports all 8 Joomla extension types. */ class DeployJoomla extends CliFramework { private int $uploaded = 0; private int $unchanged = 0; private int $skipped = 0; private int $deleted = 0; private int $errors = 0; /** @var array Parsed sftp-config.json */ private array $sftpConfig = []; /** @var string[] Regex ignore patterns */ private array $ignorePatterns = []; protected function configure(): void { $this->setDescription('Smart Joomla SFTP deploy — routes files by extension type'); $this->addArgument('--path', 'Repository root (default: current directory)', '.'); $this->addArgument('--src-dir', 'Source sub-directory to upload (default: auto-detect)', ''); $this->addArgument('--env', 'Target environment: dev or rs', ''); $this->addArgument('--config', 'Explicit sftp-config.json path (overrides --env)', ''); $this->addArgument('--key-passphrase', 'Passphrase for encrypted SSH key', ''); $this->addArgument('--delete', 'Delete remote files not present locally', false); } protected function run(): int { $repoPath = $this->resolveRepoPath(); $srcDir = $this->resolveSrcDir($repoPath); $this->log("Repository : {$repoPath}"); $this->log("Source dir : {$srcDir}"); // ── Find and parse Joomla manifest ── $manifestPath = $this->findJoomlaManifest($srcDir, $repoPath); if ($manifestPath === null) { $this->log('ERROR', "No Joomla XML manifest found"); return 1; } $this->log("Manifest : {$manifestPath}"); $ext = $this->parseExtensionManifest($manifestPath); if ($ext === null) { $this->log('ERROR', "Failed to parse manifest"); return 1; } $this->log("Extension : {$ext['type']} / {$ext['element']} (client: {$ext['client']})"); if ($ext['group'] !== '') { $this->log("Group : {$ext['group']}"); } // ── Load SFTP config ── $configPath = $this->resolveConfigPath($repoPath); $this->log("Config : {$configPath}"); if (!$this->loadSftpConfig($configPath)) { return 1; } // ── Build deployment map ── $deployMap = $this->buildDeployMap($ext, $srcDir); if (empty($deployMap)) { $this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}"); return 1; } $this->log(""); $this->log("Deploy plan:"); foreach ($deployMap as $map) { $exists = is_dir($map['local']) ? '' : ' [not found]'; $this->log(" {$map['local']} → {$map['remote']}{$exists}"); } // Manifest destination $manifestDest = $this->getManifestDestination($ext); if ($manifestDest !== null) { $this->log(" " . basename($manifestPath) . " → {$manifestDest}"); } $this->log(""); $this->printManifestChangeWarning($manifestPath); // ── Load ignore patterns ── $this->ignorePatterns = $this->loadIgnorePatterns($srcDir, $repoPath); if ($this->dryRun) { $this->log("[DRY RUN] Would deploy " . count($deployMap) . " directory mappings"); $this->dryRunWalk($deployMap); return 0; } // ── Connect ── $sftp = $this->connectSftp($repoPath); if ($sftp === null) { return 1; } // ── Deploy each mapping ── foreach ($deployMap as $map) { if (!is_dir($map['local'])) { $this->log('DEBUG', " SKIP: {$map['local']} (not found)"); continue; } $this->ensureRemoteDir($sftp, $map['remote']); $this->uploadDirectory($sftp, $map['local'], $map['remote']); } // ── Deploy manifest XML ── if ($manifestDest !== null && file_exists($manifestPath)) { $this->ensureRemoteDir($sftp, dirname($manifestDest)); $this->uploadFile($sftp, $manifestPath, $manifestDest); } // ── Summary ── $this->log(""); $this->log("Done."); $this->log(" Uploaded : {$this->uploaded}"); $this->log(" Unchanged : {$this->unchanged}"); $this->log(" Skipped : {$this->skipped}"); if ($this->deleted > 0) { $this->log(" Deleted : {$this->deleted}"); } if ($this->errors > 0) { $this->log('ERROR', " Errors : {$this->errors}"); } if ($this->isJsonMode()) { echo json_encode([ 'extension' => $ext, 'uploaded' => $this->uploaded, 'unchanged' => $this->unchanged, 'skipped' => $this->skipped, 'deleted' => $this->deleted, 'errors' => $this->errors, ], JSON_PRETTY_PRINT) . "\n"; } return $this->errors > 0 ? 1 : 0; } // ═══════════════════════════════════════════════════════════════════════════ // Manifest parsing // ═══════════════════════════════════════════════════════════════════════════ /** * Find the Joomla extension XML manifest. * * Searches: src dir root, repo root, then one level deep in each. */ private function findJoomlaManifest(string $srcDir, string $repoPath): ?string { foreach ([$srcDir, $repoPath] as $dir) { // Direct children $found = $this->scanDirForManifest($dir); if ($found !== null) { return $found; } // One level deep foreach (new \DirectoryIterator($dir) as $sub) { if ($sub->isDir() && !$sub->isDot()) { $found = $this->scanDirForManifest($sub->getPathname()); if ($found !== null) { return $found; } } } } return null; } private function scanDirForManifest(string $dir): ?string { if (!is_dir($dir)) { return null; } foreach (new \DirectoryIterator($dir) 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 Joomla XML manifest. * * @return array{type:string, element:string, client:string, * group:string, name:string, shortName:string, version:string, * subExtensions:list}|null */ private function parseExtensionManifest(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 ?? ''); $version = (string) ($xml->version ?? '0.0.0'); // 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, }; } // Derive short name (without type prefix) for directory mapping $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]; } elseif ($type === 'library' && preg_match('/^lib_(.+)$/', $element, $m)) { $shortName = $m[1]; } elseif ($type === 'module' && preg_match('/^mod_(.+)$/', $element, $m)) { $shortName = $m[1]; } elseif ($type === 'component' && preg_match('/^com_(.+)$/', $element, $m)) { $shortName = $m[1]; } // Parse sub-extensions for packages $subExtensions = []; if ($type === 'package' && isset($xml->files)) { foreach ($xml->files->children() as $child) { $subType = $child->getName(); // file, folder, etc. $subExtensions[] = [ 'type' => (string) ($child['type'] ?? ''), 'id' => (string) ($child['id'] ?? ''), 'group' => (string) ($child['group'] ?? ''), 'client' => (string) ($child['client'] ?? 'site'), 'path' => (string) $child, ]; } } return [ 'type' => $type, 'element' => $element, 'client' => $client, 'group' => $group, 'name' => $name, 'shortName' => $shortName, 'version' => $version, 'subExtensions' => $subExtensions, ]; } // ═══════════════════════════════════════════════════════════════════════════ // Deploy map — extension type → directory mappings // ═══════════════════════════════════════════════════════════════════════════ /** * Build local→remote directory mappings based on extension type. * * @return array */ private function buildDeployMap(array $ext, string $srcDir): array { $remote = rtrim((string) $this->sftpConfig['remote_path'], '/'); $map = []; switch ($ext['type']) { case 'component': $map = $this->mapComponent($ext, $srcDir, $remote); break; case 'module': $map = $this->mapModule($ext, $srcDir, $remote); break; case 'plugin': $map = $this->mapPlugin($ext, $srcDir, $remote); break; case 'template': $map = $this->mapTemplate($ext, $srcDir, $remote); break; case 'library': $map = $this->mapLibrary($ext, $srcDir, $remote); break; case 'package': $map = $this->mapPackage($ext, $srcDir, $remote); break; case 'file': $map = $this->mapFile($ext, $srcDir, $remote); break; case 'language': $map = $this->mapLanguage($ext, $srcDir, $remote); break; } return $map; } /** * Component: admin, site, media, api, language sections. */ private function mapComponent(array $ext, string $src, string $remote): array { $el = $ext['element']; $map = []; // Admin files — check multiple conventions $adminLocal = $this->firstExistingDir($src, ['admin', 'administrator', 'backend']); if ($adminLocal !== null) { $map[] = ['local' => $adminLocal, 'remote' => "{$remote}/administrator/components/{$el}"]; } // Site files $siteLocal = $this->firstExistingDir($src, ['site', 'frontend']); if ($siteLocal !== null) { $map[] = ['local' => $siteLocal, 'remote' => "{$remote}/components/{$el}"]; } // Media files if (is_dir("{$src}/media")) { $map[] = ['local' => "{$src}/media", 'remote' => "{$remote}/media/{$el}"]; } // API files (Joomla 4+) if (is_dir("{$src}/api")) { $map[] = ['local' => "{$src}/api", 'remote' => "{$remote}/api/components/{$el}"]; } // Language files — admin $langAdmin = $this->firstExistingDir($src, ['language/admin', 'admin/language']); if ($langAdmin !== null) { $map[] = ['local' => $langAdmin, 'remote' => "{$remote}/administrator/language"]; } // Language files — site $langSite = $this->firstExistingDir($src, ['language/site', 'site/language']); if ($langSite !== null) { $map[] = ['local' => $langSite, 'remote' => "{$remote}/language"]; } return $map; } /** * Module: base dir + media, routed by client (site or administrator). */ private function mapModule(array $ext, string $src, string $remote): array { $el = $ext['element']; $map = []; $base = $ext['client'] === 'administrator' ? "{$remote}/administrator/modules/{$el}" : "{$remote}/modules/{$el}"; // Module source — exclude media/ from base upload $map[] = ['local' => $src, 'remote' => $base]; if (is_dir("{$src}/media")) { $map[] = ['local' => "{$src}/media", 'remote' => "{$remote}/media/{$el}"]; } // Language files $langDir = $this->firstExistingDir($src, ['language']); if ($langDir !== null) { $langRemote = $ext['client'] === 'administrator' ? "{$remote}/administrator/language" : "{$remote}/language"; $map[] = ['local' => $langDir, 'remote' => $langRemote]; } return $map; } /** * Plugin: plugins/{group}/{shortName} + media. */ private function mapPlugin(array $ext, string $src, string $remote): array { $map = []; $group = $ext['group'] ?: 'system'; $name = $ext['shortName']; $map[] = ['local' => $src, 'remote' => "{$remote}/plugins/{$group}/{$name}"]; if (is_dir("{$src}/media")) { $map[] = ['local' => "{$src}/media", 'remote' => "{$remote}/media/{$ext['element']}"]; } // Language files $langDir = $this->firstExistingDir($src, ['language']); if ($langDir !== null) { $map[] = ['local' => $langDir, 'remote' => "{$remote}/administrator/language"]; } return $map; } /** * Template: templates/{name} or administrator/templates/{name} + media. */ private function mapTemplate(array $ext, string $src, string $remote): array { $map = []; $name = $ext['shortName']; $clientDir = $ext['client'] === 'administrator' ? 'administrator/' : ''; $map[] = ['local' => $src, 'remote' => "{$remote}/{$clientDir}templates/{$name}"]; if (is_dir("{$src}/media")) { $mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site'; $map[] = ['local' => "{$src}/media", 'remote' => "{$remote}/media/templates/{$mediaClient}/{$name}"]; } // Language files $langDir = $this->firstExistingDir($src, ['language']); if ($langDir !== null) { $langRemote = $ext['client'] === 'administrator' ? "{$remote}/administrator/language" : "{$remote}/language"; $map[] = ['local' => $langDir, 'remote' => $langRemote]; } return $map; } /** * Library: libraries/{name} + media. */ private function mapLibrary(array $ext, string $src, string $remote): array { $map = []; $name = $ext['shortName']; $map[] = ['local' => $src, 'remote' => "{$remote}/libraries/{$name}"]; if (is_dir("{$src}/media")) { $map[] = ['local' => "{$src}/media", 'remote' => "{$remote}/media/{$ext['element']}"]; } return $map; } /** * Package: deploy each sub-extension from packages/ directory. * * Reads from the package manifest to determine sub-extension types * and deploy each one to its correct location. */ private function mapPackage(array $ext, string $src, string $remote): array { $map = []; // Packages contain sub-extensions in packages/ directory $packagesDir = $this->firstExistingDir($src, ['packages', 'extensions']); if ($packagesDir !== null && !empty($ext['subExtensions'])) { foreach ($ext['subExtensions'] as $sub) { $subPath = "{$packagesDir}/{$sub['path']}"; if (!is_dir($subPath) && !is_file($subPath)) { $this->log(" Package sub-extension not found: {$sub['path']}", 'WARNING'); continue; } // If it's a directory with its own manifest, parse and map it if (is_dir($subPath)) { $subManifest = $this->scanDirForManifest($subPath); if ($subManifest !== null) { $subExt = $this->parseExtensionManifest($subManifest); if ($subExt !== null) { $subMaps = $this->buildDeployMap($subExt, $subPath); $map = array_merge($map, $subMaps); } } } } } elseif ($packagesDir !== null) { // No in manifest — scan packages/ for sub-extension directories foreach (new \DirectoryIterator($packagesDir) as $item) { if ($item->isDot() || !$item->isDir()) { continue; } $subManifest = $this->scanDirForManifest($item->getPathname()); if ($subManifest !== null) { $subExt = $this->parseExtensionManifest($subManifest); if ($subExt !== null) { $subMaps = $this->buildDeployMap($subExt, $item->getPathname()); $map = array_merge($map, $subMaps); } } } } // Package language files at root $langDir = $this->firstExistingDir($src, ['language']); if ($langDir !== null) { $map[] = ['local' => $langDir, 'remote' => "{$remote}/administrator/language"]; } return $map; } /** * File extension: deploys file groups to root locations. */ private function mapFile(array $ext, string $src, string $remote): array { // File extensions typically deploy to the Joomla root return [ ['local' => $src, 'remote' => $remote], ]; } /** * Language extension: deploys to language/{tag}/ directories. */ private function mapLanguage(array $ext, string $src, string $remote): array { $map = []; $langRemote = $ext['client'] === 'administrator' ? "{$remote}/administrator/language" : "{$remote}/language"; $map[] = ['local' => $src, 'remote' => $langRemote]; return $map; } /** * Determine where the manifest XML should be deployed. * * Joomla stores manifest copies differently per extension type. */ private function getManifestDestination(array $ext): ?string { $remote = rtrim((string) $this->sftpConfig['remote_path'], '/'); return match ($ext['type']) { 'component' => "{$remote}/administrator/components/{$ext['element']}/" . $this->getManifestFilename($ext), 'module' => ($ext['client'] === 'administrator' ? "{$remote}/administrator/modules/{$ext['element']}" : "{$remote}/modules/{$ext['element']}") . '/' . $this->getManifestFilename($ext), 'plugin' => "{$remote}/plugins/{$ext['group']}/{$ext['shortName']}/" . $this->getManifestFilename($ext), 'template' => ($ext['client'] === 'administrator' ? "{$remote}/administrator/templates/{$ext['shortName']}" : "{$remote}/templates/{$ext['shortName']}") . '/templateDetails.xml', 'library' => "{$remote}/administrator/manifests/libraries/{$ext['element']}.xml", 'package' => "{$remote}/administrator/manifests/packages/{$ext['element']}.xml", 'file' => "{$remote}/administrator/manifests/files/{$ext['element']}.xml", default => null, }; } private function getManifestFilename(array $ext): string { return match ($ext['type']) { 'template' => 'templateDetails.xml', default => "{$ext['element']}.xml", }; } // ═══════════════════════════════════════════════════════════════════════════ // SFTP operations // ═══════════════════════════════════════════════════════════════════════════ /** * Upload a directory recursively, skipping unchanged files. */ private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): void { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); $localFiles = []; foreach ($iterator as $item) { $relativePath = substr($item->getPathname(), strlen($localDir) + 1); $relativePath = str_replace('\\', '/', $relativePath); $remotePath = "{$remoteDir}/{$relativePath}"; if ($this->shouldIgnore($relativePath)) { $this->skipped++; continue; } $localFiles[dirname($relativePath) === '.' ? basename($relativePath) : $relativePath] = true; if ($item->isDir()) { $this->ensureRemoteDir($sftp, $remotePath); } else { $this->uploadFile($sftp, $item->getPathname(), $remotePath); } } // Delete remote files not present locally (if --delete flag set) if ($this->getArgument('--delete')) { $this->deleteOrphans($sftp, $localDir, $remoteDir); } } /** * Upload a single file, skipping if unchanged (smart diff). */ private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): void { $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; } } // Ensure parent directory exists $this->ensureRemoteDir($sftp, dirname($remotePath)); $result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE); if ($result) { $this->uploaded++; $this->log('DEBUG', " PUT " . basename($localPath) . " → {$remotePath}"); } else { $this->log('ERROR', " FAIL {$remotePath}"); $this->errors++; } } /** * Delete remote files that no longer exist locally. */ private function deleteOrphans(SFTP $sftp, string $localDir, string $remoteDir): void { $remoteEntries = $sftp->nlist($remoteDir); if (!is_array($remoteEntries)) { return; } foreach ($remoteEntries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $localPath = $localDir . DIRECTORY_SEPARATOR . $entry; $remotePath = "{$remoteDir}/{$entry}"; if (!file_exists($localPath)) { $remoteStat = @$sftp->stat($remotePath); if ($remoteStat !== false && ($remoteStat['type'] ?? 0) === 2) { $this->deleteRemoteDir($sftp, $remotePath); } else { $sftp->delete($remotePath); } $this->deleted++; $this->log('DEBUG', " DEL {$remotePath}"); } elseif (is_dir($localPath)) { $this->deleteOrphans($sftp, $localPath, $remotePath); } } } private function deleteRemoteDir(SFTP $sftp, string $path): void { $entries = $sftp->nlist($path); if (is_array($entries)) { foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $full = "{$path}/{$entry}"; $stat = @$sftp->stat($full); if ($stat !== false && ($stat['type'] ?? 0) === 2) { $this->deleteRemoteDir($sftp, $full); } else { $sftp->delete($full); } } } $sftp->rmdir($path); } private function ensureRemoteDir(SFTP $sftp, string $path): void { $stat = @$sftp->stat($path); if ($stat === false) { $sftp->mkdir($path, -1, true); } } // ═══════════════════════════════════════════════════════════════════════════ // Connection // ═══════════════════════════════════════════════════════════════════════════ private function connectSftp(string $repoPath): ?SFTP { $host = (string) $this->sftpConfig['host']; $port = (int) ($this->sftpConfig['port'] ?? 22); $user = (string) $this->sftpConfig['user']; $this->log("Connecting to {$user}@{$host}:{$port}..."); try { $sftp = new SFTP($host, $port, 30); } catch (\Throwable $e) { $this->log('ERROR', "Cannot reach {$host}:{$port} — " . $e->getMessage()); return null; } // Try key auth first if (!empty($this->sftpConfig['ssh_key_file'])) { $keyPath = $this->resolveKeyPath((string) $this->sftpConfig['ssh_key_file'], $repoPath); if (file_exists($keyPath)) { $passphrase = $this->getArgument('--key-passphrase') ?: ($this->sftpConfig['key_passphrase'] ?? ''); try { $keyData = file_get_contents($keyPath); $key = $passphrase ? PublicKeyLoader::load($keyData, $passphrase) : PublicKeyLoader::load($keyData); if ($sftp->login($user, $key)) { $this->log("Connected via SSH key"); return $sftp; } } catch (\Throwable $e) { $this->log("Key auth failed: " . $e->getMessage(), 'WARNING'); } } else { $this->log("SSH key not found: {$keyPath}", 'WARNING'); } } // Fallback to password if (!empty($this->sftpConfig['password'])) { if ($sftp->login($user, $this->sftpConfig['password'])) { $this->log("Connected via password"); return $sftp; } } $this->log('ERROR', "Authentication failed for {$user}@{$host}"); return null; } private function resolveKeyPath(string $configured, string $repoPath): string { if (str_starts_with($configured, '/') || preg_match('/^[A-Za-z]:[\/\\\\]/', $configured)) { return $configured; } $candidate = $repoPath . '/scripts/keys/' . ltrim($configured, '/\\'); return file_exists($candidate) ? $candidate : $configured; } // ═══════════════════════════════════════════════════════════════════════════ // Path resolution // ═══════════════════════════════════════════════════════════════════════════ private function resolveRepoPath(): string { $raw = $this->getArgument('--path', '.'); $path = realpath($raw); if ($path === false || !is_dir($path)) { $this->log('ERROR', "Repository path not found: {$raw}"); exit(1); } return $path; } /** * Resolve source directory with smart fallback chain: * 1. --src-dir flag (explicit) * 2. .mokogitea/manifest.xml * 3. src/ directory * 4. htdocs/ directory * 5. Repo root (for flat-layout extensions) */ private function resolveSrcDir(string $repoPath): string { // 1. Explicit --src-dir $explicit = $this->getArgument('--src-dir') ?: null; if ($explicit !== null) { $dir = str_starts_with($explicit, '/') ? $explicit : "{$repoPath}/{$explicit}"; if (is_dir($dir)) { return $dir; } $this->log('ERROR', "Source directory not found: {$dir}"); exit(1); } // 2. Read from .mokogitea/manifest.xml $mokoManifest = "{$repoPath}/.mokogitea/manifest.xml"; if (file_exists($mokoManifest)) { $xml = @simplexml_load_file($mokoManifest); if ($xml !== false) { $sourceDir = (string) ($xml->deploy->{'source-dir'} ?? ''); if ($sourceDir !== '') { $dir = "{$repoPath}/{$sourceDir}"; if (is_dir($dir)) { return $dir; } } } } // 3-5. Fallback chain foreach (['src', 'htdocs'] as $candidate) { if (is_dir("{$repoPath}/{$candidate}")) { return "{$repoPath}/{$candidate}"; } } // Last resort: repo root itself return $repoPath; } private const ENV_CONFIG_MAP = [ 'dev' => 'sftp-config.dev.json', 'rs' => 'sftp-config.rs.json', ]; private function resolveConfigPath(string $repoPath): string { $configDir = "{$repoPath}/scripts/sftp-config"; // 1. Explicit --config $explicit = $this->getArgument('--config') ?: null; if ($explicit !== null) { $path = realpath($explicit); if ($path === false) { $this->log('ERROR', "Config file not found: {$explicit}"); exit(1); } return $path; } // 2. --env selects named config $env = $this->getArgument('--env') ?: null; if ($env !== null) { $env = strtolower((string) $env); if (!isset(self::ENV_CONFIG_MAP[$env])) { $valid = implode(', ', array_keys(self::ENV_CONFIG_MAP)); $this->log('ERROR', "Unknown --env '{$env}'. Valid: {$valid}"); exit(2); } $envConfig = "{$configDir}/" . self::ENV_CONFIG_MAP[$env]; if (!file_exists($envConfig)) { $this->log('ERROR', "Config not found for --env {$env}: {$envConfig}"); exit(1); } return $envConfig; } // 3. Generic fallback $default = "{$configDir}/sftp-config.json"; if (!file_exists($default)) { $this->log('ERROR', "No config found. Use --env dev, --env rs, or --config ."); exit(1); } return $default; } // ═══════════════════════════════════════════════════════════════════════════ // Config loading // ═══════════════════════════════════════════════════════════════════════════ private function loadSftpConfig(string $configPath): bool { $raw = file_get_contents($configPath); if ($raw === false) { $this->log('ERROR', "Cannot read config: {$configPath}"); return false; } // Strip // comments and trailing commas (Sublime Text SFTP format) $stripped = preg_replace('#(?log('ERROR', "Invalid JSON in config: " . json_last_error_msg()); return false; } $this->sftpConfig = $decoded; // Validate required fields $missing = []; foreach (['host', 'user', 'remote_path'] as $key) { if (empty($this->sftpConfig[$key])) { $missing[] = $key; } } if (empty($this->sftpConfig['ssh_key_file']) && empty($this->sftpConfig['password'])) { $missing[] = 'ssh_key_file or password'; } if (!empty($missing)) { $this->log("Missing config fields: " . implode(', ', $missing), 'ERROR'); return false; } return true; } // ═══════════════════════════════════════════════════════════════════════════ // Ignore patterns // ═══════════════════════════════════════════════════════════════════════════ /** * Load ignore patterns from .ftpignore and config. * * @return string[] */ private function loadIgnorePatterns(string $srcDir, string $repoPath): array { $patterns = []; // From sftp-config.json ignore_regexes foreach ($this->sftpConfig['ignore_regexes'] ?? [] as $p) { $patterns[] = (string) $p; } // From .ftpignore files foreach ([$srcDir, $repoPath] as $dir) { $file = "{$dir}/.ftpignore"; if (!is_file($file)) { continue; } $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if ($lines === false) { continue; } foreach ($lines as $line) { $line = trim((string) preg_replace('/#.*$/', '', $line)); if ($line === '' || str_starts_with($line, '!')) { continue; } $line = rtrim($line, '/'); $anchored = str_starts_with($line, '/'); $line = ltrim($line, '/'); $regex = preg_quote($line, '/'); $regex = str_replace('\*\*', '.*', $regex); $regex = str_replace('\*', '[^/]*', $regex); $regex = str_replace('\?', '[^/]', $regex); $regex = $anchored ? '^' . $regex : '(^|/)' . $regex; $patterns[] = $regex . '(/|$)'; } } return $patterns; } private function shouldIgnore(string $relativePath): bool { // Always skip dotfiles (except .htaccess) $basename = basename($relativePath); if (str_starts_with($basename, '.') && $basename !== '.htaccess') { return true; } foreach ($this->ignorePatterns as $pattern) { if (preg_match('#' . $pattern . '#i', $relativePath) === 1) { return true; } } return false; } // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════ /** * Return the first existing directory from a list of candidates. */ private function firstExistingDir(string $base, array $candidates): ?string { foreach ($candidates as $sub) { $path = "{$base}/{$sub}"; if (is_dir($path)) { return $path; } } return null; } private function printManifestChangeWarning(string $manifestPath): void { $name = basename($manifestPath); $this->log("NOTE: If {$name} has changed (new fields, permissions, menu items,"); $this->log(" database schema), reinstall the extension through Joomla."); $this->log(" Code changes (PHP, JS, CSS, language) do NOT require reinstall."); $this->log(""); } /** * Dry-run: walk the deploy map and show what would be uploaded. */ private function dryRunWalk(array $deployMap): void { foreach ($deployMap as $map) { if (!is_dir($map['local'])) { $this->log("[DRY RUN] SKIP: {$map['local']} (not found)"); continue; } $count = iterator_count( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY ) ); $this->log("[DRY RUN] {$map['local']} ({$count} files) → {$map['remote']}"); } } } $app = new DeployJoomla(); exit($app->execute());