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