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
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>
1083 lines
40 KiB
PHP
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());
|