diff --git a/automation/migrate_to_gitea.php b/automation/migrate_to_gitea.php index 9939a3f..2ef2bca 100644 --- a/automation/migrate_to_gitea.php +++ b/automation/migrate_to_gitea.php @@ -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 diff --git a/cli/bulk_workflow_trigger.php b/cli/bulk_workflow_trigger.php index 541137d..25b66be 100644 --- a/cli/bulk_workflow_trigger.php +++ b/cli/bulk_workflow_trigger.php @@ -1,319 +1,319 @@ -#!/usr/bin/env php - - * - * 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 or --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 --workflow [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --token Gitea API token'); - $this->log(' --repos File with newline-separated owner/repo list'); - $this->log(' --org Trigger on all repos in an org'); - $this->log(' --workflow Workflow file (e.g., "sync-servers.yml")'); - $this->log(' --ref Branch ref (default: "main")'); - $this->log(' --inputs 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 + + * + * 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 or --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 --workflow [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); + $this->log(' --token Gitea API token'); + $this->log(' --repos File with newline-separated owner/repo list'); + $this->log(' --org Trigger on all repos in an org'); + $this->log(' --workflow Workflow file (e.g., "sync-servers.yml")'); + $this->log(' --ref Branch ref (default: "main")'); + $this->log(' --inputs 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()); diff --git a/cli/client_inventory.php b/cli/client_inventory.php index 28d0bc0..0654464 100644 --- a/cli/client_inventory.php +++ b/cli/client_inventory.php @@ -1,334 +1,334 @@ -#!/usr/bin/env php - - * - * 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 [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --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 + + * + * 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 [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); + $this->log(' --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()); diff --git a/cli/scaffold_client.php b/cli/scaffold_client.php index adf16c7..56c4081 100644 --- a/cli/scaffold_client.php +++ b/cli/scaffold_client.php @@ -1,250 +1,250 @@ -#!/usr/bin/env php - - * - * 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 --org --token [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --name Client name (e.g., "clarksvillefurs")'); - $this->log(' --org Gitea organization (e.g., "ClarksvilleFurs")'); - $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); - $this->log(' --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 + + * + * 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 --org --token [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --name Client name (e.g., "clarksvillefurs")'); + $this->log(' --org Gitea organization (e.g., "ClarksvilleFurs")'); + $this->log(' --gitea-url Gitea URL (default: https://git.mokoconsulting.tech)'); + $this->log(' --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()); diff --git a/deploy/backup-before-deploy.php b/deploy/backup-before-deploy.php index 7139cdf..8d2bb94 100644 --- a/deploy/backup-before-deploy.php +++ b/deploy/backup-before-deploy.php @@ -1,212 +1,212 @@ -#!/usr/bin/env php - - * - * 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 [--output ] [--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 + + * + * 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 [--output ] [--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()); diff --git a/deploy/deploy-dolibarr.php b/deploy/deploy-dolibarr.php index 078b468..4e9aa61 100644 --- a/deploy/deploy-dolibarr.php +++ b/deploy/deploy-dolibarr.php @@ -1,301 +1,301 @@ -#!/usr/bin/env php - - * - * 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 --config [--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 + + * + * 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 --config [--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()); diff --git a/deploy/health-check.php b/deploy/health-check.php index d251c3d..d9db5b1 100644 --- a/deploy/health-check.php +++ b/deploy/health-check.php @@ -1,227 +1,227 @@ -#!/usr/bin/env php - - * - * 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 [--timeout ] [--checks ]'); - 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 + + * + * 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 [--timeout ] [--checks ]'); + 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()); diff --git a/deploy/rollback-joomla.php b/deploy/rollback-joomla.php index e8d404d..d225361 100644 --- a/deploy/rollback-joomla.php +++ b/deploy/rollback-joomla.php @@ -1,230 +1,230 @@ -#!/usr/bin/env php - - * - * 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 --snapshot-dir [--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 + + * + * 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 --snapshot-dir [--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()); diff --git a/deploy/sync-joomla.php b/deploy/sync-joomla.php index a3d64e2..e8627ad 100644 --- a/deploy/sync-joomla.php +++ b/deploy/sync-joomla.php @@ -1,453 +1,453 @@ -#!/usr/bin/env php - - * - * 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 --dest [--rsync|--full] [options]'); - $this->log(''); - $this->log('Required:'); - $this->log(' --source sftp-config.json for source server'); - $this->log(' --dest 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 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 + + * + * 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 --dest [--rsync|--full] [options]'); + $this->log(''); + $this->log('Required:'); + $this->log(' --source sftp-config.json for source server'); + $this->log(' --dest 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 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()); diff --git a/lib/Enterprise/GitPlatformAdapter.php b/lib/Enterprise/GitPlatformAdapter.php index 39cd340..b924c93 100644 --- a/lib/Enterprise/GitPlatformAdapter.php +++ b/lib/Enterprise/GitPlatformAdapter.php @@ -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 diff --git a/lib/Enterprise/GiteaAdapter.php b/lib/Enterprise/MokoGiteaAdapter.php similarity index 99% rename from lib/Enterprise/GiteaAdapter.php rename to lib/Enterprise/MokoGiteaAdapter.php index d7e6817..1d02fa6 100644 --- a/lib/Enterprise/GiteaAdapter.php +++ b/lib/Enterprise/MokoGiteaAdapter.php @@ -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; diff --git a/lib/Enterprise/PlatformAdapterFactory.php b/lib/Enterprise/PlatformAdapterFactory.php index 9e1d423..f98e2e0 100644 --- a/lib/Enterprise/PlatformAdapterFactory.php +++ b/lib/Enterprise/PlatformAdapterFactory.php @@ -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), ]; } diff --git a/lib/Enterprise/RepositorySynchronizer.php b/lib/Enterprise/RepositorySynchronizer.php index 0bc8320..0ceb103 100644 --- a/lib/Enterprise/RepositorySynchronizer.php +++ b/lib/Enterprise/RepositorySynchronizer.php @@ -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'); diff --git a/tests/Enterprise/GitPlatformAdapterTest.php b/tests/Enterprise/GitPlatformAdapterTest.php index 86fe6e0..f18ec34 100644 --- a/tests/Enterprise/GitPlatformAdapterTest.php +++ b/tests/Enterprise/GitPlatformAdapterTest.php @@ -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 ───────────────────────────────────────────────────────────── diff --git a/validate/check_file_integrity.php b/validate/check_file_integrity.php index 97edfde..d9b1336 100644 --- a/validate/check_file_integrity.php +++ b/validate/check_file_integrity.php @@ -1,585 +1,585 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoStandards.Scripts.Validate - * INGROUP: MokoStandards - * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform - * PATH: /validate/check_file_integrity.php - * VERSION: 01.00.00 - * BRIEF: Compare deployed files on a remote server against the local repository to detect drift - */ - -declare(strict_types=1); - -final class CheckFileIntegrity -{ - private string $configFile = ''; - private string $repoPath = ''; - private bool $verbose = false; - private bool $jsonOutput = false; - - /** @var array{host: string, port: int, user: string, identity: string} */ - private array $sftpConfig = []; - - public function run(): int - { - $this->parseArgs(); - - if ($this->configFile === '') - { - $this->log('ERROR: --config is required.'); - $this->printUsage(); - return 1; - } - - if ($this->repoPath === '') - { - $this->repoPath = getcwd() ?: '.'; - } - - $this->repoPath = rtrim($this->repoPath, '/\\'); - - // Load SFTP config - if (!$this->loadConfig()) - { - return 1; - } - - // Read manifest - $manifest = $this->findManifest(); - - if ($manifest === null) - { - $this->log('ERROR: No Joomla XML manifest found in repo.'); - return 1; - } - - $this->log("Manifest: {$manifest['file']}"); - $this->log("Extension type: {$manifest['type']}"); - $this->log("Extension name: {$manifest['name']}"); - - // Build deploy mappings - $mappings = $this->buildDeployMappings($manifest); - - if (count($mappings) === 0) - { - $this->log('ERROR: No deploy mappings could be determined from manifest.'); - return 1; - } - - if ($this->verbose) - { - $this->log(''); - $this->log('Deploy mappings:'); - - foreach ($mappings as $mapping) - { - $this->log(" Local: {$mapping['local']} -> Remote: {$mapping['remote']}"); - } - - $this->log(''); - } - - // Run rsync dry-run for each mapping - $totalFiles = 0; - $matchCount = 0; - $differCount = 0; - $serverOnly = []; - $repoOnly = []; - $differing = []; - - foreach ($mappings as $mapping) - { - $localPath = $mapping['local']; - $remotePath = $mapping['remote']; - - if (!is_dir($localPath)) - { - if ($this->verbose) - { - $this->log("SKIP: Local path does not exist: {$localPath}"); - } - - continue; - } - - $result = $this->rsyncDryRun($localPath, $remotePath); - - if ($result === null) - { - $this->log("WARNING: rsync failed for mapping {$localPath} -> {$remotePath}"); - continue; - } - - $totalFiles += $result['total']; - $matchCount += $result['match']; - $differCount += $result['differ']; - $serverOnly = array_merge($serverOnly, $result['server_only']); - $repoOnly = array_merge($repoOnly, $result['repo_only']); - $differing = array_merge($differing, $result['differing']); - } - - // Output results - $summary = [ - 'total_files' => $totalFiles, - 'match' => $matchCount, - 'differ' => $differCount, - 'server_only' => count($serverOnly), - 'repo_only' => count($repoOnly), - 'details' => [ - 'server_only_files' => $serverOnly, - 'repo_only_files' => $repoOnly, - 'differing_files' => $differing, - ], - ]; - - if ($this->jsonOutput) - { - fwrite(STDOUT, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); - } - else - { - $this->log(''); - $this->log('=== FILE INTEGRITY REPORT ==='); - $this->log(''); - $this->log(sprintf('Total files checked: %d', $totalFiles)); - $this->log(sprintf('Matching: %d', $matchCount)); - $this->log(sprintf('Differing: %d', $differCount)); - $this->log(sprintf('Server-only: %d', count($serverOnly))); - $this->log(sprintf('Repo-only: %d', count($repoOnly))); - - if ($this->verbose && count($differing) > 0) - { - $this->log(''); - $this->log('Differing files:'); - - foreach ($differing as $f) - { - $this->log(" [CHANGED] {$f}"); - } - } - - if ($this->verbose && count($serverOnly) > 0) - { - $this->log(''); - $this->log('Server-only files (not in repo):'); - - foreach ($serverOnly as $f) - { - $this->log(" [SERVER] {$f}"); - } - } - - if ($this->verbose && count($repoOnly) > 0) - { - $this->log(''); - $this->log('Repo-only files (not on server):'); - - foreach ($repoOnly as $f) - { - $this->log(" [REPO] {$f}"); - } - } - - $this->log(''); - } - - $hasDrift = $differCount > 0 || count($serverOnly) > 0 || count($repoOnly) > 0; - - if ($hasDrift) - { - $this->log('RESULT: Drift detected.'); - return 1; - } - - $this->log('RESULT: Clean. No drift detected.'); - - return 0; - } - - private function parseArgs(): void - { - $args = $_SERVER['argv'] ?? []; - $count = count($args); - - for ($i = 1; $i < $count; $i++) - { - switch ($args[$i]) - { - case '--config': - $this->configFile = $args[++$i] ?? ''; - break; - case '--repo-path': - $this->repoPath = $args[++$i] ?? ''; - break; - case '--verbose': - case '-v': - $this->verbose = true; - 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: check_file_integrity.php --config [options]'); - $this->log(''); - $this->log('Options:'); - $this->log(' --config SFTP config JSON (host, port, user, identity)'); - $this->log(' --repo-path Local repo path (default: current directory)'); - $this->log(' --verbose, -v Show detailed file-by-file output'); - $this->log(' --json Output results as JSON'); - $this->log(' --help, -h Show this help'); - } - - private function loadConfig(): bool - { - if (!file_exists($this->configFile)) - { - $this->log("ERROR: Config file not found: {$this->configFile}"); - return false; - } - - $content = file_get_contents($this->configFile); - $data = json_decode($content, true); - - if (!is_array($data)) - { - $this->log('ERROR: Config file is not valid JSON.'); - return false; - } - - $host = $data['host'] ?? $data['sftp_host'] ?? ''; - $port = (int) ($data['port'] ?? $data['sftp_port'] ?? 22); - $user = $data['user'] ?? $data['sftp_user'] ?? $data['username'] ?? ''; - $identity = $data['identity'] ?? $data['ssh_key_file'] ?? $data['key'] ?? ''; - - if ($host === '' || $user === '') - { - $this->log('ERROR: Config must contain at least "host" and "user".'); - return false; - } - - $this->sftpConfig = [ - 'host' => $host, - 'port' => $port, - 'user' => $user, - 'identity' => $identity, - ]; - - $this->log("Server: {$user}@{$host}:{$port}"); - - return true; - } - - private function findManifest(): ?array - { - $srcDir = $this->repoPath . '/src'; - $searchDirs = is_dir($srcDir) ? [$srcDir] : [$this->repoPath]; - - foreach ($searchDirs as $dir) - { - $files = glob($dir . '/*.xml'); - - if ($files === false) - { - continue; - } - - foreach ($files as $xmlFile) - { - $content = file_get_contents($xmlFile); - - if ($content === false) - { - continue; - } - - libxml_use_internal_errors(true); - $xml = simplexml_load_string($content); - libxml_clear_errors(); - - if ($xml === false) - { - continue; - } - - $rootName = $xml->getName(); - - if ($rootName !== 'extension') - { - continue; - } - - $type = (string) ($xml['type'] ?? ''); - $extName = (string) ($xml->name ?? basename($xmlFile, '.xml')); - $element = (string) ($xml->element ?? $extName); - - return [ - 'file' => $xmlFile, - 'type' => $type, - 'name' => $extName, - 'element' => $element, - 'xml' => $xml, - ]; - } - } - - return null; - } - - private function buildDeployMappings(array $manifest): array - { - $type = $manifest['type']; - $element = strtolower($manifest['element']); - $xml = $manifest['xml']; - $srcDir = $this->repoPath . '/src'; - - if (!is_dir($srcDir)) - { - $srcDir = $this->repoPath; - } - - $mappings = []; - - switch ($type) - { - case 'template': - $client = (string) ($xml['client'] ?? 'site'); - $basePath = $client === 'administrator' - ? '/administrator/templates/' . $element - : '/templates/' . $element; - - $mappings[] = [ - 'local' => $srcDir, - 'remote' => $basePath, - ]; - break; - - case 'component': - $mappings[] = [ - 'local' => $srcDir . '/admin', - 'remote' => '/administrator/components/' . $element, - ]; - $mappings[] = [ - 'local' => $srcDir . '/site', - 'remote' => '/components/' . $element, - ]; - - if (is_dir($srcDir . '/media')) - { - $mappings[] = [ - 'local' => $srcDir . '/media', - 'remote' => '/media/' . $element, - ]; - } - break; - - case 'plugin': - $group = (string) ($xml['group'] ?? 'system'); - $pluginName = str_replace('plg_' . $group . '_', '', $element); - $mappings[] = [ - 'local' => $srcDir, - 'remote' => '/plugins/' . $group . '/' . $pluginName, - ]; - break; - - case 'module': - $client = (string) ($xml['client'] ?? 'site'); - $basePath = $client === 'administrator' - ? '/administrator/modules/' . $element - : '/modules/' . $element; - - $mappings[] = [ - 'local' => $srcDir, - 'remote' => $basePath, - ]; - break; - - default: - // Generic fallback: src -> extension root - $mappings[] = [ - 'local' => $srcDir, - 'remote' => '/templates/' . $element, - ]; - break; - } - - return $mappings; - } - - /** - * @return array{total: int, match: int, differ: int, server_only: string[], repo_only: string[], differing: string[]}|null - */ - private function rsyncDryRun(string $localPath, string $remotePath): ?array - { - $localPath = rtrim($localPath, '/') . '/'; - $remotePath = rtrim($remotePath, '/') . '/'; - - $sshCmd = "ssh -p {$this->sftpConfig['port']}"; - - if ($this->sftpConfig['identity'] !== '') - { - $sshCmd .= ' -i ' . escapeshellarg($this->sftpConfig['identity']); - } - - $sshCmd .= ' -o StrictHostKeyChecking=no -o BatchMode=yes'; - - $remoteSpec = "{$this->sftpConfig['user']}@{$this->sftpConfig['host']}:{$remotePath}"; - - // Rsync from server to local (dry-run) to detect differences - $cmd = sprintf( - 'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1', - escapeshellarg($sshCmd), - escapeshellarg($remoteSpec), - escapeshellarg($localPath) - ); - - if ($this->verbose) - { - $this->log("Running: {$cmd}"); - } - - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); - - // Also run in reverse to find repo-only files - $cmdReverse = sprintf( - 'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1', - escapeshellarg($sshCmd), - escapeshellarg($localPath), - escapeshellarg($remoteSpec) - ); - - $outputReverse = []; - $exitCodeReverse = 0; - exec($cmdReverse, $outputReverse, $exitCodeReverse); - - // Parse itemize-changes output - $serverOnly = []; - $differing = []; - $repoOnly = []; - $totalTracked = 0; - - foreach ($output as $line) - { - $line = trim($line); - - // Itemize format: YXcstpoguax filename - if (strlen($line) < 12 || $line[0] === ' ') - { - continue; - } - - // Skip summary lines - if (preg_match('/^(sending|receiving|sent|total|$)/', $line)) - { - continue; - } - - if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches)) - { - continue; - } - - $flags = $matches[1]; - $filename = $matches[2]; - - // Skip directories - if ($flags[1] === 'd') - { - continue; - } - - $totalTracked++; - - $updateType = $flags[0]; - - if ($updateType === '<' || $updateType === '>') - { - // File exists on source but differs or is new - if ($flags[2] === '+') - { - // New file (only on server side for forward rsync) - $serverOnly[] = $filename; - } - else - { - $differing[] = $filename; - } - } - elseif ($updateType === 'c') - { - $differing[] = $filename; - } - } - - // Parse reverse output for repo-only files - foreach ($outputReverse as $line) - { - $line = trim($line); - - if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches)) - { - continue; - } - - $flags = $matches[1]; - $filename = $matches[2]; - - if ($flags[1] === 'd') - { - continue; - } - - if ($flags[2] === '+') - { - $repoOnly[] = $filename; - } - } - - // Deduplicate - $differing = array_unique($differing); - $serverOnly = array_unique($serverOnly); - $repoOnly = array_unique($repoOnly); - - $differCount = count($differing); - $serverOnlyCount = count($serverOnly); - $repoOnlyCount = count($repoOnly); - $matchCount = max(0, $totalTracked - $differCount - $serverOnlyCount); - - return [ - 'total' => $totalTracked, - 'match' => $matchCount, - 'differ' => $differCount, - 'server_only' => $serverOnly, - 'repo_only' => $repoOnly, - 'differing' => $differing, - ]; - } - - private function log(string $message): void - { - fwrite(STDERR, $message . PHP_EOL); - } -} - -$app = new CheckFileIntegrity(); -exit($app->run()); +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Validate + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /validate/check_file_integrity.php + * VERSION: 01.00.00 + * BRIEF: Compare deployed files on a remote server against the local repository to detect drift + */ + +declare(strict_types=1); + +final class CheckFileIntegrity +{ + private string $configFile = ''; + private string $repoPath = ''; + private bool $verbose = false; + private bool $jsonOutput = false; + + /** @var array{host: string, port: int, user: string, identity: string} */ + private array $sftpConfig = []; + + public function run(): int + { + $this->parseArgs(); + + if ($this->configFile === '') + { + $this->log('ERROR: --config is required.'); + $this->printUsage(); + return 1; + } + + if ($this->repoPath === '') + { + $this->repoPath = getcwd() ?: '.'; + } + + $this->repoPath = rtrim($this->repoPath, '/\\'); + + // Load SFTP config + if (!$this->loadConfig()) + { + return 1; + } + + // Read manifest + $manifest = $this->findManifest(); + + if ($manifest === null) + { + $this->log('ERROR: No Joomla XML manifest found in repo.'); + return 1; + } + + $this->log("Manifest: {$manifest['file']}"); + $this->log("Extension type: {$manifest['type']}"); + $this->log("Extension name: {$manifest['name']}"); + + // Build deploy mappings + $mappings = $this->buildDeployMappings($manifest); + + if (count($mappings) === 0) + { + $this->log('ERROR: No deploy mappings could be determined from manifest.'); + return 1; + } + + if ($this->verbose) + { + $this->log(''); + $this->log('Deploy mappings:'); + + foreach ($mappings as $mapping) + { + $this->log(" Local: {$mapping['local']} -> Remote: {$mapping['remote']}"); + } + + $this->log(''); + } + + // Run rsync dry-run for each mapping + $totalFiles = 0; + $matchCount = 0; + $differCount = 0; + $serverOnly = []; + $repoOnly = []; + $differing = []; + + foreach ($mappings as $mapping) + { + $localPath = $mapping['local']; + $remotePath = $mapping['remote']; + + if (!is_dir($localPath)) + { + if ($this->verbose) + { + $this->log("SKIP: Local path does not exist: {$localPath}"); + } + + continue; + } + + $result = $this->rsyncDryRun($localPath, $remotePath); + + if ($result === null) + { + $this->log("WARNING: rsync failed for mapping {$localPath} -> {$remotePath}"); + continue; + } + + $totalFiles += $result['total']; + $matchCount += $result['match']; + $differCount += $result['differ']; + $serverOnly = array_merge($serverOnly, $result['server_only']); + $repoOnly = array_merge($repoOnly, $result['repo_only']); + $differing = array_merge($differing, $result['differing']); + } + + // Output results + $summary = [ + 'total_files' => $totalFiles, + 'match' => $matchCount, + 'differ' => $differCount, + 'server_only' => count($serverOnly), + 'repo_only' => count($repoOnly), + 'details' => [ + 'server_only_files' => $serverOnly, + 'repo_only_files' => $repoOnly, + 'differing_files' => $differing, + ], + ]; + + if ($this->jsonOutput) + { + fwrite(STDOUT, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); + } + else + { + $this->log(''); + $this->log('=== FILE INTEGRITY REPORT ==='); + $this->log(''); + $this->log(sprintf('Total files checked: %d', $totalFiles)); + $this->log(sprintf('Matching: %d', $matchCount)); + $this->log(sprintf('Differing: %d', $differCount)); + $this->log(sprintf('Server-only: %d', count($serverOnly))); + $this->log(sprintf('Repo-only: %d', count($repoOnly))); + + if ($this->verbose && count($differing) > 0) + { + $this->log(''); + $this->log('Differing files:'); + + foreach ($differing as $f) + { + $this->log(" [CHANGED] {$f}"); + } + } + + if ($this->verbose && count($serverOnly) > 0) + { + $this->log(''); + $this->log('Server-only files (not in repo):'); + + foreach ($serverOnly as $f) + { + $this->log(" [SERVER] {$f}"); + } + } + + if ($this->verbose && count($repoOnly) > 0) + { + $this->log(''); + $this->log('Repo-only files (not on server):'); + + foreach ($repoOnly as $f) + { + $this->log(" [REPO] {$f}"); + } + } + + $this->log(''); + } + + $hasDrift = $differCount > 0 || count($serverOnly) > 0 || count($repoOnly) > 0; + + if ($hasDrift) + { + $this->log('RESULT: Drift detected.'); + return 1; + } + + $this->log('RESULT: Clean. No drift detected.'); + + return 0; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) + { + switch ($args[$i]) + { + case '--config': + $this->configFile = $args[++$i] ?? ''; + break; + case '--repo-path': + $this->repoPath = $args[++$i] ?? ''; + break; + case '--verbose': + case '-v': + $this->verbose = true; + 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: check_file_integrity.php --config [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --config SFTP config JSON (host, port, user, identity)'); + $this->log(' --repo-path Local repo path (default: current directory)'); + $this->log(' --verbose, -v Show detailed file-by-file output'); + $this->log(' --json Output results as JSON'); + $this->log(' --help, -h Show this help'); + } + + private function loadConfig(): bool + { + if (!file_exists($this->configFile)) + { + $this->log("ERROR: Config file not found: {$this->configFile}"); + return false; + } + + $content = file_get_contents($this->configFile); + $data = json_decode($content, true); + + if (!is_array($data)) + { + $this->log('ERROR: Config file is not valid JSON.'); + return false; + } + + $host = $data['host'] ?? $data['sftp_host'] ?? ''; + $port = (int) ($data['port'] ?? $data['sftp_port'] ?? 22); + $user = $data['user'] ?? $data['sftp_user'] ?? $data['username'] ?? ''; + $identity = $data['identity'] ?? $data['ssh_key_file'] ?? $data['key'] ?? ''; + + if ($host === '' || $user === '') + { + $this->log('ERROR: Config must contain at least "host" and "user".'); + return false; + } + + $this->sftpConfig = [ + 'host' => $host, + 'port' => $port, + 'user' => $user, + 'identity' => $identity, + ]; + + $this->log("Server: {$user}@{$host}:{$port}"); + + return true; + } + + private function findManifest(): ?array + { + $srcDir = $this->repoPath . '/src'; + $searchDirs = is_dir($srcDir) ? [$srcDir] : [$this->repoPath]; + + foreach ($searchDirs as $dir) + { + $files = glob($dir . '/*.xml'); + + if ($files === false) + { + continue; + } + + foreach ($files as $xmlFile) + { + $content = file_get_contents($xmlFile); + + if ($content === false) + { + continue; + } + + libxml_use_internal_errors(true); + $xml = simplexml_load_string($content); + libxml_clear_errors(); + + if ($xml === false) + { + continue; + } + + $rootName = $xml->getName(); + + if ($rootName !== 'extension') + { + continue; + } + + $type = (string) ($xml['type'] ?? ''); + $extName = (string) ($xml->name ?? basename($xmlFile, '.xml')); + $element = (string) ($xml->element ?? $extName); + + return [ + 'file' => $xmlFile, + 'type' => $type, + 'name' => $extName, + 'element' => $element, + 'xml' => $xml, + ]; + } + } + + return null; + } + + private function buildDeployMappings(array $manifest): array + { + $type = $manifest['type']; + $element = strtolower($manifest['element']); + $xml = $manifest['xml']; + $srcDir = $this->repoPath . '/src'; + + if (!is_dir($srcDir)) + { + $srcDir = $this->repoPath; + } + + $mappings = []; + + switch ($type) + { + case 'template': + $client = (string) ($xml['client'] ?? 'site'); + $basePath = $client === 'administrator' + ? '/administrator/templates/' . $element + : '/templates/' . $element; + + $mappings[] = [ + 'local' => $srcDir, + 'remote' => $basePath, + ]; + break; + + case 'component': + $mappings[] = [ + 'local' => $srcDir . '/admin', + 'remote' => '/administrator/components/' . $element, + ]; + $mappings[] = [ + 'local' => $srcDir . '/site', + 'remote' => '/components/' . $element, + ]; + + if (is_dir($srcDir . '/media')) + { + $mappings[] = [ + 'local' => $srcDir . '/media', + 'remote' => '/media/' . $element, + ]; + } + break; + + case 'plugin': + $group = (string) ($xml['group'] ?? 'system'); + $pluginName = str_replace('plg_' . $group . '_', '', $element); + $mappings[] = [ + 'local' => $srcDir, + 'remote' => '/plugins/' . $group . '/' . $pluginName, + ]; + break; + + case 'module': + $client = (string) ($xml['client'] ?? 'site'); + $basePath = $client === 'administrator' + ? '/administrator/modules/' . $element + : '/modules/' . $element; + + $mappings[] = [ + 'local' => $srcDir, + 'remote' => $basePath, + ]; + break; + + default: + // Generic fallback: src -> extension root + $mappings[] = [ + 'local' => $srcDir, + 'remote' => '/templates/' . $element, + ]; + break; + } + + return $mappings; + } + + /** + * @return array{total: int, match: int, differ: int, server_only: string[], repo_only: string[], differing: string[]}|null + */ + private function rsyncDryRun(string $localPath, string $remotePath): ?array + { + $localPath = rtrim($localPath, '/') . '/'; + $remotePath = rtrim($remotePath, '/') . '/'; + + $sshCmd = "ssh -p {$this->sftpConfig['port']}"; + + if ($this->sftpConfig['identity'] !== '') + { + $sshCmd .= ' -i ' . escapeshellarg($this->sftpConfig['identity']); + } + + $sshCmd .= ' -o StrictHostKeyChecking=no -o BatchMode=yes'; + + $remoteSpec = "{$this->sftpConfig['user']}@{$this->sftpConfig['host']}:{$remotePath}"; + + // Rsync from server to local (dry-run) to detect differences + $cmd = sprintf( + 'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1', + escapeshellarg($sshCmd), + escapeshellarg($remoteSpec), + escapeshellarg($localPath) + ); + + if ($this->verbose) + { + $this->log("Running: {$cmd}"); + } + + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + // Also run in reverse to find repo-only files + $cmdReverse = sprintf( + 'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1', + escapeshellarg($sshCmd), + escapeshellarg($localPath), + escapeshellarg($remoteSpec) + ); + + $outputReverse = []; + $exitCodeReverse = 0; + exec($cmdReverse, $outputReverse, $exitCodeReverse); + + // Parse itemize-changes output + $serverOnly = []; + $differing = []; + $repoOnly = []; + $totalTracked = 0; + + foreach ($output as $line) + { + $line = trim($line); + + // Itemize format: YXcstpoguax filename + if (strlen($line) < 12 || $line[0] === ' ') + { + continue; + } + + // Skip summary lines + if (preg_match('/^(sending|receiving|sent|total|$)/', $line)) + { + continue; + } + + if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches)) + { + continue; + } + + $flags = $matches[1]; + $filename = $matches[2]; + + // Skip directories + if ($flags[1] === 'd') + { + continue; + } + + $totalTracked++; + + $updateType = $flags[0]; + + if ($updateType === '<' || $updateType === '>') + { + // File exists on source but differs or is new + if ($flags[2] === '+') + { + // New file (only on server side for forward rsync) + $serverOnly[] = $filename; + } + else + { + $differing[] = $filename; + } + } + elseif ($updateType === 'c') + { + $differing[] = $filename; + } + } + + // Parse reverse output for repo-only files + foreach ($outputReverse as $line) + { + $line = trim($line); + + if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches)) + { + continue; + } + + $flags = $matches[1]; + $filename = $matches[2]; + + if ($flags[1] === 'd') + { + continue; + } + + if ($flags[2] === '+') + { + $repoOnly[] = $filename; + } + } + + // Deduplicate + $differing = array_unique($differing); + $serverOnly = array_unique($serverOnly); + $repoOnly = array_unique($repoOnly); + + $differCount = count($differing); + $serverOnlyCount = count($serverOnly); + $repoOnlyCount = count($repoOnly); + $matchCount = max(0, $totalTracked - $differCount - $serverOnlyCount); + + return [ + 'total' => $totalTracked, + 'match' => $matchCount, + 'differ' => $differCount, + 'server_only' => $serverOnly, + 'repo_only' => $repoOnly, + 'differing' => $differing, + ]; + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new CheckFileIntegrity(); +exit($app->run());