refactor: rename GiteaAdapter to MokoGiteaAdapter (#30)
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s

This commit was merged in pull request #30.
This commit is contained in:
2026-05-21 22:15:50 +00:00
parent 8095ea607b
commit 46d9af0ff6
15 changed files with 2933 additions and 2933 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ use MokoEnterprise\CliFramework;
use MokoEnterprise\Config; use MokoEnterprise\Config;
use MokoEnterprise\PlatformAdapterFactory; use MokoEnterprise\PlatformAdapterFactory;
use MokoEnterprise\GitHubAdapter; use MokoEnterprise\GitHubAdapter;
use MokoEnterprise\GiteaAdapter; use MokoEnterprise\MokoGiteaAdapter;
/** /**
* Gitea Migration Script * Gitea Migration Script
@@ -42,7 +42,7 @@ use MokoEnterprise\GiteaAdapter;
class MigrateToGitea extends CliFramework class MigrateToGitea extends CliFramework
{ {
private ?GitHubAdapter $github = null; private ?GitHubAdapter $github = null;
private ?GiteaAdapter $gitea = null; private ?MokoGiteaAdapter $gitea = null;
private ?CheckpointManager $checkpoints = null; private ?CheckpointManager $checkpoints = null;
protected function configure(): void protected function configure(): void
+319 -319
View File
@@ -1,319 +1,319 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.CLI * DEFGROUP: MokoStandards.Scripts.CLI
* INGROUP: MokoStandards * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/bulk_workflow_trigger.php * PATH: /cli/bulk_workflow_trigger.php
* VERSION: 01.00.00 * VERSION: 01.00.00
* BRIEF: Trigger a workflow across multiple repos at once * BRIEF: Trigger a workflow across multiple repos at once
*/ */
declare(strict_types=1); declare(strict_types=1);
final class BulkWorkflowTrigger final class BulkWorkflowTrigger
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
private string $reposFile = ''; private string $reposFile = '';
private string $org = ''; private string $org = '';
private string $workflow = ''; private string $workflow = '';
private string $ref = 'main'; private string $ref = 'main';
private string $inputs = ''; private string $inputs = '';
private bool $dryRun = false; private bool $dryRun = false;
public function run(): int public function run(): int
{ {
$this->parseArgs(); $this->parseArgs();
if ($this->token === '') if ($this->token === '')
{ {
$this->log('ERROR: --token is required.'); $this->log('ERROR: --token is required.');
$this->printUsage(); $this->printUsage();
return 1; return 1;
} }
if ($this->workflow === '') if ($this->workflow === '')
{ {
$this->log('ERROR: --workflow is required.'); $this->log('ERROR: --workflow is required.');
$this->printUsage(); $this->printUsage();
return 1; return 1;
} }
if ($this->reposFile === '' && $this->org === '') if ($this->reposFile === '' && $this->org === '')
{ {
$this->log('ERROR: Either --repos <file> or --org <org> is required.'); $this->log('ERROR: Either --repos <file> or --org <org> is required.');
$this->printUsage(); $this->printUsage();
return 1; return 1;
} }
// Build repo list // Build repo list
$repos = $this->buildRepoList(); $repos = $this->buildRepoList();
if ($repos === null || count($repos) === 0) if ($repos === null || count($repos) === 0)
{ {
$this->log('ERROR: No repos found to process.'); $this->log('ERROR: No repos found to process.');
return 1; return 1;
} }
$this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s)."); $this->log("Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s).");
$this->log("Gitea URL: {$this->giteaUrl}"); $this->log("Gitea URL: {$this->giteaUrl}");
if ($this->dryRun) if ($this->dryRun)
{ {
$this->log('[DRY RUN] No requests will be sent.'); $this->log('[DRY RUN] No requests will be sent.');
} }
$this->log(''); $this->log('');
// Parse inputs // Parse inputs
$inputsDecoded = null; $inputsDecoded = null;
if ($this->inputs !== '') if ($this->inputs !== '')
{ {
$inputsDecoded = json_decode($this->inputs, true); $inputsDecoded = json_decode($this->inputs, true);
if (!is_array($inputsDecoded)) if (!is_array($inputsDecoded))
{ {
$this->log('ERROR: --inputs must be valid JSON.'); $this->log('ERROR: --inputs must be valid JSON.');
return 1; return 1;
} }
} }
// Print header // Print header
$this->log(sprintf('%-40s | %s', 'Repo', 'Status')); $this->log(sprintf('%-40s | %s', 'Repo', 'Status'));
$this->log(str_repeat('-', 60)); $this->log(str_repeat('-', 60));
$failCount = 0; $failCount = 0;
foreach ($repos as $repo) foreach ($repos as $repo)
{ {
$repo = trim($repo); $repo = trim($repo);
if ($repo === '' || strpos($repo, '/') === false) if ($repo === '' || strpos($repo, '/') === false)
{ {
continue; continue;
} }
[$owner, $repoName] = explode('/', $repo, 2); [$owner, $repoName] = explode('/', $repo, 2);
if ($this->dryRun) if ($this->dryRun)
{ {
$this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)')); $this->log(sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)'));
continue; continue;
} }
$payload = ['ref' => $this->ref]; $payload = ['ref' => $this->ref];
if ($inputsDecoded !== null) if ($inputsDecoded !== null)
{ {
$payload['inputs'] = $inputsDecoded; $payload['inputs'] = $inputsDecoded;
} }
$response = $this->apiRequest( $response = $this->apiRequest(
'POST', 'POST',
"/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches", "/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches",
json_encode($payload) json_encode($payload)
); );
if ($response['code'] >= 200 && $response['code'] < 300) if ($response['code'] >= 200 && $response['code'] < 300)
{ {
$status = 'TRIGGERED'; $status = 'TRIGGERED';
} }
elseif ($response['code'] === 404) elseif ($response['code'] === 404)
{ {
$status = 'FAILED (not found)'; $status = 'FAILED (not found)';
$failCount++; $failCount++;
} }
elseif ($response['code'] === 422) elseif ($response['code'] === 422)
{ {
$status = 'SKIPPED (unprocessable)'; $status = 'SKIPPED (unprocessable)';
} }
else else
{ {
$status = "FAILED (HTTP {$response['code']})"; $status = "FAILED (HTTP {$response['code']})";
$failCount++; $failCount++;
} }
$this->log(sprintf('%-40s | %s', $repo, $status)); $this->log(sprintf('%-40s | %s', $repo, $status));
} }
$this->log(''); $this->log('');
$this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.')); $this->log('Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.'));
return $failCount > 0 ? 1 : 0; return $failCount > 0 ? 1 : 0;
} }
private function parseArgs(): void private function parseArgs(): void
{ {
$args = $_SERVER['argv'] ?? []; $args = $_SERVER['argv'] ?? [];
$count = count($args); $count = count($args);
for ($i = 1; $i < $count; $i++) for ($i = 1; $i < $count; $i++)
{ {
switch ($args[$i]) switch ($args[$i])
{ {
case '--gitea-url': case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/'); $this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break; break;
case '--token': case '--token':
$this->token = $args[++$i] ?? ''; $this->token = $args[++$i] ?? '';
break; break;
case '--repos': case '--repos':
$this->reposFile = $args[++$i] ?? ''; $this->reposFile = $args[++$i] ?? '';
break; break;
case '--org': case '--org':
$this->org = $args[++$i] ?? ''; $this->org = $args[++$i] ?? '';
break; break;
case '--workflow': case '--workflow':
$this->workflow = $args[++$i] ?? ''; $this->workflow = $args[++$i] ?? '';
break; break;
case '--ref': case '--ref':
$this->ref = $args[++$i] ?? 'main'; $this->ref = $args[++$i] ?? 'main';
break; break;
case '--inputs': case '--inputs':
$this->inputs = $args[++$i] ?? ''; $this->inputs = $args[++$i] ?? '';
break; break;
case '--dry-run': case '--dry-run':
$this->dryRun = true; $this->dryRun = true;
break; break;
case '--help': case '--help':
case '-h': case '-h':
$this->printUsage(); $this->printUsage();
exit(0); exit(0);
default: default:
$this->log("WARNING: Unknown argument: {$args[$i]}"); $this->log("WARNING: Unknown argument: {$args[$i]}");
break; break;
} }
} }
} }
private function printUsage(): void private function printUsage(): void
{ {
$this->log('Usage: bulk_workflow_trigger.php --token <token> --workflow <file> [options]'); $this->log('Usage: bulk_workflow_trigger.php --token <token> --workflow <file> [options]');
$this->log(''); $this->log('');
$this->log('Options:'); $this->log('Options:');
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)'); $this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token'); $this->log(' --token <token> Gitea API token');
$this->log(' --repos <file> File with newline-separated owner/repo list'); $this->log(' --repos <file> File with newline-separated owner/repo list');
$this->log(' --org <org> Trigger on all repos in an org'); $this->log(' --org <org> Trigger on all repos in an org');
$this->log(' --workflow <filename> Workflow file (e.g., "sync-servers.yml")'); $this->log(' --workflow <filename> Workflow file (e.g., "sync-servers.yml")');
$this->log(' --ref <branch> Branch ref (default: "main")'); $this->log(' --ref <branch> Branch ref (default: "main")');
$this->log(' --inputs <json> Workflow inputs as JSON string'); $this->log(' --inputs <json> Workflow inputs as JSON string');
$this->log(' --dry-run Show what would be done without triggering'); $this->log(' --dry-run Show what would be done without triggering');
$this->log(' --help, -h Show this help'); $this->log(' --help, -h Show this help');
} }
private function buildRepoList(): ?array private function buildRepoList(): ?array
{ {
if ($this->reposFile !== '') if ($this->reposFile !== '')
{ {
if (!file_exists($this->reposFile)) if (!file_exists($this->reposFile))
{ {
$this->log("ERROR: Repos file not found: {$this->reposFile}"); $this->log("ERROR: Repos file not found: {$this->reposFile}");
return null; return null;
} }
$content = file_get_contents($this->reposFile); $content = file_get_contents($this->reposFile);
$lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool { $lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool {
return $line !== '' && $line[0] !== '#'; return $line !== '' && $line[0] !== '#';
}); });
return array_values($lines); return array_values($lines);
} }
// Fetch all repos from org // Fetch all repos from org
$this->log("Fetching repos from org: {$this->org}"); $this->log("Fetching repos from org: {$this->org}");
$page = 1; $page = 1;
$repos = []; $repos = [];
while (true) while (true)
{ {
$response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}"); $response = $this->apiRequest('GET', "/api/v1/orgs/{$this->org}/repos?limit=50&page={$page}");
if ($response['code'] < 200 || $response['code'] >= 300) if ($response['code'] < 200 || $response['code'] >= 300)
{ {
if ($page === 1) if ($page === 1)
{ {
$this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']})."); $this->log("ERROR: Could not fetch repos for org (HTTP {$response['code']}).");
return null; return null;
} }
break; break;
} }
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (!is_array($data) || count($data) === 0) if (!is_array($data) || count($data) === 0)
{ {
break; break;
} }
foreach ($data as $repo) foreach ($data as $repo)
{ {
$fullName = $repo['full_name'] ?? ''; $fullName = $repo['full_name'] ?? '';
if ($fullName !== '') if ($fullName !== '')
{ {
$repos[] = $fullName; $repos[] = $fullName;
} }
} }
$page++; $page++;
} }
$this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\"."); $this->log('Found ' . count($repos) . " repo(s) in org \"{$this->org}\".");
return $repos; return $repos;
} }
private function apiRequest(string $method, string $endpoint, ?string $body = null): array private function apiRequest(string $method, string $endpoint, ?string $body = null): array
{ {
$url = $this->giteaUrl . $endpoint; $url = $this->giteaUrl . $endpoint;
$ch = curl_init(); $ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
'Accept: application/json', 'Accept: application/json',
"Authorization: token {$this->token}", "Authorization: token {$this->token}",
]); ]);
if ($body !== null) if ($body !== null)
{ {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} }
$responseBody = curl_exec($ch); $responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) if (curl_errno($ch))
{ {
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"]; return ['code' => 0, 'body' => "cURL error: {$error}"];
} }
curl_close($ch); curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void private function log(string $message): void
{ {
fwrite(STDERR, $message . PHP_EOL); fwrite(STDERR, $message . PHP_EOL);
} }
} }
$app = new BulkWorkflowTrigger(); $app = new BulkWorkflowTrigger();
exit($app->run()); exit($app->run());
+334 -334
View File
@@ -1,334 +1,334 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.CLI * DEFGROUP: MokoStandards.Scripts.CLI
* INGROUP: MokoStandards * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/client_inventory.php * PATH: /cli/client_inventory.php
* VERSION: 01.00.00 * VERSION: 01.00.00
* BRIEF: Discover and list all client-waas repos with their server configuration status * BRIEF: Discover and list all client-waas repos with their server configuration status
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ClientInventory final class ClientInventory
{ {
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
private bool $jsonOutput = false; private bool $jsonOutput = false;
public function run(): int public function run(): int
{ {
$this->parseArgs(); $this->parseArgs();
if ($this->token === '') if ($this->token === '')
{ {
$this->log('ERROR: --token is required.'); $this->log('ERROR: --token is required.');
$this->printUsage(); $this->printUsage();
return 1; return 1;
} }
$this->log("Scanning Gitea instance: {$this->giteaUrl}"); $this->log("Scanning Gitea instance: {$this->giteaUrl}");
// Step 1: List all orgs // Step 1: List all orgs
$orgs = $this->fetchOrgs(); $orgs = $this->fetchOrgs();
if ($orgs === null) if ($orgs === null)
{ {
$this->log('ERROR: Failed to fetch organizations.'); $this->log('ERROR: Failed to fetch organizations.');
return 1; return 1;
} }
$this->log('Found ' . count($orgs) . ' organization(s).'); $this->log('Found ' . count($orgs) . ' organization(s).');
// Step 2 & 3: For each org, find client-waas repos // Step 2 & 3: For each org, find client-waas repos
$inventory = []; $inventory = [];
foreach ($orgs as $org) foreach ($orgs as $org)
{ {
$orgName = $org['username'] ?? $org['name'] ?? ''; $orgName = $org['username'] ?? $org['name'] ?? '';
if ($orgName === '') if ($orgName === '')
{ {
continue; continue;
} }
$repos = $this->fetchOrgRepos($orgName); $repos = $this->fetchOrgRepos($orgName);
if ($repos === null) if ($repos === null)
{ {
$this->log("WARNING: Could not fetch repos for org: {$orgName}"); $this->log("WARNING: Could not fetch repos for org: {$orgName}");
continue; continue;
} }
foreach ($repos as $repo) foreach ($repos as $repo)
{ {
$repoName = $repo['name'] ?? ''; $repoName = $repo['name'] ?? '';
if (strpos($repoName, 'client-waas') === false) if (strpos($repoName, 'client-waas') === false)
{ {
continue; continue;
} }
$hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']); $hasDevConfig = $this->checkVariables($orgName, $repoName, ['DEV_SYNC_HOST', 'DEV_SYNC_PATH']);
$hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']); $hasLiveConfig = $this->checkVariables($orgName, $repoName, ['LIVE_SSH_HOST', 'LIVE_SYNC_PATH']);
$lastPush = $repo['updated_at'] ?? 'unknown'; $lastPush = $repo['updated_at'] ?? 'unknown';
if ($lastPush !== 'unknown') if ($lastPush !== 'unknown')
{ {
$lastPush = substr($lastPush, 0, 19); $lastPush = substr($lastPush, 0, 19);
} }
$status = 'OK'; $status = 'OK';
if (!$hasDevConfig && !$hasLiveConfig) if (!$hasDevConfig && !$hasLiveConfig)
{ {
$status = 'UNCONFIGURED'; $status = 'UNCONFIGURED';
} }
elseif (!$hasDevConfig) elseif (!$hasDevConfig)
{ {
$status = 'NO DEV'; $status = 'NO DEV';
} }
elseif (!$hasLiveConfig) elseif (!$hasLiveConfig)
{ {
$status = 'NO LIVE'; $status = 'NO LIVE';
} }
$inventory[] = [ $inventory[] = [
'org' => $orgName, 'org' => $orgName,
'repo' => $repoName, 'repo' => $repoName,
'has_dev_config' => $hasDevConfig, 'has_dev_config' => $hasDevConfig,
'has_live_config' => $hasLiveConfig, 'has_live_config' => $hasLiveConfig,
'last_push' => $lastPush, 'last_push' => $lastPush,
'status' => $status, 'status' => $status,
]; ];
} }
} }
// Output results // Output results
if ($this->jsonOutput) if ($this->jsonOutput)
{ {
fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); fwrite(STDOUT, json_encode($inventory, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
return 0; return 0;
} }
if (count($inventory) === 0) if (count($inventory) === 0)
{ {
$this->log('No client-waas repos found.'); $this->log('No client-waas repos found.');
return 0; return 0;
} }
// Print table // Print table
$this->log(''); $this->log('');
$this->log(sprintf( $this->log(sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s', '%-20s | %-35s | %-10s | %-11s | %-19s | %s',
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status' 'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status'
)); ));
$this->log(str_repeat('-', 120)); $this->log(str_repeat('-', 120));
foreach ($inventory as $entry) foreach ($inventory as $entry)
{ {
$this->log(sprintf( $this->log(sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s', '%-20s | %-35s | %-10s | %-11s | %-19s | %s',
$entry['org'], $entry['org'],
$entry['repo'], $entry['repo'],
$entry['has_dev_config'] ? 'Yes' : 'No', $entry['has_dev_config'] ? 'Yes' : 'No',
$entry['has_live_config'] ? 'Yes' : 'No', $entry['has_live_config'] ? 'Yes' : 'No',
$entry['last_push'], $entry['last_push'],
$entry['status'] $entry['status']
)); ));
} }
$this->log(''); $this->log('');
$this->log('Total: ' . count($inventory) . ' client-waas repo(s).'); $this->log('Total: ' . count($inventory) . ' client-waas repo(s).');
return 0; return 0;
} }
private function parseArgs(): void private function parseArgs(): void
{ {
$args = $_SERVER['argv'] ?? []; $args = $_SERVER['argv'] ?? [];
$count = count($args); $count = count($args);
for ($i = 1; $i < $count; $i++) for ($i = 1; $i < $count; $i++)
{ {
switch ($args[$i]) switch ($args[$i])
{ {
case '--gitea-url': case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/'); $this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break; break;
case '--token': case '--token':
$this->token = $args[++$i] ?? ''; $this->token = $args[++$i] ?? '';
break; break;
case '--json': case '--json':
$this->jsonOutput = true; $this->jsonOutput = true;
break; break;
case '--help': case '--help':
case '-h': case '-h':
$this->printUsage(); $this->printUsage();
exit(0); exit(0);
default: default:
$this->log("WARNING: Unknown argument: {$args[$i]}"); $this->log("WARNING: Unknown argument: {$args[$i]}");
break; break;
} }
} }
} }
private function printUsage(): void private function printUsage(): void
{ {
$this->log('Usage: client_inventory.php --token <token> [options]'); $this->log('Usage: client_inventory.php --token <token> [options]');
$this->log(''); $this->log('');
$this->log('Options:'); $this->log('Options:');
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)'); $this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token'); $this->log(' --token <token> Gitea API token');
$this->log(' --json Output results as JSON'); $this->log(' --json Output results as JSON');
$this->log(' --help, -h Show this help'); $this->log(' --help, -h Show this help');
} }
private function fetchOrgs(): ?array private function fetchOrgs(): ?array
{ {
// Try admin endpoint first, fall back to user-visible orgs // Try admin endpoint first, fall back to user-visible orgs
$response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50'); $response = $this->apiRequest('GET', '/api/v1/admin/orgs?limit=50');
if ($response['code'] >= 200 && $response['code'] < 300) if ($response['code'] >= 200 && $response['code'] < 300)
{ {
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (is_array($data)) if (is_array($data))
{ {
return $data; return $data;
} }
} }
$this->log('Admin orgs endpoint unavailable, falling back to user orgs...'); $this->log('Admin orgs endpoint unavailable, falling back to user orgs...');
$response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50'); $response = $this->apiRequest('GET', '/api/v1/user/orgs?limit=50');
if ($response['code'] >= 200 && $response['code'] < 300) if ($response['code'] >= 200 && $response['code'] < 300)
{ {
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (is_array($data)) if (is_array($data))
{ {
return $data; return $data;
} }
} }
return null; return null;
} }
private function fetchOrgRepos(string $org): ?array private function fetchOrgRepos(string $org): ?array
{ {
$page = 1; $page = 1;
$allRepos = []; $allRepos = [];
while (true) while (true)
{ {
$response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}"); $response = $this->apiRequest('GET', "/api/v1/orgs/{$org}/repos?limit=50&page={$page}");
if ($response['code'] < 200 || $response['code'] >= 300) if ($response['code'] < 200 || $response['code'] >= 300)
{ {
return $page === 1 ? null : $allRepos; return $page === 1 ? null : $allRepos;
} }
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (!is_array($data) || count($data) === 0) if (!is_array($data) || count($data) === 0)
{ {
break; break;
} }
$allRepos = array_merge($allRepos, $data); $allRepos = array_merge($allRepos, $data);
$page++; $page++;
} }
return $allRepos; return $allRepos;
} }
private function checkVariables(string $org, string $repo, array $requiredVars): bool private function checkVariables(string $org, string $repo, array $requiredVars): bool
{ {
$response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables"); $response = $this->apiRequest('GET', "/api/v1/repos/{$org}/{$repo}/actions/variables");
if ($response['code'] < 200 || $response['code'] >= 300) if ($response['code'] < 200 || $response['code'] >= 300)
{ {
return false; return false;
} }
$data = json_decode($response['body'], true); $data = json_decode($response['body'], true);
if (!is_array($data)) if (!is_array($data))
{ {
return false; return false;
} }
$existingVars = []; $existingVars = [];
foreach ($data as $variable) foreach ($data as $variable)
{ {
if (isset($variable['name'])) if (isset($variable['name']))
{ {
$existingVars[] = $variable['name']; $existingVars[] = $variable['name'];
} }
} }
foreach ($requiredVars as $var) foreach ($requiredVars as $var)
{ {
if (!in_array($var, $existingVars, true)) if (!in_array($var, $existingVars, true))
{ {
return false; return false;
} }
} }
return true; return true;
} }
private function apiRequest(string $method, string $endpoint, ?string $body = null): array private function apiRequest(string $method, string $endpoint, ?string $body = null): array
{ {
$url = $this->giteaUrl . $endpoint; $url = $this->giteaUrl . $endpoint;
$ch = curl_init(); $ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
'Accept: application/json', 'Accept: application/json',
"Authorization: token {$this->token}", "Authorization: token {$this->token}",
]); ]);
if ($body !== null) if ($body !== null)
{ {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} }
$responseBody = curl_exec($ch); $responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) if (curl_errno($ch))
{ {
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"]; return ['code' => 0, 'body' => "cURL error: {$error}"];
} }
curl_close($ch); curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void private function log(string $message): void
{ {
fwrite(STDERR, $message . PHP_EOL); fwrite(STDERR, $message . PHP_EOL);
} }
} }
$app = new ClientInventory(); $app = new ClientInventory();
exit($app->run()); exit($app->run());
+250 -250
View File
@@ -1,250 +1,250 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.CLI * DEFGROUP: MokoStandards.Scripts.CLI
* INGROUP: MokoStandards * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /cli/scaffold_client.php * PATH: /cli/scaffold_client.php
* VERSION: 01.00.00 * VERSION: 01.00.00
* BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings * BRIEF: Scaffold a new client-waas repo from Template-Client-WaaS with pre-configured settings
*/ */
declare(strict_types=1); declare(strict_types=1);
final class ScaffoldClient final class ScaffoldClient
{ {
private string $name = ''; private string $name = '';
private string $org = ''; private string $org = '';
private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $giteaUrl = 'https://git.mokoconsulting.tech';
private string $token = ''; private string $token = '';
private bool $dryRun = false; private bool $dryRun = false;
public function run(): int public function run(): int
{ {
$this->parseArgs(); $this->parseArgs();
if ($this->name === '' || $this->org === '' || $this->token === '') if ($this->name === '' || $this->org === '' || $this->token === '')
{ {
$this->log('ERROR: --name, --org, and --token are required.'); $this->log('ERROR: --name, --org, and --token are required.');
$this->printUsage(); $this->printUsage();
return 1; return 1;
} }
$repoName = 'client-waas-' . $this->name; $repoName = 'client-waas-' . $this->name;
$this->log("Scaffolding client repo: {$this->org}/{$repoName}"); $this->log("Scaffolding client repo: {$this->org}/{$repoName}");
$this->log("Gitea URL: {$this->giteaUrl}"); $this->log("Gitea URL: {$this->giteaUrl}");
if ($this->dryRun) if ($this->dryRun)
{ {
$this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); $this->log('[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
$this->log("[DRY RUN] Repo: {$this->org}/{$repoName}"); $this->log("[DRY RUN] Repo: {$this->org}/{$repoName}");
$this->log("[DRY RUN] Description: \"{$this->name} WaaS site\""); $this->log("[DRY RUN] Description: \"{$this->name} WaaS site\"");
$this->log('[DRY RUN] Would create dev branch from main'); $this->log('[DRY RUN] Would create dev branch from main');
$this->printPostSetupInstructions($repoName); $this->printPostSetupInstructions($repoName);
return 0; return 0;
} }
// Step 1: Create repo from template // Step 1: Create repo from template
$this->log('Step 1: Creating repo from template...'); $this->log('Step 1: Creating repo from template...');
$createPayload = json_encode([ $createPayload = json_encode([
'owner' => $this->org, 'owner' => $this->org,
'name' => $repoName, 'name' => $repoName,
'description' => "{$this->name} WaaS site", 'description' => "{$this->name} WaaS site",
'private' => true, 'private' => true,
'git_content' => true, 'git_content' => true,
'topics' => true, 'topics' => true,
'labels' => true, 'labels' => true,
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest(
'POST', 'POST',
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
$createPayload $createPayload
); );
if ($response['code'] < 200 || $response['code'] >= 300) if ($response['code'] < 200 || $response['code'] >= 300)
{ {
$this->log("ERROR: Failed to create repo (HTTP {$response['code']})."); $this->log("ERROR: Failed to create repo (HTTP {$response['code']}).");
$this->log("Response: {$response['body']}"); $this->log("Response: {$response['body']}");
return 1; return 1;
} }
$this->log("Repo created: {$this->org}/{$repoName}"); $this->log("Repo created: {$this->org}/{$repoName}");
// Step 2: Set repo description (already set via generate, but confirm) // Step 2: Set repo description (already set via generate, but confirm)
$this->log('Step 2: Updating repo description...'); $this->log('Step 2: Updating repo description...');
$updatePayload = json_encode([ $updatePayload = json_encode([
'description' => "{$this->name} WaaS site", 'description' => "{$this->name} WaaS site",
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest(
'PATCH', 'PATCH',
"/api/v1/repos/{$this->org}/{$repoName}", "/api/v1/repos/{$this->org}/{$repoName}",
$updatePayload $updatePayload
); );
if ($response['code'] >= 200 && $response['code'] < 300) if ($response['code'] >= 200 && $response['code'] < 300)
{ {
$this->log('Description updated.'); $this->log('Description updated.');
} }
else else
{ {
$this->log("WARNING: Could not update description (HTTP {$response['code']})."); $this->log("WARNING: Could not update description (HTTP {$response['code']}).");
} }
// Step 3: Create dev branch from main // Step 3: Create dev branch from main
$this->log('Step 3: Creating dev branch from main...'); $this->log('Step 3: Creating dev branch from main...');
$branchPayload = json_encode([ $branchPayload = json_encode([
'new_branch_name' => 'dev', 'new_branch_name' => 'dev',
'old_branch_name' => 'main', 'old_branch_name' => 'main',
]); ]);
$response = $this->apiRequest( $response = $this->apiRequest(
'POST', 'POST',
"/api/v1/repos/{$this->org}/{$repoName}/branches", "/api/v1/repos/{$this->org}/{$repoName}/branches",
$branchPayload $branchPayload
); );
if ($response['code'] >= 200 && $response['code'] < 300) if ($response['code'] >= 200 && $response['code'] < 300)
{ {
$this->log('Branch "dev" created from "main".'); $this->log('Branch "dev" created from "main".');
} }
else else
{ {
$this->log("WARNING: Could not create dev branch (HTTP {$response['code']})."); $this->log("WARNING: Could not create dev branch (HTTP {$response['code']}).");
$this->log("Response: {$response['body']}"); $this->log("Response: {$response['body']}");
} }
// Step 4: Print post-setup instructions // Step 4: Print post-setup instructions
$this->printPostSetupInstructions($repoName); $this->printPostSetupInstructions($repoName);
$this->log('Scaffold complete.'); $this->log('Scaffold complete.');
return 0; return 0;
} }
private function parseArgs(): void private function parseArgs(): void
{ {
$args = $_SERVER['argv'] ?? []; $args = $_SERVER['argv'] ?? [];
$count = count($args); $count = count($args);
for ($i = 1; $i < $count; $i++) for ($i = 1; $i < $count; $i++)
{ {
switch ($args[$i]) switch ($args[$i])
{ {
case '--name': case '--name':
$this->name = $args[++$i] ?? ''; $this->name = $args[++$i] ?? '';
break; break;
case '--org': case '--org':
$this->org = $args[++$i] ?? ''; $this->org = $args[++$i] ?? '';
break; break;
case '--gitea-url': case '--gitea-url':
$this->giteaUrl = rtrim($args[++$i] ?? '', '/'); $this->giteaUrl = rtrim($args[++$i] ?? '', '/');
break; break;
case '--token': case '--token':
$this->token = $args[++$i] ?? ''; $this->token = $args[++$i] ?? '';
break; break;
case '--dry-run': case '--dry-run':
$this->dryRun = true; $this->dryRun = true;
break; break;
case '--help': case '--help':
case '-h': case '-h':
$this->printUsage(); $this->printUsage();
exit(0); exit(0);
default: default:
$this->log("WARNING: Unknown argument: {$args[$i]}"); $this->log("WARNING: Unknown argument: {$args[$i]}");
break; break;
} }
} }
} }
private function printUsage(): void private function printUsage(): void
{ {
$this->log('Usage: scaffold_client.php --name <client-name> --org <gitea-org> --token <token> [options]'); $this->log('Usage: scaffold_client.php --name <client-name> --org <gitea-org> --token <token> [options]');
$this->log(''); $this->log('');
$this->log('Options:'); $this->log('Options:');
$this->log(' --name <name> Client name (e.g., "clarksvillefurs")'); $this->log(' --name <name> Client name (e.g., "clarksvillefurs")');
$this->log(' --org <org> Gitea organization (e.g., "ClarksvilleFurs")'); $this->log(' --org <org> Gitea organization (e.g., "ClarksvilleFurs")');
$this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)'); $this->log(' --gitea-url <url> Gitea URL (default: https://git.mokoconsulting.tech)');
$this->log(' --token <token> Gitea API token'); $this->log(' --token <token> Gitea API token');
$this->log(' --dry-run Show what would be done without making changes'); $this->log(' --dry-run Show what would be done without making changes');
$this->log(' --help, -h Show this help'); $this->log(' --help, -h Show this help');
} }
private function printPostSetupInstructions(string $repoName): void private function printPostSetupInstructions(string $repoName): void
{ {
$this->log(''); $this->log('');
$this->log('=== POST-SETUP INSTRUCTIONS ==='); $this->log('=== POST-SETUP INSTRUCTIONS ===');
$this->log(''); $this->log('');
$this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings"); $this->log("Navigate to: {$this->giteaUrl}/{$this->org}/{$repoName}/settings");
$this->log(''); $this->log('');
$this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):'); $this->log('Set the following REPO VARIABLES (Settings > Actions > Variables):');
$this->log(' DEV_SYNC_HOST - Dev server hostname or IP'); $this->log(' DEV_SYNC_HOST - Dev server hostname or IP');
$this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)'); $this->log(' DEV_SYNC_PORT - Dev server SSH port (default: 22)');
$this->log(' DEV_SYNC_USER - Dev server SSH username'); $this->log(' DEV_SYNC_USER - Dev server SSH username');
$this->log(' DEV_SYNC_PATH - Dev server deploy path'); $this->log(' DEV_SYNC_PATH - Dev server deploy path');
$this->log(' LIVE_SSH_HOST - Live server hostname or IP'); $this->log(' LIVE_SSH_HOST - Live server hostname or IP');
$this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)'); $this->log(' LIVE_SSH_PORT - Live server SSH port (default: 22)');
$this->log(' LIVE_SSH_USER - Live server SSH username'); $this->log(' LIVE_SSH_USER - Live server SSH username');
$this->log(' LIVE_SYNC_PATH - Live server deploy path'); $this->log(' LIVE_SYNC_PATH - Live server deploy path');
$this->log(''); $this->log('');
$this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):'); $this->log('Set the following REPO SECRETS (Settings > Actions > Secrets):');
$this->log(' DEV_SYNC_KEY - Private SSH key for dev server'); $this->log(' DEV_SYNC_KEY - Private SSH key for dev server');
$this->log(' LIVE_SSH_KEY - Private SSH key for live server'); $this->log(' LIVE_SSH_KEY - Private SSH key for live server');
$this->log(''); $this->log('');
$this->log('================================'); $this->log('================================');
} }
private function apiRequest(string $method, string $endpoint, ?string $body = null): array private function apiRequest(string $method, string $endpoint, ?string $body = null): array
{ {
$url = $this->giteaUrl . $endpoint; $url = $this->giteaUrl . $endpoint;
$ch = curl_init(); $ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
'Accept: application/json', 'Accept: application/json',
"Authorization: token {$this->token}", "Authorization: token {$this->token}",
]); ]);
if ($body !== null) if ($body !== null)
{ {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} }
$responseBody = curl_exec($ch); $responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) if (curl_errno($ch))
{ {
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"]; return ['code' => 0, 'body' => "cURL error: {$error}"];
} }
curl_close($ch); curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody]; return ['code' => $httpCode, 'body' => $responseBody];
} }
private function log(string $message): void private function log(string $message): void
{ {
fwrite(STDERR, $message . PHP_EOL); fwrite(STDERR, $message . PHP_EOL);
} }
} }
$app = new ScaffoldClient(); $app = new ScaffoldClient();
exit($app->run()); exit($app->run());
+212 -212
View File
@@ -1,212 +1,212 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoStandards.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/backup-before-deploy.php * PATH: /deploy/backup-before-deploy.php
* VERSION: 01.00.00 * VERSION: 01.00.00
* BRIEF: Snapshot Joomla directories before deployment for rollback capability * BRIEF: Snapshot Joomla directories before deployment for rollback capability
*/ */
declare(strict_types=1); declare(strict_types=1);
class BackupBeforeDeploy class BackupBeforeDeploy
{ {
private bool $verbose = false; private bool $verbose = false;
private string $configPath = ''; private string $configPath = '';
private string $outputDir = ''; private string $outputDir = '';
private const JOOMLA_DIRS = [ private const JOOMLA_DIRS = [
'administrator/components', 'administrator/components',
'administrator/language', 'administrator/language',
'administrator/modules', 'administrator/modules',
'administrator/templates', 'administrator/templates',
'components', 'components',
'language', 'language',
'layouts', 'layouts',
'libraries', 'libraries',
'media', 'media',
'modules', 'modules',
'plugins', 'plugins',
'templates', 'templates',
]; ];
public function run(): int public function run(): int
{ {
$this->parseArgs(); $this->parseArgs();
if ($this->configPath === '') { if ($this->configPath === '') {
$this->log('Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]'); $this->log('Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
return 1; return 1;
} }
if ($this->outputDir === '') { if ($this->outputDir === '') {
$this->outputDir = '/tmp/moko-snapshot-' . date('Ymd-His'); $this->outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
} }
$config = $this->loadConfig($this->configPath); $config = $this->loadConfig($this->configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
$host = $config['host'] ?? ''; $host = $config['host'] ?? '';
$user = $config['user'] ?? ''; $user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22); $port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/'); $remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR: Config must contain host, user, and remote_path.'); $this->log('ERROR: Config must contain host, user, and remote_path.');
return 1; return 1;
} }
// Create output directory // Create output directory
if (!is_dir($this->outputDir)) { if (!is_dir($this->outputDir)) {
if (!mkdir($this->outputDir, 0755, true)) { if (!mkdir($this->outputDir, 0755, true)) {
$this->log("ERROR: Could not create output directory: {$this->outputDir}"); $this->log("ERROR: Could not create output directory: {$this->outputDir}");
return 1; return 1;
} }
} }
$this->log('Starting pre-deploy snapshot...'); $this->log('Starting pre-deploy snapshot...');
$this->log("Source: {$user}@{$host}:{$remotePath}"); $this->log("Source: {$user}@{$host}:{$remotePath}");
$this->log("Output: {$this->outputDir}"); $this->log("Output: {$this->outputDir}");
$failed = 0; $failed = 0;
foreach (self::JOOMLA_DIRS as $dir) { foreach (self::JOOMLA_DIRS as $dir) {
$remoteSource = "{$remotePath}/{$dir}/"; $remoteSource = "{$remotePath}/{$dir}/";
$localTarget = rtrim($this->outputDir, '/\\') . '/' . $dir . '/'; $localTarget = rtrim($this->outputDir, '/\\') . '/' . $dir . '/';
// Ensure local subdirectory exists // Ensure local subdirectory exists
if (!is_dir($localTarget)) { if (!is_dir($localTarget)) {
mkdir($localTarget, 0755, true); mkdir($localTarget, 0755, true);
} }
$sshCmd = "ssh -p {$port}"; $sshCmd = "ssh -p {$port}";
if ($sshKey !== '') { if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$cmd = $this->buildRsyncCommand( $cmd = $this->buildRsyncCommand(
$sshCmd, $sshCmd,
"{$user}@{$host}:{$remoteSource}", "{$user}@{$host}:{$remoteSource}",
$localTarget $localTarget
); );
$this->log("Downloading: {$dir}"); $this->log("Downloading: {$dir}");
if ($this->verbose) { if ($this->verbose) {
$this->log("CMD: {$cmd}"); $this->log("CMD: {$cmd}");
} }
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); $this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log(" {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log(" {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log("Snapshot completed with {$failed} error(s)."); $this->log("Snapshot completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log(''); $this->log('');
$this->log('Snapshot completed successfully.'); $this->log('Snapshot completed successfully.');
$this->log("SNAPSHOT_PATH={$this->outputDir}"); $this->log("SNAPSHOT_PATH={$this->outputDir}");
$this->log(''); $this->log('');
$this->log('To rollback, run:'); $this->log('To rollback, run:');
$this->log(" php rollback-joomla.php --config {$this->configPath} --snapshot-dir {$this->outputDir}"); $this->log(" php rollback-joomla.php --config {$this->configPath} --snapshot-dir {$this->outputDir}");
return 0; return 0;
} }
private function parseArgs(): void private function parseArgs(): void
{ {
$args = $_SERVER['argv'] ?? []; $args = $_SERVER['argv'] ?? [];
$count = count($args); $count = count($args);
for ($i = 1; $i < $count; $i++) { for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) { switch ($args[$i]) {
case '--config': case '--config':
$this->configPath = $args[++$i] ?? ''; $this->configPath = $args[++$i] ?? '';
break; break;
case '--output': case '--output':
$this->outputDir = $args[++$i] ?? ''; $this->outputDir = $args[++$i] ?? '';
break; break;
case '--verbose': case '--verbose':
$this->verbose = true; $this->verbose = true;
break; break;
} }
} }
} }
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log("ERROR: Config file not found: {$path}"); $this->log("ERROR: Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log("ERROR: Could not read config file: {$path}"); $this->log("ERROR: Could not read config file: {$path}");
return null; return null;
} }
// Strip // comments (sftp-config.json style) // Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw); $cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR: Invalid JSON in config file.'); $this->log('ERROR: Invalid JSON in config file.');
return null; return null;
} }
return $config; return $config;
} }
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
{ {
$parts = ['rsync', '-rlptz', '--exclude=configuration.php']; $parts = ['rsync', '-rlptz', '--exclude=configuration.php'];
if ($this->verbose) { if ($this->verbose) {
$parts[] = '-v'; $parts[] = '-v';
} }
$parts[] = '-e'; $parts[] = '-e';
$parts[] = escapeshellarg($sshCmd); $parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source); $parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest); $parts[] = escapeshellarg($dest);
return implode(' ', $parts); return implode(' ', $parts);
} }
private function log(string $message): void private function log(string $message): void
{ {
$timestamp = date('Y-m-d H:i:s'); $timestamp = date('Y-m-d H:i:s');
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
} }
} }
$app = new BackupBeforeDeploy(); $app = new BackupBeforeDeploy();
exit($app->run()); exit($app->run());
+301 -301
View File
@@ -1,301 +1,301 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoStandards.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/deploy-dolibarr.php * PATH: /deploy/deploy-dolibarr.php
* VERSION: 01.00.00 * VERSION: 01.00.00
* BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync * BRIEF: Deploy Dolibarr module files to a remote server via SFTP/rsync
*/ */
declare(strict_types=1); declare(strict_types=1);
class DeployDolibarr class DeployDolibarr
{ {
private bool $verbose = false; private bool $verbose = false;
private bool $dryRun = false; private bool $dryRun = false;
private string $configPath = ''; private string $configPath = '';
private string $source = ''; private string $source = '';
private const MODULE_DIRS = [ private const MODULE_DIRS = [
'core/modules', 'core/modules',
'class', 'class',
'lib', 'lib',
'sql', 'sql',
'langs', 'langs',
'css', 'css',
'js', 'js',
'img', 'img',
]; ];
private const EXCLUDES = [ private const EXCLUDES = [
'.git/', '.git/',
'vendor/', 'vendor/',
'tests/', 'tests/',
'node_modules/', 'node_modules/',
]; ];
public function run(): int public function run(): int
{ {
$this->parseArgs(); $this->parseArgs();
if ($this->configPath === '' || $this->source === '') { if ($this->configPath === '' || $this->source === '') {
$this->log('Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]'); $this->log('Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
return 1; return 1;
} }
if (!is_dir($this->source)) { if (!is_dir($this->source)) {
$this->log("ERROR: Source directory does not exist: {$this->source}"); $this->log("ERROR: Source directory does not exist: {$this->source}");
return 1; return 1;
} }
$moduleName = $this->detectModuleName(); $moduleName = $this->detectModuleName();
if ($moduleName === null) { if ($moduleName === null) {
$this->log('ERROR: Could not auto-detect module name. Expected core/modules/mod*.class.php'); $this->log('ERROR: Could not auto-detect module name. Expected core/modules/mod*.class.php');
return 1; return 1;
} }
$config = $this->loadConfig($this->configPath); $config = $this->loadConfig($this->configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
$host = $config['host'] ?? ''; $host = $config['host'] ?? '';
$user = $config['user'] ?? ''; $user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22); $port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/'); $remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR: Config must contain host, user, and remote_path.'); $this->log('ERROR: Config must contain host, user, and remote_path.');
return 1; return 1;
} }
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}"; $remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
$this->log("Deploying Dolibarr module: {$moduleName}"); $this->log("Deploying Dolibarr module: {$moduleName}");
$this->log("Source: {$this->source}"); $this->log("Source: {$this->source}");
$this->log("Target: {$user}@{$host}:{$remoteBase}"); $this->log("Target: {$user}@{$host}:{$remoteBase}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('*** DRY RUN — no changes will be made ***'); $this->log('*** DRY RUN — no changes will be made ***');
} }
$failed = 0; $failed = 0;
// Deploy subdirectories // Deploy subdirectories
foreach (self::MODULE_DIRS as $dir) { foreach (self::MODULE_DIRS as $dir) {
$localDir = rtrim($this->source, '/\\') . '/' . $dir . '/'; $localDir = rtrim($this->source, '/\\') . '/' . $dir . '/';
if (!is_dir($localDir)) { if (!is_dir($localDir)) {
if ($this->verbose) { if ($this->verbose) {
$this->log("SKIP: {$dir} (not present in source)"); $this->log("SKIP: {$dir} (not present in source)");
} }
continue; continue;
} }
$remoteTarget = "{$remoteBase}/{$dir}/"; $remoteTarget = "{$remoteBase}/{$dir}/";
$result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey); $result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey);
if (!$result) { if (!$result) {
$failed++; $failed++;
} }
} }
// Deploy root PHP files // Deploy root PHP files
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php'); $rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
if (!empty($rootPhpFiles)) { if (!empty($rootPhpFiles)) {
$this->log('Syncing root PHP files...'); $this->log('Syncing root PHP files...');
$sourceRoot = rtrim($this->source, '/\\') . '/'; $sourceRoot = rtrim($this->source, '/\\') . '/';
$remoteTarget = "{$remoteBase}/"; $remoteTarget = "{$remoteBase}/";
$sshCmd = "ssh -p {$port}"; $sshCmd = "ssh -p {$port}";
if ($sshKey !== '') { if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$cmd = $this->buildRsyncCommand( $cmd = $this->buildRsyncCommand(
$sshCmd, $sshCmd,
$sourceRoot, $sourceRoot,
"{$user}@{$host}:{$remoteTarget}", "{$user}@{$host}:{$remoteTarget}",
['--include=*.php', '--exclude=*/', '--exclude=.*'] ['--include=*.php', '--exclude=*/', '--exclude=.*']
); );
if ($this->verbose) { if ($this->verbose) {
$this->log("CMD: {$cmd}"); $this->log("CMD: {$cmd}");
} }
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log("ERROR: rsync failed for root PHP files (exit code {$exitCode})"); $this->log("ERROR: rsync failed for root PHP files (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log(" {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log(" {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log("Deployment completed with {$failed} error(s)."); $this->log("Deployment completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log('Deployment completed successfully.'); $this->log('Deployment completed successfully.');
return 0; return 0;
} }
private function parseArgs(): void private function parseArgs(): void
{ {
$args = $_SERVER['argv'] ?? []; $args = $_SERVER['argv'] ?? [];
$count = count($args); $count = count($args);
for ($i = 1; $i < $count; $i++) { for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) { switch ($args[$i]) {
case '--source': case '--source':
$this->source = $args[++$i] ?? ''; $this->source = $args[++$i] ?? '';
break; break;
case '--config': case '--config':
$this->configPath = $args[++$i] ?? ''; $this->configPath = $args[++$i] ?? '';
break; break;
case '--dry-run': case '--dry-run':
$this->dryRun = true; $this->dryRun = true;
break; break;
case '--verbose': case '--verbose':
$this->verbose = true; $this->verbose = true;
break; break;
} }
} }
} }
private function detectModuleName(): ?string private function detectModuleName(): ?string
{ {
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php'; $pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
$matches = glob($pattern); $matches = glob($pattern);
if (empty($matches)) { if (empty($matches)) {
return null; return null;
} }
$filename = basename($matches[0]); $filename = basename($matches[0]);
// mod{ModuleName}.class.php → extract ModuleName, lowercase it // mod{ModuleName}.class.php → extract ModuleName, lowercase it
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) { if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
return strtolower($m[1]); return strtolower($m[1]);
} }
return null; return null;
} }
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log("ERROR: Config file not found: {$path}"); $this->log("ERROR: Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log("ERROR: Could not read config file: {$path}"); $this->log("ERROR: Could not read config file: {$path}");
return null; return null;
} }
// Strip // comments (sftp-config.json style) // Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw); $cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR: Invalid JSON in config file.'); $this->log('ERROR: Invalid JSON in config file.');
return null; return null;
} }
return $config; return $config;
} }
private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool
{ {
$dirName = basename(rtrim($localDir, '/')); $dirName = basename(rtrim($localDir, '/'));
$sshCmd = "ssh -p {$port}"; $sshCmd = "ssh -p {$port}";
if ($sshKey !== '') { if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$this->log("Syncing: {$dirName}"); $this->log("Syncing: {$dirName}");
if ($this->verbose) { if ($this->verbose) {
$this->log("CMD: {$cmd}"); $this->log("CMD: {$cmd}");
} }
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log("ERROR: rsync failed for {$dirName} (exit code {$exitCode})"); $this->log("ERROR: rsync failed for {$dirName} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log(" {$line}");
} }
return false; return false;
} }
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log(" {$line}");
} }
} }
return true; return true;
} }
private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string
{ {
$parts = ['rsync', '-rlptz', '--delete']; $parts = ['rsync', '-rlptz', '--delete'];
foreach (self::EXCLUDES as $exclude) { foreach (self::EXCLUDES as $exclude) {
$parts[] = '--exclude=' . $exclude; $parts[] = '--exclude=' . $exclude;
} }
foreach ($extraArgs as $arg) { foreach ($extraArgs as $arg) {
$parts[] = $arg; $parts[] = $arg;
} }
if ($this->dryRun) { if ($this->dryRun) {
$parts[] = '--dry-run'; $parts[] = '--dry-run';
} }
if ($this->verbose) { if ($this->verbose) {
$parts[] = '-v'; $parts[] = '-v';
} }
$parts[] = '-e'; $parts[] = '-e';
$parts[] = escapeshellarg($sshCmd); $parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source); $parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest); $parts[] = escapeshellarg($dest);
return implode(' ', $parts); return implode(' ', $parts);
} }
private function log(string $message): void private function log(string $message): void
{ {
$timestamp = date('Y-m-d H:i:s'); $timestamp = date('Y-m-d H:i:s');
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
} }
} }
$app = new DeployDolibarr(); $app = new DeployDolibarr();
exit($app->run()); exit($app->run());
+227 -227
View File
@@ -1,227 +1,227 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoStandards.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/health-check.php * PATH: /deploy/health-check.php
* VERSION: 01.00.00 * VERSION: 01.00.00
* BRIEF: Post-deploy health check — verify a Joomla site is responding correctly * BRIEF: Post-deploy health check — verify a Joomla site is responding correctly
*/ */
declare(strict_types=1); declare(strict_types=1);
class HealthCheck class HealthCheck
{ {
private string $url = ''; private string $url = '';
private int $timeout = 30; private int $timeout = 30;
private array $checks = ['http']; private array $checks = ['http'];
private int $passed = 0; private int $passed = 0;
private int $failed = 0; private int $failed = 0;
public function run(): int public function run(): int
{ {
$this->parseArgs(); $this->parseArgs();
if ($this->url === '') { if ($this->url === '') {
$this->log('Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]'); $this->log('Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]');
return 1; return 1;
} }
$this->url = rtrim($this->url, '/'); $this->url = rtrim($this->url, '/');
$this->log("Health check for: {$this->url}"); $this->log("Health check for: {$this->url}");
$this->log("Timeout: {$this->timeout}s"); $this->log("Timeout: {$this->timeout}s");
$this->log("Checks: " . implode(', ', $this->checks)); $this->log("Checks: " . implode(', ', $this->checks));
$this->log(''); $this->log('');
foreach ($this->checks as $check) { foreach ($this->checks as $check) {
switch ($check) { switch ($check) {
case 'http': case 'http':
$this->checkHttp(); $this->checkHttp();
break; break;
case 'admin': case 'admin':
$this->checkAdmin(); $this->checkAdmin();
break; break;
case 'api': case 'api':
$this->checkApi(); $this->checkApi();
break; break;
default: default:
$this->log("UNKNOWN CHECK: {$check} — skipping"); $this->log("UNKNOWN CHECK: {$check} — skipping");
break; break;
} }
} }
$this->log(''); $this->log('');
$this->log("Results: {$this->passed} passed, {$this->failed} failed"); $this->log("Results: {$this->passed} passed, {$this->failed} failed");
return $this->failed > 0 ? 1 : 0; return $this->failed > 0 ? 1 : 0;
} }
private function parseArgs(): void private function parseArgs(): void
{ {
$args = $_SERVER['argv'] ?? []; $args = $_SERVER['argv'] ?? [];
$count = count($args); $count = count($args);
for ($i = 1; $i < $count; $i++) { for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) { switch ($args[$i]) {
case '--url': case '--url':
$this->url = $args[++$i] ?? ''; $this->url = $args[++$i] ?? '';
break; break;
case '--timeout': case '--timeout':
$this->timeout = (int) ($args[++$i] ?? 30); $this->timeout = (int) ($args[++$i] ?? 30);
break; break;
case '--checks': case '--checks':
$raw = $args[++$i] ?? 'http'; $raw = $args[++$i] ?? 'http';
$this->checks = array_map('trim', explode(',', $raw)); $this->checks = array_map('trim', explode(',', $raw));
break; break;
} }
} }
} }
private function checkHttp(): void private function checkHttp(): void
{ {
$this->log('[http] GET ' . $this->url); $this->log('[http] GET ' . $this->url);
$result = $this->curlGet($this->url); $result = $this->curlGet($this->url);
if ($result === null) { if ($result === null) {
$this->fail('http', 'Request failed — could not connect'); $this->fail('http', 'Request failed — could not connect');
return; return;
} }
if ($result['http_code'] !== 200) { if ($result['http_code'] !== 200) {
$this->fail('http', "Expected HTTP 200, got {$result['http_code']}"); $this->fail('http', "Expected HTTP 200, got {$result['http_code']}");
return; return;
} }
if ($this->containsFatalError($result['body'])) { if ($this->containsFatalError($result['body'])) {
$this->fail('http', 'Response body contains PHP fatal error'); $this->fail('http', 'Response body contains PHP fatal error');
return; return;
} }
$this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)"); $this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)");
} }
private function checkAdmin(): void private function checkAdmin(): void
{ {
$adminUrl = $this->url . '/administrator/'; $adminUrl = $this->url . '/administrator/';
$this->log('[admin] GET ' . $adminUrl); $this->log('[admin] GET ' . $adminUrl);
$result = $this->curlGet($adminUrl); $result = $this->curlGet($adminUrl);
if ($result === null) { if ($result === null) {
$this->fail('admin', 'Request failed — could not connect'); $this->fail('admin', 'Request failed — could not connect');
return; return;
} }
if ($result['http_code'] !== 200) { if ($result['http_code'] !== 200) {
$this->fail('admin', "Expected HTTP 200, got {$result['http_code']}"); $this->fail('admin', "Expected HTTP 200, got {$result['http_code']}");
return; return;
} }
$this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)"); $this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)");
} }
private function checkApi(): void private function checkApi(): void
{ {
$apiUrl = $this->url . '/api/index.php/v1'; $apiUrl = $this->url . '/api/index.php/v1';
$this->log('[api] GET ' . $apiUrl); $this->log('[api] GET ' . $apiUrl);
$result = $this->curlGet($apiUrl); $result = $this->curlGet($apiUrl);
if ($result === null) { if ($result === null) {
$this->fail('api', 'Request failed — could not connect'); $this->fail('api', 'Request failed — could not connect');
return; return;
} }
if ($result['http_code'] !== 200 && $result['http_code'] !== 401) { if ($result['http_code'] !== 200 && $result['http_code'] !== 401) {
$this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}"); $this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}");
return; return;
} }
$this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)"); $this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)");
} }
private function curlGet(string $url): ?array private function curlGet(string $url): ?array
{ {
$ch = curl_init(); $ch = curl_init();
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_URL => $url, CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5, CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $this->timeout, CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => $this->timeout, CURLOPT_CONNECTTIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'MokoHealthCheck/1.0', CURLOPT_USERAGENT => 'MokoHealthCheck/1.0',
]); ]);
$body = curl_exec($ch); $body = curl_exec($ch);
if (curl_errno($ch)) { if (curl_errno($ch)) {
$error = curl_error($ch); $error = curl_error($ch);
$this->log(" cURL error: {$error}"); $this->log(" cURL error: {$error}");
curl_close($ch); curl_close($ch);
return null; return null;
} }
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME); $totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch); curl_close($ch);
return [ return [
'http_code' => $httpCode, 'http_code' => $httpCode,
'body' => is_string($body) ? $body : '', 'body' => is_string($body) ? $body : '',
'time_ms' => (int) round($totalTime * 1000), 'time_ms' => (int) round($totalTime * 1000),
]; ];
} }
private function containsFatalError(string $body): bool private function containsFatalError(string $body): bool
{ {
$patterns = [ $patterns = [
'Fatal error:', 'Fatal error:',
'Fatal Error', 'Fatal Error',
'Parse error:', 'Parse error:',
'Uncaught Error:', 'Uncaught Error:',
'Uncaught Exception:', 'Uncaught Exception:',
]; ];
foreach ($patterns as $pattern) { foreach ($patterns as $pattern) {
if (stripos($body, $pattern) !== false) { if (stripos($body, $pattern) !== false) {
return true; return true;
} }
} }
return false; return false;
} }
private function pass(string $check, string $message): void private function pass(string $check, string $message): void
{ {
$this->passed++; $this->passed++;
$this->log("[{$check}] PASS: {$message}"); $this->log("[{$check}] PASS: {$message}");
} }
private function fail(string $check, string $message): void private function fail(string $check, string $message): void
{ {
$this->failed++; $this->failed++;
$this->log("[{$check}] FAIL: {$message}"); $this->log("[{$check}] FAIL: {$message}");
} }
private function log(string $message): void private function log(string $message): void
{ {
$timestamp = date('Y-m-d H:i:s'); $timestamp = date('Y-m-d H:i:s');
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
} }
} }
$app = new HealthCheck(); $app = new HealthCheck();
exit($app->run()); exit($app->run());
+230 -230
View File
@@ -1,230 +1,230 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoStandards.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/rollback-joomla.php * PATH: /deploy/rollback-joomla.php
* VERSION: 01.00.00 * VERSION: 01.00.00
* BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot * BRIEF: Rollback a Joomla deployment by restoring from a pre-deploy snapshot
*/ */
declare(strict_types=1); declare(strict_types=1);
class RollbackJoomla class RollbackJoomla
{ {
private bool $verbose = false; private bool $verbose = false;
private bool $dryRun = false; private bool $dryRun = false;
private string $configPath = ''; private string $configPath = '';
private string $snapshotDir = ''; private string $snapshotDir = '';
private const JOOMLA_DIRS = [ private const JOOMLA_DIRS = [
'administrator/components', 'administrator/components',
'administrator/language', 'administrator/language',
'administrator/modules', 'administrator/modules',
'administrator/templates', 'administrator/templates',
'components', 'components',
'language', 'language',
'layouts', 'layouts',
'libraries', 'libraries',
'media', 'media',
'modules', 'modules',
'plugins', 'plugins',
'templates', 'templates',
]; ];
public function run(): int public function run(): int
{ {
$this->parseArgs(); $this->parseArgs();
if ($this->configPath === '' || $this->snapshotDir === '') { if ($this->configPath === '' || $this->snapshotDir === '') {
$this->log('Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]'); $this->log('Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]');
return 1; return 1;
} }
if (!is_dir($this->snapshotDir)) { if (!is_dir($this->snapshotDir)) {
$this->log("ERROR: Snapshot directory does not exist: {$this->snapshotDir}"); $this->log("ERROR: Snapshot directory does not exist: {$this->snapshotDir}");
return 1; return 1;
} }
$config = $this->loadConfig($this->configPath); $config = $this->loadConfig($this->configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
$host = $config['host'] ?? ''; $host = $config['host'] ?? '';
$user = $config['user'] ?? ''; $user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22); $port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/'); $remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR: Config must contain host, user, and remote_path.'); $this->log('ERROR: Config must contain host, user, and remote_path.');
return 1; return 1;
} }
$this->log('Starting Joomla rollback from snapshot...'); $this->log('Starting Joomla rollback from snapshot...');
$this->log("Snapshot: {$this->snapshotDir}"); $this->log("Snapshot: {$this->snapshotDir}");
$this->log("Target: {$user}@{$host}:{$remotePath}"); $this->log("Target: {$user}@{$host}:{$remotePath}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('*** DRY RUN — no changes will be made ***'); $this->log('*** DRY RUN — no changes will be made ***');
} }
$failed = 0; $failed = 0;
foreach (self::JOOMLA_DIRS as $dir) { foreach (self::JOOMLA_DIRS as $dir) {
$localDir = rtrim($this->snapshotDir, '/\\') . '/' . $dir . '/'; $localDir = rtrim($this->snapshotDir, '/\\') . '/' . $dir . '/';
if (!is_dir($localDir)) { if (!is_dir($localDir)) {
if ($this->verbose) { if ($this->verbose) {
$this->log("SKIP: {$dir} (not present in snapshot)"); $this->log("SKIP: {$dir} (not present in snapshot)");
} }
continue; continue;
} }
$remoteTarget = "{$remotePath}/{$dir}/"; $remoteTarget = "{$remotePath}/{$dir}/";
$sshCmd = "ssh -p {$port}"; $sshCmd = "ssh -p {$port}";
if ($sshKey !== '') { if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$rsyncArgs = [ $rsyncArgs = [
'rsync', 'rsync',
'-rlptz', '-rlptz',
'--delete', '--delete',
'--exclude=configuration.php', '--exclude=configuration.php',
'-e', $sshCmd, '-e', $sshCmd,
]; ];
if ($this->dryRun) { if ($this->dryRun) {
$rsyncArgs[] = '--dry-run'; $rsyncArgs[] = '--dry-run';
} }
if ($this->verbose) { if ($this->verbose) {
$rsyncArgs[] = '-v'; $rsyncArgs[] = '-v';
} }
$rsyncArgs[] = $localDir; $rsyncArgs[] = $localDir;
$rsyncArgs[] = "{$user}@{$host}:{$remoteTarget}"; $rsyncArgs[] = "{$user}@{$host}:{$remoteTarget}";
$cmd = implode(' ', array_map('escapeshellarg', $rsyncArgs)); $cmd = implode(' ', array_map('escapeshellarg', $rsyncArgs));
// rsync -e needs unescaped, rebuild manually // rsync -e needs unescaped, rebuild manually
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$this->log("Restoring: {$dir}"); $this->log("Restoring: {$dir}");
if ($this->verbose) { if ($this->verbose) {
$this->log("CMD: {$cmd}"); $this->log("CMD: {$cmd}");
} }
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})"); $this->log("ERROR: rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log(" {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log(" {$line}"); $this->log(" {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log("Rollback completed with {$failed} error(s)."); $this->log("Rollback completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log('Rollback completed successfully.'); $this->log('Rollback completed successfully.');
return 0; return 0;
} }
private function parseArgs(): void private function parseArgs(): void
{ {
$args = $_SERVER['argv'] ?? []; $args = $_SERVER['argv'] ?? [];
$count = count($args); $count = count($args);
for ($i = 1; $i < $count; $i++) { for ($i = 1; $i < $count; $i++) {
switch ($args[$i]) { switch ($args[$i]) {
case '--config': case '--config':
$this->configPath = $args[++$i] ?? ''; $this->configPath = $args[++$i] ?? '';
break; break;
case '--snapshot-dir': case '--snapshot-dir':
$this->snapshotDir = $args[++$i] ?? ''; $this->snapshotDir = $args[++$i] ?? '';
break; break;
case '--dry-run': case '--dry-run':
$this->dryRun = true; $this->dryRun = true;
break; break;
case '--verbose': case '--verbose':
$this->verbose = true; $this->verbose = true;
break; break;
} }
} }
} }
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log("ERROR: Config file not found: {$path}"); $this->log("ERROR: Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log("ERROR: Could not read config file: {$path}"); $this->log("ERROR: Could not read config file: {$path}");
return null; return null;
} }
// Strip // comments (sftp-config.json style) // Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw); $cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR: Invalid JSON in config file.'); $this->log('ERROR: Invalid JSON in config file.');
return null; return null;
} }
return $config; return $config;
} }
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
{ {
$parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php']; $parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php'];
if ($this->dryRun) { if ($this->dryRun) {
$parts[] = '--dry-run'; $parts[] = '--dry-run';
} }
if ($this->verbose) { if ($this->verbose) {
$parts[] = '-v'; $parts[] = '-v';
} }
$parts[] = '-e'; $parts[] = '-e';
$parts[] = escapeshellarg($sshCmd); $parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source); $parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest); $parts[] = escapeshellarg($dest);
return implode(' ', $parts); return implode(' ', $parts);
} }
private function log(string $message): void private function log(string $message): void
{ {
$timestamp = date('Y-m-d H:i:s'); $timestamp = date('Y-m-d H:i:s');
fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL); fwrite(STDERR, "[{$timestamp}] {$message}" . PHP_EOL);
} }
} }
$app = new RollbackJoomla(); $app = new RollbackJoomla();
exit($app->run()); exit($app->run());
+453 -453
View File
@@ -1,453 +1,453 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* FILE INFORMATION * FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Deploy * DEFGROUP: MokoStandards.Scripts.Deploy
* INGROUP: MokoStandards * INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /deploy/sync-joomla.php * PATH: /deploy/sync-joomla.php
* VERSION: 01.00.00 * VERSION: 01.00.00
* BRIEF: Sync Joomla site directories between two servers via rsync over SSH * BRIEF: Sync Joomla site directories between two servers via rsync over SSH
*/ */
declare(strict_types=1); declare(strict_types=1);
class SyncJoomla class SyncJoomla
{ {
/** @var string Path to source sftp-config.json */ /** @var string Path to source sftp-config.json */
private string $sourceConfig = ''; private string $sourceConfig = '';
/** @var string Path to dest sftp-config.json */ /** @var string Path to dest sftp-config.json */
private string $destConfig = ''; private string $destConfig = '';
/** @var bool Sync standard Joomla directories only */ /** @var bool Sync standard Joomla directories only */
private bool $rsyncMode = false; private bool $rsyncMode = false;
/** @var bool Sync everything under remote_path */ /** @var bool Sync everything under remote_path */
private bool $fullMode = false; private bool $fullMode = false;
/** @var bool Dry-run (preview only) */ /** @var bool Dry-run (preview only) */
private bool $dryRun = false; private bool $dryRun = false;
/** @var bool Verbose output */ /** @var bool Verbose output */
private bool $verbose = false; private bool $verbose = false;
/** @var string[] Additional exclude patterns */ /** @var string[] Additional exclude patterns */
private array $excludes = []; private array $excludes = [];
/** @var string Local relay directory */ /** @var string Local relay directory */
private string $relayDir = '/tmp/sync/'; private string $relayDir = '/tmp/sync/';
/** @var string[] Standard Joomla directories to sync */ /** @var string[] Standard Joomla directories to sync */
private array $joomlaDirs = [ private array $joomlaDirs = [
'administrator/components', 'administrator/components',
'administrator/language', 'administrator/language',
'administrator/modules', 'administrator/modules',
'administrator/templates', 'administrator/templates',
'components', 'components',
'language', 'language',
'layouts', 'layouts',
'libraries', 'libraries',
'media', 'media',
'modules', 'modules',
'plugins', 'plugins',
'templates', 'templates',
]; ];
/** /**
* Main entry point. * Main entry point.
* *
* @return int Exit code * @return int Exit code
*/ */
public function run(): int public function run(): int
{ {
$this->parseArgs(); $this->parseArgs();
if (!$this->validate()) { if (!$this->validate()) {
return 1; return 1;
} }
$source = $this->loadConfig($this->sourceConfig); $source = $this->loadConfig($this->sourceConfig);
$dest = $this->loadConfig($this->destConfig); $dest = $this->loadConfig($this->destConfig);
if ($source === null || $dest === null) { if ($source === null || $dest === null) {
return 1; return 1;
} }
$this->log("Source: {$source['user']}@{$source['host']}:{$source['remote_path']}"); $this->log("Source: {$source['user']}@{$source['host']}:{$source['remote_path']}");
$this->log("Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}"); $this->log("Dest: {$dest['user']}@{$dest['host']}:{$dest['remote_path']}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('[DRY-RUN] No files will be transferred.'); $this->log('[DRY-RUN] No files will be transferred.');
} }
$this->prepareRelayDir(); $this->prepareRelayDir();
$dirs = $this->resolveDirs(); $dirs = $this->resolveDirs();
$totalFiles = 0; $totalFiles = 0;
$syncedDirs = 0; $syncedDirs = 0;
foreach ($dirs as $dir) { foreach ($dirs as $dir) {
$this->log("--- Syncing: {$dir}"); $this->log("--- Syncing: {$dir}");
$pulled = $this->pullFromSource($source, $dir); $pulled = $this->pullFromSource($source, $dir);
if ($pulled === false) { if ($pulled === false) {
$this->log(" WARNING: pull failed for {$dir}, skipping."); $this->log(" WARNING: pull failed for {$dir}, skipping.");
continue; continue;
} }
$pushed = $this->pushToDest($dest, $dir); $pushed = $this->pushToDest($dest, $dir);
if ($pushed === false) { if ($pushed === false) {
$this->log(" WARNING: push failed for {$dir}, skipping."); $this->log(" WARNING: push failed for {$dir}, skipping.");
continue; continue;
} }
$totalFiles += $pulled + $pushed; $totalFiles += $pulled + $pushed;
$syncedDirs++; $syncedDirs++;
} }
$this->cleanup(); $this->cleanup();
$this->log(''); $this->log('');
$this->log('=== Sync Summary ==='); $this->log('=== Sync Summary ===');
$this->log("Directories synced: {$syncedDirs}/" . count($dirs)); $this->log("Directories synced: {$syncedDirs}/" . count($dirs));
$this->log("Rsync operations: " . ($syncedDirs * 2) . " (pull + push)"); $this->log("Rsync operations: " . ($syncedDirs * 2) . " (pull + push)");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('Mode: dry-run (no files were transferred)'); $this->log('Mode: dry-run (no files were transferred)');
} }
return 0; return 0;
} }
/** /**
* Parse command-line arguments. * Parse command-line arguments.
*/ */
private function parseArgs(): void private function parseArgs(): void
{ {
global $argv; global $argv;
$i = 1; $i = 1;
while ($i < count($argv)) { while ($i < count($argv)) {
switch ($argv[$i]) { switch ($argv[$i]) {
case '--source': case '--source':
$this->sourceConfig = $argv[++$i] ?? ''; $this->sourceConfig = $argv[++$i] ?? '';
break; break;
case '--dest': case '--dest':
$this->destConfig = $argv[++$i] ?? ''; $this->destConfig = $argv[++$i] ?? '';
break; break;
case '--rsync': case '--rsync':
$this->rsyncMode = true; $this->rsyncMode = true;
break; break;
case '--full': case '--full':
$this->fullMode = true; $this->fullMode = true;
break; break;
case '--dry-run': case '--dry-run':
$this->dryRun = true; $this->dryRun = true;
break; break;
case '--verbose': case '--verbose':
$this->verbose = true; $this->verbose = true;
break; break;
case '--exclude': case '--exclude':
$this->excludes[] = $argv[++$i] ?? ''; $this->excludes[] = $argv[++$i] ?? '';
break; break;
default: default:
$this->log("Unknown argument: {$argv[$i]}"); $this->log("Unknown argument: {$argv[$i]}");
break; break;
} }
$i++; $i++;
} }
} }
/** /**
* Validate required arguments. * Validate required arguments.
* *
* @return bool True if valid * @return bool True if valid
*/ */
private function validate(): bool private function validate(): bool
{ {
if ($this->sourceConfig === '' || $this->destConfig === '') { if ($this->sourceConfig === '' || $this->destConfig === '') {
$this->log('ERROR: --source and --dest are required.'); $this->log('ERROR: --source and --dest are required.');
$this->printUsage(); $this->printUsage();
return false; return false;
} }
if (!$this->rsyncMode && !$this->fullMode) { if (!$this->rsyncMode && !$this->fullMode) {
$this->log('ERROR: Either --rsync or --full must be specified.'); $this->log('ERROR: Either --rsync or --full must be specified.');
$this->printUsage(); $this->printUsage();
return false; return false;
} }
if ($this->rsyncMode && $this->fullMode) { if ($this->rsyncMode && $this->fullMode) {
$this->log('ERROR: --rsync and --full are mutually exclusive.'); $this->log('ERROR: --rsync and --full are mutually exclusive.');
return false; return false;
} }
if (!file_exists($this->sourceConfig)) { if (!file_exists($this->sourceConfig)) {
$this->log("ERROR: Source config not found: {$this->sourceConfig}"); $this->log("ERROR: Source config not found: {$this->sourceConfig}");
return false; return false;
} }
if (!file_exists($this->destConfig)) { if (!file_exists($this->destConfig)) {
$this->log("ERROR: Dest config not found: {$this->destConfig}"); $this->log("ERROR: Dest config not found: {$this->destConfig}");
return false; return false;
} }
return true; return true;
} }
/** /**
* Load and decode an sftp-config.json file. * Load and decode an sftp-config.json file.
* *
* @param string $path Path to the config file * @param string $path Path to the config file
* @return array|null Parsed config or null on error * @return array|null Parsed config or null on error
*/ */
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
$json = file_get_contents($path); $json = file_get_contents($path);
if ($json === false) { if ($json === false) {
$this->log("ERROR: Cannot read config: {$path}"); $this->log("ERROR: Cannot read config: {$path}");
return null; return null;
} }
// Strip // comments (Sublime Text SFTP format) // Strip // comments (Sublime Text SFTP format)
$json = preg_replace('#^\s*//.*$#m', '', $json); $json = preg_replace('#^\s*//.*$#m', '', $json);
$json = preg_replace('#,\s*([\]}])#', '$1', $json); $json = preg_replace('#,\s*([\]}])#', '$1', $json);
$config = json_decode($json, true); $config = json_decode($json, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log("ERROR: Invalid JSON in config: {$path}"); $this->log("ERROR: Invalid JSON in config: {$path}");
return null; return null;
} }
$required = ['host', 'user', 'remote_path', 'ssh_key_file']; $required = ['host', 'user', 'remote_path', 'ssh_key_file'];
foreach ($required as $key) { foreach ($required as $key) {
if (empty($config[$key])) { if (empty($config[$key])) {
$this->log("ERROR: Missing '{$key}' in config: {$path}"); $this->log("ERROR: Missing '{$key}' in config: {$path}");
return null; return null;
} }
} }
if (!isset($config['port'])) { if (!isset($config['port'])) {
$config['port'] = 22; $config['port'] = 22;
} }
return $config; return $config;
} }
/** /**
* Resolve the list of directories to sync. * Resolve the list of directories to sync.
* *
* @return string[] Directory paths (relative to remote_path) * @return string[] Directory paths (relative to remote_path)
*/ */
private function resolveDirs(): array private function resolveDirs(): array
{ {
if ($this->fullMode) { if ($this->fullMode) {
return ['.']; return ['.'];
} }
return $this->joomlaDirs; return $this->joomlaDirs;
} }
/** /**
* Prepare the local relay directory. * Prepare the local relay directory.
*/ */
private function prepareRelayDir(): void private function prepareRelayDir(): void
{ {
if (is_dir($this->relayDir)) { if (is_dir($this->relayDir)) {
shell_exec("rm -rf " . escapeshellarg($this->relayDir)); shell_exec("rm -rf " . escapeshellarg($this->relayDir));
} }
mkdir($this->relayDir, 0755, true); mkdir($this->relayDir, 0755, true);
$this->log("Relay directory: {$this->relayDir}"); $this->log("Relay directory: {$this->relayDir}");
} }
/** /**
* Build common rsync exclude flags. * Build common rsync exclude flags.
* *
* configuration.php is always excluded — it contains per-environment * configuration.php is always excluded — it contains per-environment
* database credentials and settings that must never be synced. * database credentials and settings that must never be synced.
* *
* @return string Exclude arguments for rsync * @return string Exclude arguments for rsync
*/ */
private function buildExcludes(): string private function buildExcludes(): string
{ {
$excludes = ['configuration.php']; $excludes = ['configuration.php'];
$excludes = array_merge($excludes, $this->excludes); $excludes = array_merge($excludes, $this->excludes);
$flags = ''; $flags = '';
foreach ($excludes as $pattern) { foreach ($excludes as $pattern) {
$flags .= ' --exclude=' . escapeshellarg($pattern); $flags .= ' --exclude=' . escapeshellarg($pattern);
} }
return $flags; return $flags;
} }
/** /**
* Build SSH command fragment for rsync. * Build SSH command fragment for rsync.
* *
* @param array $config Server config * @param array $config Server config
* @return string The -e flag value for rsync * @return string The -e flag value for rsync
*/ */
private function buildSshCmd(array $config): string private function buildSshCmd(array $config): string
{ {
$keyPath = escapeshellarg($config['ssh_key_file']); $keyPath = escapeshellarg($config['ssh_key_file']);
$port = (int) $config['port']; $port = (int) $config['port'];
return "ssh -i {$keyPath} -p {$port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"; return "ssh -i {$keyPath} -p {$port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
} }
/** /**
* Pull a directory from the source server to the local relay. * Pull a directory from the source server to the local relay.
* *
* @param array $config Source server config * @param array $config Source server config
* @param string $dir Relative directory to sync * @param string $dir Relative directory to sync
* @return int|false Number of files or false on failure * @return int|false Number of files or false on failure
*/ */
private function pullFromSource(array $config, string $dir): int|false private function pullFromSource(array $config, string $dir): int|false
{ {
$remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './');
$localPath = $this->relayDir . ltrim($dir, './'); $localPath = $this->relayDir . ltrim($dir, './');
if (!is_dir($localPath)) { if (!is_dir($localPath)) {
mkdir($localPath, 0755, true); mkdir($localPath, 0755, true);
} }
$sshCmd = $this->buildSshCmd($config); $sshCmd = $this->buildSshCmd($config);
$excludes = $this->buildExcludes(); $excludes = $this->buildExcludes();
$dryFlag = $this->dryRun ? ' --dry-run' : ''; $dryFlag = $this->dryRun ? ' --dry-run' : '';
$verboseFlag = $this->verbose ? ' -v' : ''; $verboseFlag = $this->verbose ? ' -v' : '';
$remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/");
$local = escapeshellarg("{$localPath}/"); $local = escapeshellarg("{$localPath}/");
$cmd = "rsync -az --delete" $cmd = "rsync -az --delete"
. $dryFlag . $dryFlag
. $verboseFlag . $verboseFlag
. $excludes . $excludes
. " -e " . escapeshellarg($sshCmd) . " -e " . escapeshellarg($sshCmd)
. " {$remote} {$local}" . " {$remote} {$local}"
. " 2>&1"; . " 2>&1";
$this->logVerbose(" PULL: {$cmd}"); $this->logVerbose(" PULL: {$cmd}");
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output));
return false; return false;
} }
$fileCount = count($output); $fileCount = count($output);
$this->logVerbose(" Pulled {$fileCount} line(s) of output."); $this->logVerbose(" Pulled {$fileCount} line(s) of output.");
return $fileCount; return $fileCount;
} }
/** /**
* Push a directory from the local relay to the destination server. * Push a directory from the local relay to the destination server.
* *
* @param array $config Dest server config * @param array $config Dest server config
* @param string $dir Relative directory to sync * @param string $dir Relative directory to sync
* @return int|false Number of files or false on failure * @return int|false Number of files or false on failure
*/ */
private function pushToDest(array $config, string $dir): int|false private function pushToDest(array $config, string $dir): int|false
{ {
$remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './'); $remotePath = rtrim($config['remote_path'], '/') . '/' . ltrim($dir, './');
$localPath = $this->relayDir . ltrim($dir, './'); $localPath = $this->relayDir . ltrim($dir, './');
$sshCmd = $this->buildSshCmd($config); $sshCmd = $this->buildSshCmd($config);
$excludes = $this->buildExcludes(); $excludes = $this->buildExcludes();
$dryFlag = $this->dryRun ? ' --dry-run' : ''; $dryFlag = $this->dryRun ? ' --dry-run' : '';
$verboseFlag = $this->verbose ? ' -v' : ''; $verboseFlag = $this->verbose ? ' -v' : '';
$local = escapeshellarg("{$localPath}/"); $local = escapeshellarg("{$localPath}/");
$remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/"); $remote = escapeshellarg("{$config['user']}@{$config['host']}:{$remotePath}/");
$cmd = "rsync -az --delete" $cmd = "rsync -az --delete"
. $dryFlag . $dryFlag
. $verboseFlag . $verboseFlag
. $excludes . $excludes
. " -e " . escapeshellarg($sshCmd) . " -e " . escapeshellarg($sshCmd)
. " {$local} {$remote}" . " {$local} {$remote}"
. " 2>&1"; . " 2>&1";
$this->logVerbose(" PUSH: {$cmd}"); $this->logVerbose(" PUSH: {$cmd}");
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output)); $this->log(" ERROR (exit {$exitCode}): " . implode("\n", $output));
return false; return false;
} }
$fileCount = count($output); $fileCount = count($output);
$this->logVerbose(" Pushed {$fileCount} line(s) of output."); $this->logVerbose(" Pushed {$fileCount} line(s) of output.");
return $fileCount; return $fileCount;
} }
/** /**
* Clean up the relay directory. * Clean up the relay directory.
*/ */
private function cleanup(): void private function cleanup(): void
{ {
if (is_dir($this->relayDir)) { if (is_dir($this->relayDir)) {
shell_exec("rm -rf " . escapeshellarg($this->relayDir)); shell_exec("rm -rf " . escapeshellarg($this->relayDir));
$this->logVerbose("Cleaned up relay directory."); $this->logVerbose("Cleaned up relay directory.");
} }
} }
/** /**
* Print usage information. * Print usage information.
*/ */
private function printUsage(): void private function printUsage(): void
{ {
$this->log(''); $this->log('');
$this->log('Usage: sync-joomla.php --source <config> --dest <config> [--rsync|--full] [options]'); $this->log('Usage: sync-joomla.php --source <config> --dest <config> [--rsync|--full] [options]');
$this->log(''); $this->log('');
$this->log('Required:'); $this->log('Required:');
$this->log(' --source <path> sftp-config.json for source server'); $this->log(' --source <path> sftp-config.json for source server');
$this->log(' --dest <path> sftp-config.json for dest server'); $this->log(' --dest <path> sftp-config.json for dest server');
$this->log(' --rsync Sync standard Joomla directories'); $this->log(' --rsync Sync standard Joomla directories');
$this->log(' --full Sync everything under the remote path'); $this->log(' --full Sync everything under the remote path');
$this->log(''); $this->log('');
$this->log('Options:'); $this->log('Options:');
$this->log(' --dry-run Preview only, no files transferred'); $this->log(' --dry-run Preview only, no files transferred');
$this->log(' --verbose Verbose output'); $this->log(' --verbose Verbose output');
$this->log(' --exclude <pattern> Additional exclude pattern (repeatable)'); $this->log(' --exclude <pattern> Additional exclude pattern (repeatable)');
} }
/** /**
* Log a message to stdout. * Log a message to stdout.
* *
* @param string $message Message to log * @param string $message Message to log
*/ */
private function log(string $message): void private function log(string $message): void
{ {
echo $message . PHP_EOL; echo $message . PHP_EOL;
} }
/** /**
* Log a verbose message (only when --verbose is set). * Log a verbose message (only when --verbose is set).
* *
* @param string $message Message to log * @param string $message Message to log
*/ */
private function logVerbose(string $message): void private function logVerbose(string $message): void
{ {
if ($this->verbose) { if ($this->verbose) {
$this->log($message); $this->log($message);
} }
} }
} }
$sync = new SyncJoomla(); $sync = new SyncJoomla();
exit($sync->run()); exit($sync->run());
+1 -1
View File
@@ -21,7 +21,7 @@ namespace MokoEnterprise;
* Git Platform Adapter Interface * Git Platform Adapter Interface
* *
* Defines all platform operations required by MokoStandards automation. * Defines all platform operations required by MokoStandards automation.
* Implementations exist for GitHub (GitHubAdapter) and Gitea (GiteaAdapter), * Implementations exist for GitHub (GitHubAdapter) and Gitea (MokoGiteaAdapter),
* allowing scripts to work against either platform transparently. * allowing scripts to work against either platform transparently.
* *
* @package MokoStandards\Enterprise * @package MokoStandards\Enterprise
@@ -9,7 +9,7 @@
* DEFGROUP: MokoStandards.Enterprise.Platform * DEFGROUP: MokoStandards.Enterprise.Platform
* INGROUP: MokoStandards.Enterprise * INGROUP: MokoStandards.Enterprise
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /lib/Enterprise/GiteaAdapter.php * PATH: /lib/Enterprise/MokoGiteaAdapter.php
* BRIEF: Gitea implementation of GitPlatformAdapter * BRIEF: Gitea implementation of GitPlatformAdapter
*/ */
@@ -35,7 +35,7 @@ use RuntimeException;
* @package MokoStandards\Enterprise * @package MokoStandards\Enterprise
* @version 04.06.10 * @version 04.06.10
*/ */
class GiteaAdapter implements GitPlatformAdapter class MokoGiteaAdapter implements GitPlatformAdapter
{ {
private ApiClient $apiClient; private ApiClient $apiClient;
private string $baseUrl; private string $baseUrl;
+7 -7
View File
@@ -51,7 +51,7 @@ class PlatformAdapterFactory
return match ($platform) { return match ($platform) {
'github' => self::createGitHubAdapter($config), 'github' => self::createGitHubAdapter($config),
'gitea' => self::createGiteaAdapter($config), 'gitea' => self::createMokoGiteaAdapter($config),
default => throw new RuntimeException("Unsupported git platform: {$platform}. Use 'github' or 'gitea'."), default => throw new RuntimeException("Unsupported git platform: {$platform}. Use 'github' or 'gitea'."),
}; };
} }
@@ -84,13 +84,13 @@ class PlatformAdapterFactory
} }
/** /**
* Create a GiteaAdapter with configured ApiClient. * Create a MokoGiteaAdapter with configured ApiClient.
* *
* @param Config $config Configuration instance * @param Config $config Configuration instance
* @return GiteaAdapter Configured Gitea adapter * @return MokoGiteaAdapter Configured Gitea adapter
* @throws RuntimeException If Gitea token is not available * @throws RuntimeException If Gitea token is not available
*/ */
private static function createGiteaAdapter(Config $config): GiteaAdapter private static function createMokoGiteaAdapter(Config $config): MokoGiteaAdapter
{ {
$token = $config->getString('gitea.token', ''); $token = $config->getString('gitea.token', '');
if (empty($token)) { if (empty($token)) {
@@ -110,21 +110,21 @@ class PlatformAdapterFactory
authScheme: 'token' authScheme: 'token'
); );
return new GiteaAdapter($apiClient, $apiBaseUrl); return new MokoGiteaAdapter($apiClient, $apiBaseUrl);
} }
/** /**
* Create adapters for both platforms (useful during migration). * Create adapters for both platforms (useful during migration).
* *
* @param Config $config Configuration instance * @param Config $config Configuration instance
* @return array{github: GitHubAdapter, gitea: GiteaAdapter} Both adapters * @return array{github: GitHubAdapter, gitea: MokoGiteaAdapter} Both adapters
* @throws RuntimeException If either token is missing * @throws RuntimeException If either token is missing
*/ */
public static function createBoth(Config $config): array public static function createBoth(Config $config): array
{ {
return [ return [
'github' => self::createGitHubAdapter($config), 'github' => self::createGitHubAdapter($config),
'gitea' => self::createGiteaAdapter($config), 'gitea' => self::createMokoGiteaAdapter($config),
]; ];
} }
+1 -1
View File
@@ -65,7 +65,7 @@ class RepositorySynchronizer
?GitPlatformAdapter $adapter = null ?GitPlatformAdapter $adapter = null
) { ) {
$this->apiClient = $apiClient; $this->apiClient = $apiClient;
$this->adapter = $adapter ?? new GiteaAdapter($apiClient); $this->adapter = $adapter ?? new MokoGiteaAdapter($apiClient);
$this->logger = $logger; $this->logger = $logger;
$this->metrics = $metrics; $this->metrics = $metrics;
$this->checkpoints = $checkpoints ?? new CheckpointManager('.checkpoints'); $this->checkpoints = $checkpoints ?? new CheckpointManager('.checkpoints');
+9 -9
View File
@@ -19,7 +19,7 @@ use MokoEnterprise\ApiClient;
use MokoEnterprise\Config; use MokoEnterprise\Config;
use MokoEnterprise\GitPlatformAdapter; use MokoEnterprise\GitPlatformAdapter;
use MokoEnterprise\GitHubAdapter; use MokoEnterprise\GitHubAdapter;
use MokoEnterprise\GiteaAdapter; use MokoEnterprise\MokoGiteaAdapter;
use MokoEnterprise\PlatformAdapterFactory; use MokoEnterprise\PlatformAdapterFactory;
echo "Testing GitPlatformAdapter Interface Compliance\n"; echo "Testing GitPlatformAdapter Interface Compliance\n";
@@ -58,8 +58,8 @@ assert_true($ghAdapter->getWorkflowDir() === '.github/workflows', 'getWorkflowDi
assert_true($ghAdapter->getApiClient() === $ghClient, 'getApiClient() returns injected client'); assert_true($ghAdapter->getApiClient() === $ghClient, 'getApiClient() returns injected client');
echo "\n"; echo "\n";
// ── Test 2: GiteaAdapter implements GitPlatformAdapter ────────────────── // ── Test 2: MokoGiteaAdapter implements GitPlatformAdapter ──────────────────
echo "2. Testing GiteaAdapter interface compliance...\n"; echo "2. Testing MokoGiteaAdapter interface compliance...\n";
$giteaClient = new ApiClient( $giteaClient = new ApiClient(
baseUrl: 'https://git.mokoconsulting.tech/api/v1', baseUrl: 'https://git.mokoconsulting.tech/api/v1',
@@ -67,9 +67,9 @@ $giteaClient = new ApiClient(
enableCaching: false, enableCaching: false,
authScheme: 'token' authScheme: 'token'
); );
$giteaAdapter = new GiteaAdapter($giteaClient); $giteaAdapter = new MokoGiteaAdapter($giteaClient);
assert_true($giteaAdapter instanceof GitPlatformAdapter, 'GiteaAdapter implements GitPlatformAdapter'); assert_true($giteaAdapter instanceof GitPlatformAdapter, 'MokoGiteaAdapter implements GitPlatformAdapter');
assert_true($giteaAdapter->getPlatformName() === 'gitea', 'getPlatformName() returns "gitea"'); assert_true($giteaAdapter->getPlatformName() === 'gitea', 'getPlatformName() returns "gitea"');
assert_true($giteaAdapter->getBaseUrl() === 'https://git.mokoconsulting.tech/api/v1', 'getBaseUrl() returns Gitea API URL'); assert_true($giteaAdapter->getBaseUrl() === 'https://git.mokoconsulting.tech/api/v1', 'getBaseUrl() returns Gitea API URL');
assert_true($giteaAdapter->getWorkflowDir() === '.mokogitea/workflows', 'getWorkflowDir() returns .gitea/workflows'); assert_true($giteaAdapter->getWorkflowDir() === '.mokogitea/workflows', 'getWorkflowDir() returns .gitea/workflows');
@@ -125,10 +125,10 @@ try {
$config->set('gitea.token', 'test-gitea-token'); $config->set('gitea.token', 'test-gitea-token');
try { try {
$adapter = PlatformAdapterFactory::create($config, 'gitea'); $adapter = PlatformAdapterFactory::create($config, 'gitea');
assert_true($adapter instanceof GiteaAdapter, 'Factory creates GiteaAdapter for platform=gitea'); assert_true($adapter instanceof MokoGiteaAdapter, 'Factory creates MokoGiteaAdapter for platform=gitea');
assert_true($adapter->getPlatformName() === 'gitea', 'Created adapter identifies as gitea'); assert_true($adapter->getPlatformName() === 'gitea', 'Created adapter identifies as gitea');
} catch (\Exception $e) { } catch (\Exception $e) {
assert_true(false, 'Factory creates GiteaAdapter: ' . $e->getMessage()); assert_true(false, 'Factory creates MokoGiteaAdapter: ' . $e->getMessage());
} }
// Test invalid platform // Test invalid platform
@@ -185,9 +185,9 @@ try {
assert_true(true, 'GitHubAdapter.migrateRepository() throws RuntimeException'); assert_true(true, 'GitHubAdapter.migrateRepository() throws RuntimeException');
} }
// GiteaAdapter.migrateRepository() should NOT throw (it calls the API) // MokoGiteaAdapter.migrateRepository() should NOT throw (it calls the API)
// We can't test it without a real server, but verify the method exists // We can't test it without a real server, but verify the method exists
assert_true(method_exists($giteaAdapter, 'migrateRepository'), 'GiteaAdapter.migrateRepository() exists'); assert_true(method_exists($giteaAdapter, 'migrateRepository'), 'MokoGiteaAdapter.migrateRepository() exists');
echo "\n"; echo "\n";
// ── Summary ───────────────────────────────────────────────────────────── // ── Summary ─────────────────────────────────────────────────────────────
File diff suppressed because it is too large Load Diff