Files
moko-platform/cli/deploy_joomla.php
T
Jonathan Miller 66e728b078
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 18s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 27s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 28s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m7s
style: fix PHPCS violations across migrated CLI scripts
Auto-fixed 5006 tab-indent and line-ending errors via phpcbf, then
manually broke 100 lines exceeding 150-char limit. All 74 files in
cli/, automation/, maintenance/, deploy/ now pass PHPCS PSR-12 clean.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 13:36:05 -05:00

1083 lines
40 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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<string,mixed> 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, '<extension')) {
return $file->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<array{type:string, id:string,
* group:string, client:string, path:string}>}|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<int, array{local:string, remote:string}>
*/
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 <files> 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 <files> 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 <deploy><source-dir>
* 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 <path>.");
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('#(?<!:)//[^\r\n]*#', '', $raw);
$stripped = preg_replace('/,(\s*[}\]])/', '$1', $stripped ?? '');
$decoded = json_decode($stripped ?? '', true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->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());