refactor: rename GiteaAdapter to MokoGiteaAdapter (#30)
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 2s
This commit was merged in pull request #30.
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
+585
-585
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user