From 66e728b0782cfe0296bd1db0c53aebced3f56799 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 31 May 2026 13:36:05 -0500 Subject: [PATCH] style: fix PHPCS violations across migrated CLI scripts Auto-fixed 5006 tab-indent and line-ending errors via phpcbf, then manually broke 100 lines exceeding 150-char limit. All 74 files in cli/, automation/, maintenance/, deploy/ now pass PHPCS PSR-12 clean. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- automation/enrich_manifest_xml.php | 4 +- automation/enrich_mokostandards_xml.php | 14 +- cli/archive_repo.php | 9 +- cli/badge_update.php | 1 + cli/branch_rename.php | 1 + cli/bulk_workflow_trigger.php | 1 + cli/changelog_promote.php | 1 + cli/changelog_prune.php | 1 + cli/client_health_check.php | 1 + cli/client_inventory.php | 8 +- cli/completion.php | 1 + cli/create_project.php | 11 +- cli/create_repo.php | 248 +++- cli/deploy_joomla.php | 5 +- cli/dev_branch_reset.php | 1 + cli/joomla_build.php | 78 +- cli/joomla_compat_check.php | 1 + cli/joomla_release.php | 61 +- cli/license_manage.php | 7 +- cli/manifest_element.php | 230 +++- cli/manifest_read.php | 1 + cli/platform_detect.php | 1 + cli/release.php | 1 + cli/release_body_update.php | 1 + cli/release_cascade.php | 1 + cli/release_manage.php | 226 ++-- cli/release_notes.php | 1 + cli/release_package.php | 994 +++++++-------- cli/release_publish.php | 124 +- cli/release_validate.php | 268 +++- cli/release_verify.php | 17 +- cli/scaffold_client.php | 154 ++- cli/sync_rulesets.php | 1 + cli/theme_lint.php | 292 +++-- cli/updates_xml_sync.php | 32 +- cli/version_auto_bump.php | 44 +- cli/version_bump.php | 291 ++++- cli/version_bump_remote.php | 256 ++-- cli/version_check.php | 225 +++- cli/version_read.php | 1 + cli/version_set_platform.php | 1 + cli/wiki_sync.php | 9 +- deploy/backup-before-deploy.php | 273 ++-- deploy/deploy-dolibarr.php | 407 +++--- deploy/deploy-joomla.php | 879 ++++++------- deploy/deploy-sftp.php | 1335 ++++++++++---------- deploy/health-check.php | 301 ++--- deploy/rollback-joomla.php | 259 ++-- deploy/sync-joomla.php | 1 + maintenance/pin_action_shas.php | 13 +- maintenance/repo_inventory.php | 312 +++-- maintenance/rotate_secrets.php | 369 +++--- maintenance/setup_labels.php | 377 +++--- maintenance/sync_dolibarr_readmes.php | 474 +++---- maintenance/update_repo_inventory.php | 437 +++---- maintenance/update_sha_hashes.php | 1 + maintenance/update_version_from_readme.php | 785 ++++++------ 57 files changed, 5590 insertions(+), 4258 deletions(-) diff --git a/automation/enrich_manifest_xml.php b/automation/enrich_manifest_xml.php index 0c0bece..26c6afb 100644 --- a/automation/enrich_manifest_xml.php +++ b/automation/enrich_manifest_xml.php @@ -111,7 +111,9 @@ class EnrichManifestXmlCli extends CliFramework if (!isset($enrichment['build'])) { $enrichment['build'] = []; } - $enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform); + $enrichment['build']['language'] = $enrichment['build']['language'] + ?? $repo['language'] + ?? MokoStandardsParser::platformLanguage($platform); $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform); $enrichedXml = $this->enrichManifestXml($existingXml, $enrichment); diff --git a/automation/enrich_mokostandards_xml.php b/automation/enrich_mokostandards_xml.php index 4b3c346..43dc8a6 100644 --- a/automation/enrich_mokostandards_xml.php +++ b/automation/enrich_mokostandards_xml.php @@ -111,7 +111,9 @@ class EnrichMokostandardsXmlCli extends CliFramework if (!isset($enrichment['build'])) { $enrichment['build'] = []; } - $enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform); + $enrichment['build']['language'] = $enrichment['build']['language'] + ?? $repo['language'] + ?? MokoStandardsParser::platformLanguage($platform); $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform); $enrichedXml = $this->enrichManifestXml($existingXml, $enrichment); @@ -131,7 +133,15 @@ class EnrichMokostandardsXmlCli extends CliFramework $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); - [$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}"); + $commitMsg = "chore: enrich .mokostandards" + . " with build/deploy/scripts\n\n" + . "Auto-detected: {$details}"; + [$cr, $co] = $this->gitCmd( + $workDir, + 'commit', + '-m', + $commitMsg + ); if ($cr !== 0) { echo "SKIP (no diff)\n"; $stats['skipped']++; diff --git a/cli/archive_repo.php b/cli/archive_repo.php index 3ae2b81..8b262c6 100644 --- a/cli/archive_repo.php +++ b/cli/archive_repo.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -136,7 +137,13 @@ class ArchiveRepoCli extends CliFramework $org, 'moko-platform', "chore: archived repository {$repoName}", - "## Repository Archived\n\n**Repository:** `{$org}/{$repoName}`\n**Archived:** {$now}\n**Platform:** {$platformName}\n**Sync definition removed:** yes\n\n---\n*Auto-created by `archive_repo.php`*\n", + "## Repository Archived\n\n" + . "**Repository:** `{$org}/{$repoName}`\n" + . "**Archived:** {$now}\n" + . "**Platform:** {$platformName}\n" + . "**Sync definition removed:** yes\n\n" + . "---\n" + . "*Auto-created by `archive_repo.php`*\n", [ 'labels' => ['type: chore', 'automation', 'archived'], 'assignees' => ['jmiller'], diff --git a/cli/badge_update.php b/cli/badge_update.php index f45a538..d7d529b 100644 --- a/cli/badge_update.php +++ b/cli/badge_update.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/branch_rename.php b/cli/branch_rename.php index 74225dc..f882104 100644 --- a/cli/branch_rename.php +++ b/cli/branch_rename.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/bulk_workflow_trigger.php b/cli/bulk_workflow_trigger.php index 7e1069f..f5b5d0a 100644 --- a/cli/bulk_workflow_trigger.php +++ b/cli/bulk_workflow_trigger.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. diff --git a/cli/changelog_promote.php b/cli/changelog_promote.php index f0f069f..e5dc3db 100644 --- a/cli/changelog_promote.php +++ b/cli/changelog_promote.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/changelog_prune.php b/cli/changelog_prune.php index 56889a0..d6fe85d 100644 --- a/cli/changelog_prune.php +++ b/cli/changelog_prune.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/client_health_check.php b/cli/client_health_check.php index 3808511..ac7145d 100644 --- a/cli/client_health_check.php +++ b/cli/client_health_check.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/client_inventory.php b/cli/client_inventory.php index 936d10e..eeac840 100644 --- a/cli/client_inventory.php +++ b/cli/client_inventory.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -127,7 +128,12 @@ class ClientInventoryCli extends CliFramework $this->log('INFO', ''); $this->log('INFO', sprintf( '%-20s | %-35s | %-10s | %-11s | %-19s | %s', - 'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status' + 'Org', + 'Repo', + 'Dev Config', + 'Live Config', + 'Last Push', + 'Status' )); $this->log('INFO', str_repeat('-', 120)); diff --git a/cli/completion.php b/cli/completion.php index 12f6c94..5f3ad4d 100644 --- a/cli/completion.php +++ b/cli/completion.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/create_project.php b/cli/create_project.php index 46feac8..e57363f 100644 --- a/cli/create_project.php +++ b/cli/create_project.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -282,7 +283,10 @@ class CreateProjectCli extends CliFramework $result['name'] = $m[1]; } - if (preg_match_all('/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"\s*description\s*=\s*"([^"]+)"(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s', $content, $matches, PREG_SET_ORDER)) { + $fieldPattern = '/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"' + . '\s*description\s*=\s*"([^"]+)"' + . '(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s'; + if (preg_match_all($fieldPattern, $content, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $field = [ 'name' => $match[1], @@ -376,7 +380,10 @@ class CreateProjectCli extends CliFramework $vars['singleSelectOptions'] = $optionInputs; $this->graphql( - 'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) { + 'mutation($projectId: ID!, $name: String!,' + . ' $dataType: ProjectV2CustomFieldType!,' + . ' $singleSelectOptions:' + . ' [ProjectV2SingleSelectFieldOptionInput!]) { createProjectV2Field(input: { projectId: $projectId, dataType: $dataType, diff --git a/cli/create_repo.php b/cli/create_repo.php index 0df151a..68d332c 100644 --- a/cli/create_repo.php +++ b/cli/create_repo.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -25,76 +26,201 @@ use MokoEnterprise\PlatformAdapterFactory; class CreateRepoCli extends CliFramework { - protected function configure(): void - { - $this->setDescription('Scaffold a new governed repository with full moko-platform baseline'); - $this->addArgument('--name', 'Repository name', null); - $this->addArgument('--type', 'Project type', null); - $this->addArgument('--description', 'Repository description', ''); - $this->addArgument('--private', 'Create as private', false); - } + protected function configure(): void + { + $this->setDescription('Scaffold a new governed repository with full moko-platform baseline'); + $this->addArgument('--name', 'Repository name', null); + $this->addArgument('--type', 'Project type', null); + $this->addArgument('--description', 'Repository description', ''); + $this->addArgument('--private', 'Create as private', false); + } - protected function run(): int - { - $name = $this->getArgument('--name'); $type = $this->getArgument('--type'); - $description = $this->getArgument('--description'); $private = (bool) $this->getArgument('--private'); - if (!$name || !$type) { $this->log('ERROR', "Usage: php create_repo.php --name --type [--description \"...\"] [--private] [--dry-run]"); return 2; } - $config = Config::load(); $adapter = PlatformAdapterFactory::create($config); - $org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); - $repoRoot = dirname(__DIR__, 2); - $TYPE_TO_PLATFORM = ['dolibarr' => 'crm-module', 'dolibarr-platform' => 'crm-platform', 'joomla' => 'waas-component', 'nodejs' => 'nodejs', 'terraform' => 'terraform', 'python' => 'python', 'wordpress' => 'wordpress', 'generic' => 'generic']; - $TYPE_TO_TOPICS = ['dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'], 'joomla' => ['joomla', 'cms', 'php', 'mokostandards'], 'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'], 'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'], 'python' => ['python', 'mokostandards'], 'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'], 'generic' => ['mokostandards']]; - $platform = $TYPE_TO_PLATFORM[$type] ?? 'generic'; $topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards']; - $platformName = $adapter->getPlatformName(); - echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n Type: {$type} (platform: {$platform})\n Visibility: " . ($private ? 'private' : 'public') . "\n"; - if ($description) { echo " Description: {$description}\n"; } echo "\n"; + protected function run(): int + { + $name = $this->getArgument('--name'); + $type = $this->getArgument('--type'); + $description = $this->getArgument('--description'); + $private = (bool) $this->getArgument('--private'); + if (!$name || !$type) { + $this->log('ERROR', "Usage: php create_repo.php --name --type [--description \"...\"] [--private] [--dry-run]"); + return 2; + } + $config = Config::load(); + $adapter = PlatformAdapterFactory::create($config); + $org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); + $repoRoot = dirname(__DIR__, 2); + $TYPE_TO_PLATFORM = [ + 'dolibarr' => 'crm-module', + 'dolibarr-platform' => 'crm-platform', + 'joomla' => 'waas-component', + 'nodejs' => 'nodejs', + 'terraform' => 'terraform', + 'python' => 'python', + 'wordpress' => 'wordpress', + 'generic' => 'generic', + ]; + $TYPE_TO_TOPICS = [ + 'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'], + 'joomla' => ['joomla', 'cms', 'php', 'mokostandards'], + 'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'], + 'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'], + 'python' => ['python', 'mokostandards'], + 'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'], + 'generic' => ['mokostandards'], + ]; + $platform = $TYPE_TO_PLATFORM[$type] ?? 'generic'; + $topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards']; + $platformName = $adapter->getPlatformName(); + $vis = $private ? 'private' : 'public'; + echo "Scaffolding new repository: {$org}/{$name}" + . " (on {$platformName})\n" + . " Type: {$type} (platform: {$platform})\n" + . " Visibility: {$vis}\n"; + if ($description) { + echo " Description: {$description}\n"; + } echo "\n"; - echo "Step 1: Creating repository...\n"; - if (!$this->dryRun) { - try { - $data = $adapter->createOrgRepo($org, $name, ['description' => $description ?: "Managed by moko-platform ({$type})", 'private' => $private, 'has_issues' => true, 'has_projects' => true, 'has_wiki' => false, 'auto_init' => true, 'delete_branch_on_merge' => true, 'allow_squash_merge' => true, 'allow_merge_commit' => false, 'allow_rebase_merge' => false]); - echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n"; - } catch (\Exception $e) { - if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { echo " Repository already exists -- continuing with setup\n"; } - else { $this->log('ERROR', "Failed to create repo: " . $e->getMessage()); return 1; } - } - } else { echo " (dry-run) would create {$org}/{$name}\n"; } + echo "Step 1: Creating repository...\n"; + if (!$this->dryRun) { + try { + $data = $adapter->createOrgRepo($org, $name, [ + 'description' => $description ?: "Managed by moko-platform ({$type})", + 'private' => $private, + 'has_issues' => true, + 'has_projects' => true, + 'has_wiki' => false, + 'auto_init' => true, + 'delete_branch_on_merge' => true, + 'allow_squash_merge' => true, + 'allow_merge_commit' => false, + 'allow_rebase_merge' => false, + ]); + echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n"; + } catch (\Exception $e) { + if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { + echo " Repository already exists -- continuing with setup\n"; + } else { + $this->log('ERROR', "Failed to create repo: " . $e->getMessage()); + return 1; + } + } + } else { + echo " (dry-run) would create {$org}/{$name}\n"; + } - echo "Step 2: Setting topics...\n"; - if (!$this->dryRun) { $adapter->setRepoTopics($org, $name, $topics); echo " Topics: " . implode(', ', $topics) . "\n"; } - else { echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n"; } + echo "Step 2: Setting topics...\n"; + if (!$this->dryRun) { + $adapter->setRepoTopics($org, $name, $topics); + echo " Topics: " . implode(', ', $topics) . "\n"; + } else { + echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n"; + } - echo "Step 3: Creating .github/.mokostandards...\n"; - $mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n"; - if (!$this->dryRun) { try { $adapter->createOrUpdateFile($org, $name, '.github/.mokostandards', $mokoContent, 'chore: add .mokostandards platform config [skip ci]'); echo " .mokostandards created\n"; } catch (\Exception $e) { echo " Warning: " . $e->getMessage() . "\n"; } } - else { echo " (dry-run) would create .github/.mokostandards\n"; } + echo "Step 3: Creating .github/.mokostandards...\n"; + $mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n"; + if (!$this->dryRun) { + try { + $adapter->createOrUpdateFile( + $org, + $name, + '.github/.mokostandards', + $mokoContent, + 'chore: add .mokostandards platform config [skip ci]' + ); + echo " .mokostandards created\n"; + } catch (\Exception $e) { + echo " Warning: " . $e->getMessage() . "\n"; + } + } else { + echo " (dry-run) would create .github/.mokostandards\n"; + } - echo "Step 4: Creating README.md...\n"; - $baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com'; - $repoUrl = "{$baseUrl}/{$org}/{$name}"; $standardsUrl = "{$baseUrl}/{$org}/MokoStandards"; - $readmeContent = "\n\n# {$name}\n\n{$description}\n\n## Getting Started\n\nThis repository is governed by [moko-platform]({$standardsUrl}).\n\n## License\n\nGPL-3.0-or-later. See [LICENSE](LICENSE) for details.\n"; - if (!$this->dryRun) { - $sha = null; - try { $existing = $adapter->getFileContents($org, $name, 'README.md'); $sha = $existing['sha'] ?? null; } catch (\Exception $e) { $adapter->getApiClient()->resetCircuitBreaker(); } - $adapter->createOrUpdateFile($org, $name, 'README.md', $readmeContent, 'docs: initialize README with moko-platform header [skip ci]', $sha); - echo " README.md created\n"; - } else { echo " (dry-run) would create README.md\n"; } + echo "Step 4: Creating README.md...\n"; + $baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com'; + $repoUrl = "{$baseUrl}/{$org}/{$name}"; + $standardsUrl = "{$baseUrl}/{$org}/MokoStandards"; + $readmeContent = "\n\n" + . "# {$name}\n\n" + . "{$description}\n\n" + . "## Getting Started\n\n" + . "This repository is governed by" + . " [moko-platform]({$standardsUrl}).\n\n" + . "## License\n\n" + . "GPL-3.0-or-later. See [LICENSE](LICENSE)" + . " for details.\n"; + if (!$this->dryRun) { + $sha = null; + try { + $existing = $adapter->getFileContents($org, $name, 'README.md'); + $sha = $existing['sha'] ?? null; + } catch (\Exception $e) { + $adapter->getApiClient()->resetCircuitBreaker(); + } + $adapter->createOrUpdateFile( + $org, + $name, + 'README.md', + $readmeContent, + 'docs: initialize README with moko-platform header [skip ci]', + $sha + ); + echo " README.md created\n"; + } else { + echo " (dry-run) would create README.md\n"; + } - echo "Step 5: Provisioning labels...\n"; - if (!$this->dryRun) { $labelScript = "{$repoRoot}/api/maintenance/setup_labels.php"; if (file_exists($labelScript)) { $exitCode = 0; passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode); } else { echo " Labels will be provisioned on next sync\n"; } } - else { echo " (dry-run) would provision standard labels\n"; } + echo "Step 5: Provisioning labels...\n"; + if (!$this->dryRun) { + $labelScript = "{$repoRoot}/api/maintenance/setup_labels.php"; + if (file_exists($labelScript)) { + $exitCode = 0; + passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode); + } else { + echo " Labels will be provisioned on next sync\n"; + } + } else { + echo " (dry-run) would provision standard labels\n"; + } - echo "Step 6: Running initial sync...\n"; - if (!$this->dryRun) { $syncScript = "{$repoRoot}/api/automation/bulk_sync.php"; if (file_exists($syncScript)) { passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes"); } else { echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n"; } } - else { echo " (dry-run) would run initial sync\n"; } + echo "Step 6: Running initial sync...\n"; + if (!$this->dryRun) { + $syncScript = "{$repoRoot}/api/automation/bulk_sync.php"; + if (file_exists($syncScript)) { + passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes"); + } else { + echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n"; + } + } else { + echo " (dry-run) would run initial sync\n"; + } - echo "Step 7: Creating Project...\n"; - if (!$this->dryRun) { $projectScript = "{$repoRoot}/api/cli/create_project.php"; if (file_exists($projectScript)) { passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type)); } else { echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n"; } } - else { echo " (dry-run) would create Project\n"; } + echo "Step 7: Creating Project...\n"; + if (!$this->dryRun) { + $projectScript = "{$repoRoot}/api/cli/create_project.php"; + if (file_exists($projectScript)) { + passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type)); + } else { + echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n"; + } + } else { + echo " (dry-run) would create Project\n"; + } - echo "\n" . str_repeat('-', 50) . "\nRepository {$org}/{$name} scaffolded successfully\n URL: {$repoUrl}\n Platform: {$platform} ({$platformName})\n Next: verify the sync and merge any PRs\n"; - return 0; - } + echo "\n" . str_repeat('-', 50) . "\n" + . "Repository {$org}/{$name} scaffolded successfully\n" + . " URL: {$repoUrl}\n" + . " Platform: {$platform} ({$platformName})\n" + . " Next: verify the sync and merge any PRs\n"; + return 0; + } } $app = new CreateRepoCli(); diff --git a/cli/deploy_joomla.php b/cli/deploy_joomla.php index 3dcde46..c5c9cc4 100644 --- a/cli/deploy_joomla.php +++ b/cli/deploy_joomla.php @@ -233,7 +233,10 @@ class DeployJoomla extends CliFramework /** * Parse extension metadata from the Joomla XML manifest. * - * @return array{type:string, element:string, client:string, group:string, name:string, shortName:string, version:string, subExtensions:list}|null + * @return array{type:string, element:string, client:string, + * group:string, name:string, shortName:string, version:string, + * subExtensions:list}|null */ private function parseExtensionManifest(string $path): ?array { diff --git a/cli/dev_branch_reset.php b/cli/dev_branch_reset.php index bdcc309..92d5704 100644 --- a/cli/dev_branch_reset.php +++ b/cli/dev_branch_reset.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/joomla_build.php b/cli/joomla_build.php index d1b54c2..19d190c 100644 --- a/cli/joomla_build.php +++ b/cli/joomla_build.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -50,7 +51,10 @@ class JoomlaBuildCli extends CliFramework // ── Find source directory ────────────────────────────────────────────── $srcDir = null; foreach (['src', 'htdocs'] as $d) { - if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; } + if (is_dir("{$path}/{$d}")) { + $srcDir = "{$path}/{$d}"; + break; + } } if ($srcDir === null) { $this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}"); @@ -72,7 +76,9 @@ class JoomlaBuildCli extends CliFramework // Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS") if (preg_match('/^[A-Z_]+$/', $meta['name'])) { $resolved = $this->resolveLanguageKey($srcDir, $meta['name']); - if ($resolved !== null) { $meta['name'] = $resolved; } + if ($resolved !== null) { + $meta['name'] = $resolved; + } } $prefix = $this->typePrefix($meta); @@ -87,7 +93,9 @@ class JoomlaBuildCli extends CliFramework $this->log('INFO', " Output: {$zipName}"); // ── Build ────────────────────────────────────────────────────────────── - if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); } + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } if ($meta['type'] === 'package') { $this->buildPackageZip($srcDir, $zipPath); @@ -114,11 +122,15 @@ class JoomlaBuildCli extends CliFramework if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') { $fh = fopen($ghFile, 'a'); - foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); } + foreach ($vars as $k => $v) { + fwrite($fh, "{$k}={$v}\n"); + } fclose($fh); $this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT"); } else { - foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; } + foreach ($vars as $k => $v) { + echo "{$k}={$v}\n"; + } } return 0; @@ -131,9 +143,13 @@ class JoomlaBuildCli extends CliFramework private function findManifest(string $dir): ?string { // Priority: pkg_*.xml (packages), then any *.xml with - foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; } + foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { + return $f; + } foreach (glob("{$dir}/*.xml") ?: [] as $f) { - if (str_contains((string) file_get_contents($f), 'attributes()->plugin ?? ''); } - if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); } + if ($element === '') { + $element = (string) ($xml->attributes()->plugin ?? ''); + } + if ($element === '') { + $element = (string) ($xml->attributes()->module ?? ''); + } if ($element === '') { $element = strtolower(basename($file, '.xml')); if (in_array($element, ['templatedetails', 'manifest'], true)) { @@ -179,7 +199,9 @@ class JoomlaBuildCli extends CliFramework // Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas) $element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element); - if ($name === '') { $name = $element; } + if ($name === '') { + $name = $element; + } return compact('name', 'type', 'element', 'group'); } @@ -216,10 +238,18 @@ class JoomlaBuildCli extends CliFramework private function isExcluded(string $name): bool { - if ($name === '.ftpignore') return true; - if (str_starts_with($name, 'sftp-config')) return true; - if (str_starts_with($name, '.env')) return true; - if (str_starts_with($name, '.build-trigger')) return true; + if ($name === '.ftpignore') { + return true; + } + if (str_starts_with($name, 'sftp-config')) { + return true; + } + if (str_starts_with($name, '.env')) { + return true; + } + if (str_starts_with($name, '.build-trigger')) { + return true; + } $ext = pathinfo($name, PATHINFO_EXTENSION); return in_array($ext, ['ppk', 'pem', 'key', 'local'], true); } @@ -237,7 +267,9 @@ class JoomlaBuildCli extends CliFramework ); foreach ($iter as $file) { $local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1)); - if ($this->isExcluded(basename($local))) continue; + if ($this->isExcluded(basename($local))) { + continue; + } $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); } $zip->close(); @@ -268,8 +300,12 @@ class JoomlaBuildCli extends CliFramework } // 2. Copy package-level files (manifest, script, language) - foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); - foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); + foreach (glob("{$srcDir}/*.xml") ?: [] as $f) { + copy($f, "{$staging}/" . basename($f)); + } + foreach (glob("{$srcDir}/*.php") ?: [] as $f) { + copy($f, "{$staging}/" . basename($f)); + } foreach (['language', 'administrator'] as $d) { if (is_dir("{$srcDir}/{$d}")) { $this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}"); @@ -285,7 +321,9 @@ class JoomlaBuildCli extends CliFramework private function copyTree(string $src, string $dst): void { - if (!is_dir($dst)) mkdir($dst, 0755, true); + if (!is_dir($dst)) { + mkdir($dst, 0755, true); + } $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST @@ -298,7 +336,9 @@ class JoomlaBuildCli extends CliFramework private function rmTree(string $dir): void { - if (!is_dir($dir)) return; + if (!is_dir($dir)) { + return; + } $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST diff --git a/cli/joomla_compat_check.php b/cli/joomla_compat_check.php index dcf9e98..0c1a9c5 100644 --- a/cli/joomla_compat_check.php +++ b/cli/joomla_compat_check.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/joomla_release.php b/cli/joomla_release.php index b136937..150b66b 100644 --- a/cli/joomla_release.php +++ b/cli/joomla_release.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -55,17 +56,17 @@ class JoomlaRelease extends CliFramework 'stable' => '', ]; - private ApiClient $api; + private ApiClient $api; private \MokoEnterprise\GitPlatformAdapter $adapter; protected function configure(): void { $this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml'); - $this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', ''); - $this->addArgument('--path', 'Local repo path (alternative to --repo)', '.'); + $this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', ''); + $this->addArgument('--path', 'Local repo path (alternative to --repo)', '.'); $this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable'); - $this->addArgument('--dry-run', 'Preview without making changes', false); - $this->addArgument('--verbose', 'Show detailed output', false); + $this->addArgument('--dry-run', 'Preview without making changes', false); + $this->addArgument('--verbose', 'Show detailed output', false); } protected function run(): int @@ -86,7 +87,9 @@ class JoomlaRelease extends CliFramework if ($repo !== '') { $path = $this->cloneRepo($repo); - if ($path === null) { return 1; } + if ($path === null) { + return 1; + } } $path = rtrim($path, '/\\'); @@ -191,7 +194,9 @@ class JoomlaRelease extends CliFramework private function findManifest(string $path): ?string { foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) { - if (!is_dir($dir)) { continue; } + if (!is_dir($dir)) { + continue; + } foreach (glob("{$dir}/*.xml") as $file) { if (str_contains((string) file_get_contents($file), 'copyDir("{$srcDir}/{$d}", "{$staging}/{$d}"); @@ -321,7 +332,9 @@ class JoomlaRelease extends CliFramework */ private function copyDir(string $src, string $dst): void { - if (!is_dir($dst)) { mkdir($dst, 0755, true); } + if (!is_dir($dst)) { + mkdir($dst, 0755, true); + } $iter = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST @@ -342,7 +355,9 @@ class JoomlaRelease extends CliFramework ); foreach ($iter as $file) { $local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname())); - if ($this->isExcluded(basename($local))) { continue; } + if ($this->isExcluded(basename($local))) { + continue; + } $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local); } $zip->close(); @@ -359,17 +374,29 @@ class JoomlaRelease extends CliFramework private function isExcluded(string $name): bool { - if ($name === '.ftpignore') { return true; } - if (str_starts_with($name, 'sftp-config')) { return true; } - if (str_starts_with($name, '.env')) { return true; } + if ($name === '.ftpignore') { + return true; + } + if (str_starts_with($name, 'sftp-config')) { + return true; + } + if (str_starts_with($name, '.env')) { + return true; + } $ext = pathinfo($name, PATHINFO_EXTENSION); return in_array($ext, ['ppk', 'pem', 'key'], true); } // ── GitHub Release ─────────────────────────────────────────────── - private function ensureRelease(string $repo, string $tag, string $version, string $stability, string $extName = '', string $packageName = ''): void - { + private function ensureRelease( + string $repo, + string $tag, + string $version, + string $stability, + string $extName = '', + string $packageName = '' + ): void { $releaseName = $extName !== '' ? "{$extName} {$version} ({$packageName})" : (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})"); diff --git a/cli/license_manage.php b/cli/license_manage.php index 318999c..01fe260 100644 --- a/cli/license_manage.php +++ b/cli/license_manage.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -90,11 +91,13 @@ class LicenseManage extends CliFramework // Determine subcommand from argv global $argv; foreach ($argv as $arg) { - if (in_array($arg, [ + if ( + in_array($arg, [ 'list', 'create-package', 'update-package', 'delete-package', 'issue', 'revoke', 'activate', 'renew', 'validate', 'usage', 'master-key', 'keys', 'packages', - ], true)) { + ], true) + ) { $this->subcommand = $arg; break; } diff --git a/cli/manifest_element.php b/cli/manifest_element.php index 73d1ed1..a3c7e4a 100644 --- a/cli/manifest_element.php +++ b/cli/manifest_element.php @@ -21,73 +21,171 @@ use MokoEnterprise\CliFramework; class ManifestElementCli extends CliFramework { - protected function configure(): void - { - $this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest'); - $this->addArgument('--path', 'Repository root', '.'); - $this->addArgument('--version', 'Version string', null); - $this->addArgument('--stability', 'Stability level', 'stable'); - $this->addArgument('--repo', 'Repository name', ''); - $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); - } + protected function configure(): void + { + $this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--version', 'Version string', null); + $this->addArgument('--stability', 'Stability level', 'stable'); + $this->addArgument('--repo', 'Repository name', ''); + $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); + } - protected function run(): int - { - $path = $this->getArgument('--path'); $version = $this->getArgument('--version'); - $stability = $this->getArgument('--stability'); $repoName = $this->getArgument('--repo'); - $githubOutput = (bool) $this->getArgument('--github-output'); - $root = realpath($path) ?: $path; - $platform = 'generic'; - $manifestXml = "{$root}/.mokogitea/manifest.xml"; - if (file_exists($manifestXml)) { $content = file_get_contents($manifestXml); if (preg_match('/([^<]+)<\/platform>/', $content, $pm)) { $platform = trim($pm[1]); } } - $extManifest = null; - $manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []); - foreach ($manifestFiles as $file) { $c = file_get_contents($file); if (strpos($c, '([^<]+)<\/element>/', $xml, $em)) { $extElement = $em[1]; } - if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) { $extElement = $mm[1]; } - if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { $extElement = $pm2[1]; } - if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { $extElement = $pn[1]; } - if (empty($extElement)) { $extElement = strtolower(basename($extManifest, '.xml')); if (in_array($extElement, ['templatedetails', 'manifest'], true)) { $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); } } - if (preg_match('/([^<]+)<\/name>/', $xml, $nm)) { $extName = trim($nm[1]); } - break; - case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: - $extType = 'dolibarr-module'; $modBasename = basename($modFile, '.class.php'); - $extElement = strtolower(preg_replace('/^mod/', '', $modBasename)); - $modContent = file_get_contents($modFile); - if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) { $extName = $nm[1]; } - break; - default: - $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); $extType = 'generic'; break; - } - $extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); - $typePrefix = ''; - switch ($extType) { - case 'plugin': $typePrefix = "plg_{$extFolder}_"; break; case 'module': $typePrefix = 'mod_'; break; - case 'component': $typePrefix = 'com_'; break; case 'template': $typePrefix = 'tpl_'; break; - case 'library': $typePrefix = 'lib_'; break; case 'package': $typePrefix = 'pkg_'; break; - } - $suffixMap = ['development' => '-dev', 'dev' => '-dev', 'alpha' => '-alpha', 'beta' => '-beta', 'rc' => '-rc', 'release-candidate' => '-rc', 'stable' => '']; - $suffix = $suffixMap[$stability] ?? ''; $zipName = ''; - if ($version !== null) { $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip"; } - if (empty($extName)) { $extName = $repoName ?: basename($root); } - $outputs = ['platform' => $platform, 'ext_element' => $extElement, 'ext_type' => $extType, 'ext_folder' => $extFolder, 'ext_name' => $extName, 'type_prefix' => $typePrefix, 'zip_name' => $zipName]; - if ($githubOutput) { - $ghOutput = getenv('GITHUB_OUTPUT'); $lines = []; - foreach ($outputs as $key => $value) { $lines[] = "{$key}={$value}"; } - if ($ghOutput) { file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); } - else { foreach ($outputs as $key => $value) { echo "::set-output name={$key}::{$value}\n"; } } - } else { foreach ($outputs as $key => $value) { echo "{$key}={$value}\n"; } } - return 0; - } + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $stability = $this->getArgument('--stability'); + $repoName = $this->getArgument('--repo'); + $githubOutput = (bool) $this->getArgument('--github-output'); + $root = realpath($path) ?: $path; + $platform = 'generic'; + $manifestXml = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($manifestXml)) { + $content = file_get_contents($manifestXml); + if (preg_match('/([^<]+)<\/platform>/', $content, $pm)) { + $platform = trim($pm[1]); + } + } + $extManifest = null; + $manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []); + foreach ($manifestFiles as $file) { + $c = file_get_contents($file); + if (strpos($c, '([^<]+)<\/element>/', $xml, $em)) { + $extElement = $em[1]; + } + if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) { + $extElement = $mm[1]; + } + if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { + $extElement = $pm2[1]; + } + if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { + $extElement = $pn[1]; + } + if (empty($extElement)) { + $extElement = strtolower(basename($extManifest, '.xml')); + if (in_array($extElement, ['templatedetails', 'manifest'], true)) { + $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); + } + } + if (preg_match('/([^<]+)<\/name>/', $xml, $nm)) { + $extName = trim($nm[1]); + } + break; + case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: + $extType = 'dolibarr-module'; + $modBasename = basename($modFile, '.class.php'); + $extElement = strtolower(preg_replace('/^mod/', '', $modBasename)); + $modContent = file_get_contents($modFile); + if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) { + $extName = $nm[1]; + } + break; + default: + $extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); + $extType = 'generic'; + break; + } + $extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); + $typePrefix = ''; + switch ($extType) { + case 'plugin': + $typePrefix = "plg_{$extFolder}_"; + break; + case 'module': + $typePrefix = 'mod_'; + break; + case 'component': + $typePrefix = 'com_'; + break; + case 'template': + $typePrefix = 'tpl_'; + break; + case 'library': + $typePrefix = 'lib_'; + break; + case 'package': + $typePrefix = 'pkg_'; + break; + } + $suffixMap = [ + 'development' => '-dev', + 'dev' => '-dev', + 'alpha' => '-alpha', + 'beta' => '-beta', + 'rc' => '-rc', + 'release-candidate' => '-rc', + 'stable' => '', + ]; + $suffix = $suffixMap[$stability] ?? ''; + $zipName = ''; + if ($version !== null) { + $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip"; + } + if (empty($extName)) { + $extName = $repoName ?: basename($root); + } + $outputs = [ + 'platform' => $platform, + 'ext_element' => $extElement, + 'ext_type' => $extType, + 'ext_folder' => $extFolder, + 'ext_name' => $extName, + 'type_prefix' => $typePrefix, + 'zip_name' => $zipName, + ]; + if ($githubOutput) { + $ghOutput = getenv('GITHUB_OUTPUT'); + $lines = []; + foreach ($outputs as $key => $value) { + $lines[] = "{$key}={$value}"; + } + if ($ghOutput) { + file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); + } else { + foreach ($outputs as $key => $value) { + echo "::set-output name={$key}::{$value}\n"; + } + } + } else { + foreach ($outputs as $key => $value) { + echo "{$key}={$value}\n"; + } + } + return 0; + } } $app = new ManifestElementCli(); diff --git a/cli/manifest_read.php b/cli/manifest_read.php index 684e728..4a6797b 100644 --- a/cli/manifest_read.php +++ b/cli/manifest_read.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/platform_detect.php b/cli/platform_detect.php index b9d2185..83ee94d 100644 --- a/cli/platform_detect.php +++ b/cli/platform_detect.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/release.php b/cli/release.php index 50ad826..4ef6b7c 100644 --- a/cli/release.php +++ b/cli/release.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/release_body_update.php b/cli/release_body_update.php index a0ae03f..54c9b79 100644 --- a/cli/release_body_update.php +++ b/cli/release_body_update.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/release_cascade.php b/cli/release_cascade.php index 716b0e6..a1825ba 100644 --- a/cli/release_cascade.php +++ b/cli/release_cascade.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/release_manage.php b/cli/release_manage.php index 54ef54d..e665d3e 100644 --- a/cli/release_manage.php +++ b/cli/release_manage.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -20,89 +21,154 @@ use MokoEnterprise\CliFramework; class ReleaseManageCli extends CliFramework { - protected function configure(): void - { - $this->setDescription('Create/update Gitea releases, upload assets, update release body'); - $this->addArgument('--action', 'create | upload | update-body | delete', null); - $this->addArgument('--tag', 'Release tag name', null); - $this->addArgument('--name', 'Release name/title', null); - $this->addArgument('--body', 'Release body/description', null); - $this->addArgument('--body-file', 'Read body from file', null); - $this->addArgument('--target', 'Target branch/commitish', 'main'); - $this->addArgument('--files', 'Comma-separated file paths to upload', null); - $this->addArgument('--token', 'Gitea API token', null); - $this->addArgument('--api-base', 'Gitea API base URL', null); - } + protected function configure(): void + { + $this->setDescription('Create/update Gitea releases, upload assets, update release body'); + $this->addArgument('--action', 'create | upload | update-body | delete', null); + $this->addArgument('--tag', 'Release tag name', null); + $this->addArgument('--name', 'Release name/title', null); + $this->addArgument('--body', 'Release body/description', null); + $this->addArgument('--body-file', 'Read body from file', null); + $this->addArgument('--target', 'Target branch/commitish', 'main'); + $this->addArgument('--files', 'Comma-separated file paths to upload', null); + $this->addArgument('--token', 'Gitea API token', null); + $this->addArgument('--api-base', 'Gitea API base URL', null); + } - protected function run(): int - { - $action = $this->getArgument('--action'); $tag = $this->getArgument('--tag'); - $name = $this->getArgument('--name'); $body = $this->getArgument('--body'); - $bodyFile = $this->getArgument('--body-file'); $target = $this->getArgument('--target'); - $filesArg = $this->getArgument('--files'); $token = $this->getArgument('--token'); - $apiBase = $this->getArgument('--api-base'); - $files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : []; - if ($token === null) { $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; } - if ($bodyFile !== null && file_exists($bodyFile)) { $body = file_get_contents($bodyFile); } - if ($action === null || $tag === null || $token === null || $apiBase === null) { $this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL"); return 1; } - switch ($action) { - case 'create': - $existing = $this->getReleaseByTag($apiBase, $tag, $token); - if ($existing !== null) { $existingId = $existing['id']; $this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token); $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); echo "Deleted previous release: {$tag} (id: {$existingId})\n"; } - $payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]); - $result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload); - if ($result['code'] >= 200 && $result['code'] < 300) { $releaseId = $result['data']['id'] ?? 'unknown'; echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n"; } - else { $this->log('ERROR', "Failed to create release: HTTP {$result['code']}"); return 1; } - break; - case 'upload': - if (empty($files)) { $this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2"); return 1; } - $release = $this->getReleaseByTag($apiBase, $tag, $token); - if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; } - $releaseId = $release['id']; - $assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token); - $existingAssets = $assetsResult['data'] ?? []; - foreach ($files as $filePath) { - $filePath = trim($filePath); if (!file_exists($filePath)) { $this->log('ERROR', "File not found: {$filePath}"); continue; } - $fileName = basename($filePath); - foreach ($existingAssets as $asset) { if (($asset['name'] ?? '') === $fileName) { $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token); echo "Deleted existing asset: {$fileName}\n"; break; } } - $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName); - $result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath); - if ($result['code'] >= 200 && $result['code'] < 300) { echo "Uploaded: {$fileName}\n"; } else { $this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}"); } - } - break; - case 'update-body': - $release = $this->getReleaseByTag($apiBase, $tag, $token); - if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; } - $payload = json_encode(['body' => $body ?? '']); - $result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload); - if ($result['code'] >= 200 && $result['code'] < 300) { echo "Release body updated for tag: {$tag}\n"; } else { $this->log('ERROR', "Failed to update body: HTTP {$result['code']}"); return 1; } - break; - case 'delete': - $existing = $this->getReleaseByTag($apiBase, $tag, $token); - if ($existing !== null) { $this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token); $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); echo "Deleted: {$tag} (id: {$existing['id']})\n"; } - else { echo "No release found for tag: {$tag}\n"; } - break; - default: $this->log('ERROR', "Unknown action: {$action}"); return 1; - } - return 0; - } + protected function run(): int + { + $action = $this->getArgument('--action'); + $tag = $this->getArgument('--tag'); + $name = $this->getArgument('--name'); + $body = $this->getArgument('--body'); + $bodyFile = $this->getArgument('--body-file'); + $target = $this->getArgument('--target'); + $filesArg = $this->getArgument('--files'); + $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : []; + if ($token === null) { + $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; + } + if ($bodyFile !== null && file_exists($bodyFile)) { + $body = file_get_contents($bodyFile); + } + if ($action === null || $tag === null || $token === null || $apiBase === null) { + $this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL"); + return 1; + } + switch ($action) { + case 'create': + $existing = $this->getReleaseByTag($apiBase, $tag, $token); + if ($existing !== null) { + $existingId = $existing['id']; + $this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token); + $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); + echo "Deleted previous release: {$tag} (id: {$existingId})\n"; + } + $payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]); + $result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload); + if ($result['code'] >= 200 && $result['code'] < 300) { + $releaseId = $result['data']['id'] ?? 'unknown'; + echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n"; + } else { + $this->log('ERROR', "Failed to create release: HTTP {$result['code']}"); + return 1; + } + break; + case 'upload': + if (empty($files)) { + $this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2"); + return 1; + } + $release = $this->getReleaseByTag($apiBase, $tag, $token); + if ($release === null) { + $this->log('ERROR', "No release found for tag: {$tag}"); + return 1; + } + $releaseId = $release['id']; + $assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token); + $existingAssets = $assetsResult['data'] ?? []; + foreach ($files as $filePath) { + $filePath = trim($filePath); + if (!file_exists($filePath)) { + $this->log('ERROR', "File not found: {$filePath}"); + continue; + } + $fileName = basename($filePath); + foreach ($existingAssets as $asset) { + if (($asset['name'] ?? '') === $fileName) { + $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token); + echo "Deleted existing asset: {$fileName}\n"; + break; + } + } + $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName); + $result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath); + if ($result['code'] >= 200 && $result['code'] < 300) { + echo "Uploaded: {$fileName}\n"; + } else { + $this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}"); + } + } + break; + case 'update-body': + $release = $this->getReleaseByTag($apiBase, $tag, $token); + if ($release === null) { + $this->log('ERROR', "No release found for tag: {$tag}"); + return 1; + } + $payload = json_encode(['body' => $body ?? '']); + $result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload); + if ($result['code'] >= 200 && $result['code'] < 300) { + echo "Release body updated for tag: {$tag}\n"; + } else { + $this->log('ERROR', "Failed to update body: HTTP {$result['code']}"); + return 1; + } + break; + case 'delete': + $existing = $this->getReleaseByTag($apiBase, $tag, $token); + if ($existing !== null) { + $this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token); + $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); + echo "Deleted: {$tag} (id: {$existing['id']})\n"; + } else { + echo "No release found for tag: {$tag}\n"; + } + break; + default: + $this->log('ERROR', "Unknown action: {$action}"); + return 1; + } + return 0; + } - private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array - { - $ch = curl_init($url); $headers = ["Authorization: token {$token}"]; - $opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method]; - if ($jsonBody !== null) { $headers[] = 'Content-Type: application/json'; $opts[CURLOPT_POSTFIELDS] = $jsonBody; } - elseif ($filePath !== null) { $headers[] = 'Content-Type: application/octet-stream'; $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); } - $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); - $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []]; - } + private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array + { + $ch = curl_init($url); + $headers = ["Authorization: token {$token}"]; + $opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method]; + if ($jsonBody !== null) { + $headers[] = 'Content-Type: application/json'; + $opts[CURLOPT_POSTFIELDS] = $jsonBody; + } elseif ($filePath !== null) { + $headers[] = 'Content-Type: application/octet-stream'; + $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); + } + $opts[CURLOPT_HTTPHEADER] = $headers; + curl_setopt_array($ch, $opts); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []]; + } - private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array - { - $result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token); - return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null; - } + private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array + { + $result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token); + return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null; + } } $app = new ReleaseManageCli(); diff --git a/cli/release_notes.php b/cli/release_notes.php index 6fb99d5..dd31520 100644 --- a/cli/release_notes.php +++ b/cli/release_notes.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/release_package.php b/cli/release_package.php index c449da9..be6abf5 100644 --- a/cli/release_package.php +++ b/cli/release_package.php @@ -21,503 +21,503 @@ use MokoEnterprise\CliFramework; class ReleasePackageCli extends CliFramework { - /** @var array */ - private array $excludePatterns = [ - 'sftp-config*', - '.ftpignore', - '*.ppk', - '*.pem', - '*.key', - '.env*', - '*.local', - '.build-trigger', - ]; - - protected function configure(): void - { - $this->setDescription('Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release'); - $this->addArgument('--path', 'Repository root path', '.'); - $this->addArgument('--version', 'Release version', ''); - $this->addArgument('--tag', 'Release tag name', ''); - $this->addArgument('--token', 'Gitea API token', ''); - $this->addArgument('--api-base', 'Gitea API base URL', ''); - $this->addArgument('--repo', 'Repo name for element detection fallback', ''); - $this->addArgument('--output', 'Output directory for built packages', ''); - } - - protected function run(): int - { - $path = $this->getArgument('--path'); - $version = $this->getArgument('--version'); - $tag = $this->getArgument('--tag'); - $token = $this->getArgument('--token'); - $apiBase = $this->getArgument('--api-base'); - $repoName = $this->getArgument('--repo'); - $outputDir = $this->getArgument('--output'); - - if ($outputDir === '' || $outputDir === null) { - $outputDir = sys_get_temp_dir(); - } - - // Allow token from environment - if ($token === '' || $token === null) { - $token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: ''); - } - - if ($version === '' || $tag === '' || $token === '' || $apiBase === '') { - $this->log('ERROR', "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL"); - $this->log('ERROR', " --repo REPO Repo name for element detection fallback"); - $this->log('ERROR', " --output DIR Output directory for built packages (default: sys_get_temp_dir())"); - $this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var"); - return 1; - } - - $root = realpath($path) ?: $path; - - // ── Read platform from .mokogitea/manifest.xml ─────────────────────── - $detectedPlatform = 'generic'; - $detectedEntryPoint = ''; - $mokoManifest = "{$root}/.mokogitea/manifest.xml"; - if (file_exists($mokoManifest)) { - $mokoXml = @simplexml_load_file($mokoManifest); - if ($mokoXml !== false) { - $rawPlatform = (string)($mokoXml->governance->platform ?? ''); - if ($rawPlatform !== '') { - $detectedPlatform = match ($rawPlatform) { - 'waas-component' => 'joomla', - 'crm-module' => 'dolibarr', - default => $rawPlatform, - }; - } - $detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? ''); - } - } - - // ── Detect element metadata from manifest XML ──────────────────────── - $extElement = ''; - $extType = ''; - $extFolder = ''; - $typePrefix = ''; - - $manifestFiles = array_merge( - glob("{$root}/src/pkg_*.xml") ?: [], - glob("{$root}/src/*.xml") ?: [], - glob("{$root}/*.xml") ?: [] - ); - - $extManifest = null; - foreach ($manifestFiles as $file) { - $content = file_get_contents($file); - if ($content !== false && strpos($content, '([^<]+)<\/element>/', $xml, $em)) { - $extElement = $em[1]; - } - if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) { - $extElement = $mm[1]; - } - if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { - $extElement = $pm[1]; - } - if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { - $extElement = $pn[1]; - } - if ($extElement === '') { - $extElement = strtolower(basename($extManifest, '.xml')); - if (in_array($extElement, ['templatedetails', 'manifest'], true)) { - $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); - } - } - } - - // Fallback to repo name - if ($extElement === '') { - $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); - } - - // Strip existing type prefix to prevent duplication - $extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); - - // Compute type prefix - switch ($extType) { - case 'plugin': - $typePrefix = "plg_{$extFolder}_"; - break; - case 'module': - $typePrefix = 'mod_'; - break; - case 'component': - $typePrefix = 'com_'; - break; - case 'template': - $typePrefix = 'tpl_'; - break; - case 'library': - $typePrefix = 'lib_'; - break; - case 'package': - $typePrefix = 'pkg_'; - break; - } - - echo "Element: {$typePrefix}{$extElement}\n"; - echo "Type: {$extType}\n"; - - // ── Compute filenames ──────────────────────────────────────────────── - $baseName = "{$typePrefix}{$extElement}-{$version}"; - $zipFile = "{$outputDir}/{$baseName}.zip"; - $tarFile = "{$outputDir}/{$baseName}.tar.gz"; - - echo "ZIP: {$baseName}.zip\n"; - echo "TAR: {$baseName}.tar.gz\n"; - - // ── Find source directory ──────────────────────────────────────────── - $sourceDir = null; - - if ($detectedEntryPoint !== '') { - $entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/'); - if (is_dir("{$root}/{$entryDir}")) { - $sourceDir = "{$root}/{$entryDir}"; - } - } - - if ($sourceDir === null && is_dir("{$root}/src")) { - $sourceDir = "{$root}/src"; - } elseif ($sourceDir === null && is_dir("{$root}/htdocs")) { - $sourceDir = "{$root}/htdocs"; - } - - if ($sourceDir === null) { - echo "No src/ or htdocs/ directory found — skipping package build\n"; - return 0; - } - - echo "Source: {$sourceDir}\n"; - - // ── Build packages ─────────────────────────────────────────────────── - $isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); - - if ($isJoomlaPackage) { - echo "Building Joomla package (sub-extensions)...\n"; - - $zip = new \ZipArchive(); - if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { - $this->log('ERROR', "Failed to create ZIP: {$zipFile}"); - return 1; - } - - $packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: []; - foreach ($packageDirs as $pkgDir) { - $subName = basename($pkgDir); - $subZipPath = "{$outputDir}/{$subName}.zip"; - - $subZip = new \ZipArchive(); - if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { - $this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}"); - continue; - } - $this->addDirToZip($subZip, $pkgDir, '', $this->excludePatterns); - $subZip->close(); - - $zip->addFile($subZipPath, "packages/{$subName}.zip"); - echo " Sub-package: {$subName}.zip\n"; - } - - $pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: []; - foreach ($pkgManifests as $pkgXml) { - $pkgContent = file_get_contents($pkgXml); - if (strpos($pkgContent, '') !== false && strpos($pkgContent, 'folder="packages"') === false) { - $pkgContent = str_replace('', '', $pkgContent); - file_put_contents($pkgXml, $pkgContent); - echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n"; - } - } - - $topLevelFiles = array_merge( - glob("{$sourceDir}/*.xml") ?: [], - glob("{$sourceDir}/*.php") ?: [] - ); - foreach ($topLevelFiles as $tlFile) { - if (!$this->isExcluded(basename($tlFile), $this->excludePatterns)) { - $zip->addFile($tlFile, basename($tlFile)); - } - } - - $topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: []; - foreach ($topLevelDirs as $tlDir) { - $dirName = basename($tlDir); - if ($dirName === 'packages') { - continue; - } - $this->addDirToZip($zip, $tlDir, $dirName, $this->excludePatterns); - echo " Included dir: {$dirName}/\n"; - } - - $zip->close(); - echo "ZIP created: {$zipFile}\n"; - } else { - echo "Building standard extension ZIP...\n"; - - $zip = new \ZipArchive(); - if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { - $this->log('ERROR', "Failed to create ZIP: {$zipFile}"); - return 1; - } - $this->addDirToZip($zip, $sourceDir, '', $this->excludePatterns); - $zip->close(); - echo "ZIP created: {$zipFile}\n"; - } - - // ── Build tar.gz ───────────────────────────────────────────────────── - $tarExcludeArgs = []; - foreach ($this->excludePatterns as $pattern) { - $tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern); - } - - $tarCommand = sprintf( - 'tar -czf %s -C %s %s .', - escapeshellarg($tarFile), - escapeshellarg($sourceDir), - implode(' ', $tarExcludeArgs) - ); - - $tarReturnCode = 0; - $tarOutputLines = []; - exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode); - - if (!file_exists($tarFile)) { - $this->log('ERROR', "Failed to create tar.gz: {$tarFile}"); - if ($tarOutputLines !== []) { - $this->log('ERROR', implode("\n", $tarOutputLines)); - } - return 1; - } - echo "TAR created: {$tarFile}\n"; - - // ── Compute SHA-256 checksums ──────────────────────────────────────── - $zipHash = hash_file('sha256', $zipFile); - $tarHash = hash_file('sha256', $tarFile); - - if ($zipHash === false || $tarHash === false) { - $this->log('ERROR', "Failed to compute SHA-256 checksums"); - return 1; - } - - $zipSha = "{$zipFile}.sha256"; - $tarSha = "{$tarFile}.sha256"; - - file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n"); - file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n"); - - echo "SHA-256 (ZIP): {$zipHash}\n"; - echo "SHA-256 (TAR): {$tarHash}\n"; - echo "sha256_zip={$zipHash}\n"; - echo "zip_name={$baseName}.zip\n"; - - // Write to GITHUB_OUTPUT if available - $ghOutput = getenv('GITHUB_OUTPUT'); - if ($ghOutput) { - file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND); - } - - // ── Get release ID from tag ────────────────────────────────────────── - $result = $this->giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token); - if ($result['data'] === null || !isset($result['data']['id'])) { - $this->log('ERROR', "No release found for tag: {$tag} (HTTP {$result['code']})"); - return 1; - } - - $releaseId = (int) $result['data']['id']; - echo "Release ID: {$releaseId} (tag: {$tag})\n"; - - // ── Delete existing assets with same names ─────────────────────────── - $assetsResult = $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token); - $existingAssets = $assetsResult['data'] ?? []; - - $uploadNames = [ - "{$baseName}.zip", - "{$baseName}.tar.gz", - "{$baseName}.zip.sha256", - "{$baseName}.tar.gz.sha256", - ]; - - foreach ($existingAssets as $asset) { - if (!is_array($asset)) { - continue; - } - $assetName = $asset['name'] ?? ''; - $assetId = $asset['id'] ?? 0; - if (in_array($assetName, $uploadNames, true) && $assetId > 0) { - $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE'); - echo "Deleted existing asset: {$assetName}\n"; - } - } - - // ── Upload assets ──────────────────────────────────────────────────── - $filesToUpload = [ - "{$baseName}.zip" => $zipFile, - "{$baseName}.tar.gz" => $tarFile, - "{$baseName}.zip.sha256" => $zipSha, - "{$baseName}.tar.gz.sha256" => $tarSha, - ]; - - $uploaded = 0; - foreach ($filesToUpload as $name => $localPath) { - if (!file_exists($localPath)) { - $this->log('ERROR', "File not found, skipping: {$localPath}"); - continue; - } - - $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name); - $httpCode = $this->giteaUploadAsset($uploadUrl, $token, $localPath); - $status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})"; - echo "Upload: {$name} — {$status}\n"; - - if ($httpCode >= 200 && $httpCode < 300) { - $uploaded++; - } - } - - // ── Summary ────────────────────────────────────────────────────────── - echo "\n"; - echo "Package build complete\n"; - echo " Element: {$typePrefix}{$extElement}\n"; - echo " Version: {$version}\n"; - echo " Tag: {$tag}\n"; - echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n"; - - return $uploaded === count($filesToUpload) ? 0 : 1; - } - - /** - * Perform a Gitea API request. - * - * @return array{data: array|null, code: int} - */ - private function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array - { - $ch = curl_init($url); - if ($ch === false) { - return ['data' => null, 'code' => 0]; - } - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Content-Type: application/json', - ], - CURLOPT_TIMEOUT => 30, - CURLOPT_CUSTOMREQUEST => $method, - ]); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - $response = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') { - return ['data' => null, 'code' => $httpCode]; - } - - $decoded = json_decode($response, true); - return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode]; - } - - /** - * Upload a file as a release asset. - */ - private function giteaUploadAsset(string $url, string $token, string $filePath): int - { - $ch = curl_init($url); - if ($ch === false) { - return 0; - } - $fileContent = file_get_contents($filePath); - if ($fileContent === false) { - return 0; - } - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_HTTPHEADER => [ - "Authorization: token {$token}", - 'Content-Type: application/octet-stream', - ], - CURLOPT_POSTFIELDS => $fileContent, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 120, - ]); - curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - return $httpCode; - } - - /** - * Check if a filename matches any exclusion pattern. - */ - private function isExcluded(string $filename, array $patterns): bool - { - $basename = basename($filename); - foreach ($patterns as $pattern) { - if (fnmatch($pattern, $basename)) { - return true; - } - } - return false; - } - - /** - * Recursively add files from a directory to a ZipArchive. - */ - private function addDirToZip(\ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void - { - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($sourceDir, \RecursiveDirectoryIterator::SKIP_DOTS), - \RecursiveIteratorIterator::LEAVES_ONLY - ); - - foreach ($iterator as $file) { - if (!$file instanceof \SplFileInfo || !$file->isFile()) { - continue; - } - - $realPath = $file->getRealPath(); - if ($realPath === false) { - continue; - } - - if ($this->isExcluded($file->getFilename(), $excludes)) { - continue; - } - - $relativePath = substr($realPath, strlen($sourceDir) + 1); - // Normalise to forward slashes for ZIP compatibility - $relativePath = str_replace('\\', '/', $relativePath); - $archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath; - $zip->addFile($realPath, $archivePath); - } - } + /** @var array */ + private array $excludePatterns = [ + 'sftp-config*', + '.ftpignore', + '*.ppk', + '*.pem', + '*.key', + '.env*', + '*.local', + '.build-trigger', + ]; + + protected function configure(): void + { + $this->setDescription('Build packages (ZIP + tar.gz) with SHA-256 and upload to Gitea release'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--version', 'Release version', ''); + $this->addArgument('--tag', 'Release tag name', ''); + $this->addArgument('--token', 'Gitea API token', ''); + $this->addArgument('--api-base', 'Gitea API base URL', ''); + $this->addArgument('--repo', 'Repo name for element detection fallback', ''); + $this->addArgument('--output', 'Output directory for built packages', ''); + } + + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $tag = $this->getArgument('--tag'); + $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $repoName = $this->getArgument('--repo'); + $outputDir = $this->getArgument('--output'); + + if ($outputDir === '' || $outputDir === null) { + $outputDir = sys_get_temp_dir(); + } + + // Allow token from environment + if ($token === '' || $token === null) { + $token = getenv('MOKOGITEA_TOKEN') ?: (getenv('GITEA_TOKEN') ?: ''); + } + + if ($version === '' || $tag === '' || $token === '' || $apiBase === '') { + $this->log('ERROR', "Usage: release_package.php --path . --version VER --tag TAG --token TOKEN --api-base URL"); + $this->log('ERROR', " --repo REPO Repo name for element detection fallback"); + $this->log('ERROR', " --output DIR Output directory for built packages (default: sys_get_temp_dir())"); + $this->log('ERROR', " Token can also be set via MOKOGITEA_TOKEN or GITEA_TOKEN env var"); + return 1; + } + + $root = realpath($path) ?: $path; + + // ── Read platform from .mokogitea/manifest.xml ─────────────────────── + $detectedPlatform = 'generic'; + $detectedEntryPoint = ''; + $mokoManifest = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($mokoManifest)) { + $mokoXml = @simplexml_load_file($mokoManifest); + if ($mokoXml !== false) { + $rawPlatform = (string)($mokoXml->governance->platform ?? ''); + if ($rawPlatform !== '') { + $detectedPlatform = match ($rawPlatform) { + 'waas-component' => 'joomla', + 'crm-module' => 'dolibarr', + default => $rawPlatform, + }; + } + $detectedEntryPoint = (string)($mokoXml->build->{"entry-point"} ?? ''); + } + } + + // ── Detect element metadata from manifest XML ──────────────────────── + $extElement = ''; + $extType = ''; + $extFolder = ''; + $typePrefix = ''; + + $manifestFiles = array_merge( + glob("{$root}/src/pkg_*.xml") ?: [], + glob("{$root}/src/*.xml") ?: [], + glob("{$root}/*.xml") ?: [] + ); + + $extManifest = null; + foreach ($manifestFiles as $file) { + $content = file_get_contents($file); + if ($content !== false && strpos($content, '([^<]+)<\/element>/', $xml, $em)) { + $extElement = $em[1]; + } + if ($extElement === '' && preg_match('/module="([^"]*)"/', $xml, $mm)) { + $extElement = $mm[1]; + } + if ($extElement === '' && preg_match('/plugin="([^"]*)"/', $xml, $pm)) { + $extElement = $pm[1]; + } + if ($extType === 'package' && preg_match('/([^<]+)<\/packagename>/', $xml, $pn)) { + $extElement = $pn[1]; + } + if ($extElement === '') { + $extElement = strtolower(basename($extManifest, '.xml')); + if (in_array($extElement, ['templatedetails', 'manifest'], true)) { + $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); + } + } + } + + // Fallback to repo name + if ($extElement === '') { + $extElement = strtolower(str_replace([' ', '-'], '', $repoName !== '' ? $repoName : basename($root))); + } + + // Strip existing type prefix to prevent duplication + $extElement = (string) preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); + + // Compute type prefix + switch ($extType) { + case 'plugin': + $typePrefix = "plg_{$extFolder}_"; + break; + case 'module': + $typePrefix = 'mod_'; + break; + case 'component': + $typePrefix = 'com_'; + break; + case 'template': + $typePrefix = 'tpl_'; + break; + case 'library': + $typePrefix = 'lib_'; + break; + case 'package': + $typePrefix = 'pkg_'; + break; + } + + echo "Element: {$typePrefix}{$extElement}\n"; + echo "Type: {$extType}\n"; + + // ── Compute filenames ──────────────────────────────────────────────── + $baseName = "{$typePrefix}{$extElement}-{$version}"; + $zipFile = "{$outputDir}/{$baseName}.zip"; + $tarFile = "{$outputDir}/{$baseName}.tar.gz"; + + echo "ZIP: {$baseName}.zip\n"; + echo "TAR: {$baseName}.tar.gz\n"; + + // ── Find source directory ──────────────────────────────────────────── + $sourceDir = null; + + if ($detectedEntryPoint !== '') { + $entryDir = rtrim(dirname($detectedEntryPoint) === '.' ? $detectedEntryPoint : dirname($detectedEntryPoint), '/'); + if (is_dir("{$root}/{$entryDir}")) { + $sourceDir = "{$root}/{$entryDir}"; + } + } + + if ($sourceDir === null && is_dir("{$root}/src")) { + $sourceDir = "{$root}/src"; + } elseif ($sourceDir === null && is_dir("{$root}/htdocs")) { + $sourceDir = "{$root}/htdocs"; + } + + if ($sourceDir === null) { + echo "No src/ or htdocs/ directory found — skipping package build\n"; + return 0; + } + + echo "Source: {$sourceDir}\n"; + + // ── Build packages ─────────────────────────────────────────────────── + $isJoomlaPackage = ($extType === 'package' && is_dir("{$sourceDir}/packages")); + + if ($isJoomlaPackage) { + echo "Building Joomla package (sub-extensions)...\n"; + + $zip = new \ZipArchive(); + if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create ZIP: {$zipFile}"); + return 1; + } + + $packageDirs = glob("{$sourceDir}/packages/*", GLOB_ONLYDIR) ?: []; + foreach ($packageDirs as $pkgDir) { + $subName = basename($pkgDir); + $subZipPath = "{$outputDir}/{$subName}.zip"; + + $subZip = new \ZipArchive(); + if ($subZip->open($subZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create sub-package ZIP: {$subZipPath}"); + continue; + } + $this->addDirToZip($subZip, $pkgDir, '', $this->excludePatterns); + $subZip->close(); + + $zip->addFile($subZipPath, "packages/{$subName}.zip"); + echo " Sub-package: {$subName}.zip\n"; + } + + $pkgManifests = glob("{$sourceDir}/pkg_*.xml") ?: []; + foreach ($pkgManifests as $pkgXml) { + $pkgContent = file_get_contents($pkgXml); + if (strpos($pkgContent, '') !== false && strpos($pkgContent, 'folder="packages"') === false) { + $pkgContent = str_replace('', '', $pkgContent); + file_put_contents($pkgXml, $pkgContent); + echo " Fixed: added folder=\"packages\" to " . basename($pkgXml) . "\n"; + } + } + + $topLevelFiles = array_merge( + glob("{$sourceDir}/*.xml") ?: [], + glob("{$sourceDir}/*.php") ?: [] + ); + foreach ($topLevelFiles as $tlFile) { + if (!$this->isExcluded(basename($tlFile), $this->excludePatterns)) { + $zip->addFile($tlFile, basename($tlFile)); + } + } + + $topLevelDirs = glob("{$sourceDir}/*", GLOB_ONLYDIR) ?: []; + foreach ($topLevelDirs as $tlDir) { + $dirName = basename($tlDir); + if ($dirName === 'packages') { + continue; + } + $this->addDirToZip($zip, $tlDir, $dirName, $this->excludePatterns); + echo " Included dir: {$dirName}/\n"; + } + + $zip->close(); + echo "ZIP created: {$zipFile}\n"; + } else { + echo "Building standard extension ZIP...\n"; + + $zip = new \ZipArchive(); + if ($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log('ERROR', "Failed to create ZIP: {$zipFile}"); + return 1; + } + $this->addDirToZip($zip, $sourceDir, '', $this->excludePatterns); + $zip->close(); + echo "ZIP created: {$zipFile}\n"; + } + + // ── Build tar.gz ───────────────────────────────────────────────────── + $tarExcludeArgs = []; + foreach ($this->excludePatterns as $pattern) { + $tarExcludeArgs[] = '--exclude=' . escapeshellarg($pattern); + } + + $tarCommand = sprintf( + 'tar -czf %s -C %s %s .', + escapeshellarg($tarFile), + escapeshellarg($sourceDir), + implode(' ', $tarExcludeArgs) + ); + + $tarReturnCode = 0; + $tarOutputLines = []; + exec($tarCommand . ' 2>&1', $tarOutputLines, $tarReturnCode); + + if (!file_exists($tarFile)) { + $this->log('ERROR', "Failed to create tar.gz: {$tarFile}"); + if ($tarOutputLines !== []) { + $this->log('ERROR', implode("\n", $tarOutputLines)); + } + return 1; + } + echo "TAR created: {$tarFile}\n"; + + // ── Compute SHA-256 checksums ──────────────────────────────────────── + $zipHash = hash_file('sha256', $zipFile); + $tarHash = hash_file('sha256', $tarFile); + + if ($zipHash === false || $tarHash === false) { + $this->log('ERROR', "Failed to compute SHA-256 checksums"); + return 1; + } + + $zipSha = "{$zipFile}.sha256"; + $tarSha = "{$tarFile}.sha256"; + + file_put_contents($zipSha, "{$zipHash} {$baseName}.zip\n"); + file_put_contents($tarSha, "{$tarHash} {$baseName}.tar.gz\n"); + + echo "SHA-256 (ZIP): {$zipHash}\n"; + echo "SHA-256 (TAR): {$tarHash}\n"; + echo "sha256_zip={$zipHash}\n"; + echo "zip_name={$baseName}.zip\n"; + + // Write to GITHUB_OUTPUT if available + $ghOutput = getenv('GITHUB_OUTPUT'); + if ($ghOutput) { + file_put_contents($ghOutput, "sha256_zip={$zipHash}\nzip_name={$baseName}.zip\n", FILE_APPEND); + } + + // ── Get release ID from tag ────────────────────────────────────────── + $result = $this->giteaApiRequest("{$apiBase}/releases/tags/{$tag}", $token); + if ($result['data'] === null || !isset($result['data']['id'])) { + $this->log('ERROR', "No release found for tag: {$tag} (HTTP {$result['code']})"); + return 1; + } + + $releaseId = (int) $result['data']['id']; + echo "Release ID: {$releaseId} (tag: {$tag})\n"; + + // ── Delete existing assets with same names ─────────────────────────── + $assetsResult = $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets", $token); + $existingAssets = $assetsResult['data'] ?? []; + + $uploadNames = [ + "{$baseName}.zip", + "{$baseName}.tar.gz", + "{$baseName}.zip.sha256", + "{$baseName}.tar.gz.sha256", + ]; + + foreach ($existingAssets as $asset) { + if (!is_array($asset)) { + continue; + } + $assetName = $asset['name'] ?? ''; + $assetId = $asset['id'] ?? 0; + if (in_array($assetName, $uploadNames, true) && $assetId > 0) { + $this->giteaApiRequest("{$apiBase}/releases/{$releaseId}/assets/{$assetId}", $token, 'DELETE'); + echo "Deleted existing asset: {$assetName}\n"; + } + } + + // ── Upload assets ──────────────────────────────────────────────────── + $filesToUpload = [ + "{$baseName}.zip" => $zipFile, + "{$baseName}.tar.gz" => $tarFile, + "{$baseName}.zip.sha256" => $zipSha, + "{$baseName}.tar.gz.sha256" => $tarSha, + ]; + + $uploaded = 0; + foreach ($filesToUpload as $name => $localPath) { + if (!file_exists($localPath)) { + $this->log('ERROR', "File not found, skipping: {$localPath}"); + continue; + } + + $uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($name); + $httpCode = $this->giteaUploadAsset($uploadUrl, $token, $localPath); + $status = ($httpCode >= 200 && $httpCode < 300) ? 'OK' : "FAILED ({$httpCode})"; + echo "Upload: {$name} — {$status}\n"; + + if ($httpCode >= 200 && $httpCode < 300) { + $uploaded++; + } + } + + // ── Summary ────────────────────────────────────────────────────────── + echo "\n"; + echo "Package build complete\n"; + echo " Element: {$typePrefix}{$extElement}\n"; + echo " Version: {$version}\n"; + echo " Tag: {$tag}\n"; + echo " Uploaded: {$uploaded}/" . count($filesToUpload) . " asset(s)\n"; + + return $uploaded === count($filesToUpload) ? 0 : 1; + } + + /** + * Perform a Gitea API request. + * + * @return array{data: array|null, code: int} + */ + private function giteaApiRequest(string $url, string $token, string $method = 'GET', ?string $body = null): array + { + $ch = curl_init($url); + if ($ch === false) { + return ['data' => null, 'code' => 0]; + } + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300 || !is_string($response) || $response === '') { + return ['data' => null, 'code' => $httpCode]; + } + + $decoded = json_decode($response, true); + return ['data' => is_array($decoded) ? $decoded : null, 'code' => $httpCode]; + } + + /** + * Upload a file as a release asset. + */ + private function giteaUploadAsset(string $url, string $token, string $filePath): int + { + $ch = curl_init($url); + if ($ch === false) { + return 0; + } + $fileContent = file_get_contents($filePath); + if ($fileContent === false) { + return 0; + } + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/octet-stream', + ], + CURLOPT_POSTFIELDS => $fileContent, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + ]); + curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $httpCode; + } + + /** + * Check if a filename matches any exclusion pattern. + */ + private function isExcluded(string $filename, array $patterns): bool + { + $basename = basename($filename); + foreach ($patterns as $pattern) { + if (fnmatch($pattern, $basename)) { + return true; + } + } + return false; + } + + /** + * Recursively add files from a directory to a ZipArchive. + */ + private function addDirToZip(\ZipArchive $zip, string $sourceDir, string $prefix, array $excludes): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($sourceDir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + if (!$file instanceof \SplFileInfo || !$file->isFile()) { + continue; + } + + $realPath = $file->getRealPath(); + if ($realPath === false) { + continue; + } + + if ($this->isExcluded($file->getFilename(), $excludes)) { + continue; + } + + $relativePath = substr($realPath, strlen($sourceDir) + 1); + // Normalise to forward slashes for ZIP compatibility + $relativePath = str_replace('\\', '/', $relativePath); + $archivePath = $prefix !== '' ? "{$prefix}/{$relativePath}" : $relativePath; + $zip->addFile($realPath, $archivePath); + } + } } $app = new ReleasePackageCli(); diff --git a/cli/release_publish.php b/cli/release_publish.php index 53d1863..73bfae1 100644 --- a/cli/release_publish.php +++ b/cli/release_publish.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -61,10 +62,18 @@ class ReleasePublishCli extends CliFramework // Auto-detect org/repo from git remote if not set if (empty($org) || empty($repo)) { - $remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git remote get-url origin 2>/dev/null")); + $cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd "; + $remote = trim((string) @shell_exec( + $cd . escapeshellarg($resolvedPath) + . " && git remote get-url origin 2>/dev/null" + )); if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) { - if (empty($org)) $org = $m[1]; - if (empty($repo)) $repo = $m[2]; + if (empty($org)) { + $org = $m[1]; + } + if (empty($repo)) { + $repo = $m[2]; + } } } @@ -76,7 +85,12 @@ class ReleasePublishCli extends CliFramework // Auto-detect branch if (empty($branch)) { - $branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null")); + $cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd "; + $branch = getenv('GITHUB_REF_NAME') + ?: trim((string) @shell_exec( + $cdCmd . escapeshellarg($resolvedPath) + . " && git rev-parse --abbrev-ref HEAD 2>/dev/null" + )); } $apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}"; @@ -145,12 +159,23 @@ class ReleasePublishCli extends CliFramework // -- Step 2b: Update badges and changelog -- if (!$this->dryRun) { - passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); + passthru( + "{$php} {$cli}/badge_update.php --path " + . escapeshellarg($path) . " --version " + . escapeshellarg($baseVersion) . " 2>/dev/null" + ); $changelogFile = realpath($path) . '/CHANGELOG.md'; if (file_exists($changelogFile)) { - passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); - passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null"); + passthru( + "{$php} {$cli}/changelog_promote.php --path " + . escapeshellarg($path) . " --version " + . escapeshellarg($baseVersion) . " 2>/dev/null" + ); + passthru( + "{$php} {$cli}/changelog_prune.php --path " + . escapeshellarg($path) . " --keep 5 2>/dev/null" + ); } } @@ -158,30 +183,67 @@ class ReleasePublishCli extends CliFramework $root = realpath($path) ?: $path; if (!$this->dryRun) { // Configure git - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\""); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\""); + $cdPfx = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd "; + $cdR = $cdPfx . escapeshellarg($root); + @shell_exec( + $cdR . " && git config --local user.email" + . " \"gitea-actions[bot]@mokoconsulting.tech\"" + ); + @shell_exec( + $cdR . " && git config --local user.name" + . " \"gitea-actions[bot]\"" + ); if (!empty($repoUrl)) { - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl)); + @shell_exec( + $cdR . " && git remote set-url origin " + . escapeshellarg($repoUrl) + ); } // Ensure we're on the actual branch (not detached HEAD from PR merge) - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git fetch origin " . escapeshellarg($branch) . " 2>/dev/null"); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"); + @shell_exec( + $cdR . " && git fetch origin " + . escapeshellarg($branch) . " 2>/dev/null" + ); + @shell_exec( + $cdR . " && git checkout -B " + . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null" + ); // Re-apply version changes on the checked-out branch passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " --branch " . escapeshellarg($branch) . " --stability " . escapeshellarg($stability) . " 2>/dev/null"); - passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null"); - passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); + passthru( + "{$php} {$cli}/version_check.php --path " + . escapeshellarg($path) . " --fix 2>/dev/null" + ); + passthru( + "{$php} {$cli}/badge_update.php --path " + . escapeshellarg($path) . " --version " + . escapeshellarg($baseVersion) . " 2>/dev/null" + ); - $diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty")); + $diffCheck = trim((string) @shell_exec( + $cdR . " && git diff --quiet" + . " && git diff --cached --quiet" + . " 2>&1 && echo clean || echo dirty" + )); if ($diffCheck === 'dirty') { - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A"); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]") - . " --author=\"gitea-actions[bot] \""); - $pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); + @shell_exec($cdR . " && git add -A"); + $commitMsg = "chore(release): build" + . " {$releaseVersion} [skip ci]"; + @shell_exec( + $cdR . " && git commit -m " + . escapeshellarg($commitMsg) + . " --author=\"gitea-actions[bot]" + . " \"" + ); + $pushResult = @shell_exec( + $cdR . " && git push origin " + . escapeshellarg($branch) . " 2>&1" + ); echo " Committed release changes\n"; echo " Push: " . trim($pushResult ?? '') . "\n"; } @@ -258,12 +320,26 @@ class ReleasePublishCli extends CliFramework $root = realpath($path) ?: $path; if (!$this->dryRun) { - $diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet updates.xml 2>&1 && echo clean || echo dirty")); + $cdX = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd "; + $cdRt = $cdX . escapeshellarg($root); + $diffCheck = trim((string) @shell_exec( + $cdRt . " && git diff --quiet updates.xml" + . " 2>&1 && echo clean || echo dirty" + )); if ($diffCheck === 'dirty') { - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml"); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore: update channels for {$releaseVersion} [skip ci]") - . " --author=\"gitea-actions[bot] \""); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); + @shell_exec($cdRt . " && git add updates.xml"); + $chMsg = "chore: update channels for" + . " {$releaseVersion} [skip ci]"; + @shell_exec( + $cdRt . " && git commit -m " + . escapeshellarg($chMsg) + . " --author=\"gitea-actions[bot]" + . " \"" + ); + @shell_exec( + $cdRt . " && git push origin " + . escapeshellarg($branch) . " 2>&1" + ); echo " Committed updates.xml\n"; } diff --git a/cli/release_validate.php b/cli/release_validate.php index 234cf68..e3bfe8c 100644 --- a/cli/release_validate.php +++ b/cli/release_validate.php @@ -21,70 +21,216 @@ use MokoEnterprise\CliFramework; class ReleaseValidateCli extends CliFramework { - private int $pass = 0; - private int $fail = 0; - private int $warn = 0; - private array $results = []; + private int $pass = 0; + private int $fail = 0; + private int $warn = 0; + private array $results = []; - protected function configure(): void - { - $this->setDescription('Pre-release validation -- version consistency, required files, manifest checks'); - $this->addArgument('--path', 'Repository root', '.'); - $this->addArgument('--version', 'Expected version string', null); - $this->addArgument('--platform', 'joomla|dolibarr|generic', null); - $this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false); - $this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false); - } + protected function configure(): void + { + $this->setDescription('Pre-release validation -- version consistency, required files, manifest checks'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--version', 'Expected version string', null); + $this->addArgument('--platform', 'joomla|dolibarr|generic', null); + $this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false); + $this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false); + } - protected function run(): int - { - $path = $this->getArgument('--path'); $version = $this->getArgument('--version'); - $platform = $this->getArgument('--platform'); - $outputSummary = (bool) $this->getArgument('--output-summary'); - $githubOutput = (bool) $this->getArgument('--github-output'); - if ($version === null) { $this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]"); return 1; } - $root = realpath($path) ?: $path; - if ($platform === null) { - $manifestXml = "{$root}/.mokogitea/manifest.xml"; - if (file_exists($manifestXml)) { $mContent = file_get_contents($manifestXml); if (preg_match('/([^<]+)<\/platform>/', $mContent, $pm)) { $platform = trim($pm[1]); } } - if (in_array($platform, ['waas-component'], true)) { $platform = 'joomla'; } - if (in_array($platform, ['crm-module'], true)) { $platform = 'dolibarr'; } - if ($platform === null) { $platform = 'generic'; } - } - $hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs"); - $this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory'); - if (!file_exists("{$root}/README.md")) { $this->addVResult('README.md', 'FAIL', 'Not found'); } - else { $readme = file_get_contents("{$root}/README.md"); $this->addVResult('README.md version', (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || strpos($readme, $version) !== false) ? 'PASS' : 'FAIL', (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || strpos($readme, $version) !== false) ? "`{$version}` found" : "`{$version}` not found"); } - if (!file_exists("{$root}/CHANGELOG.md")) { $this->addVResult('CHANGELOG.md', 'WARN', 'Not found'); } - else { $cl = file_get_contents("{$root}/CHANGELOG.md"); $this->addVResult('CHANGELOG.md version', preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl) ? 'PASS' : 'WARN', preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl) ? "Section found" : "No section header"); } - $licenseFound = false; foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) { if (file_exists("{$root}/{$lf}")) { $licenseFound = true; break; } } - $this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); - if ($platform === 'joomla') { - $manifest = null; foreach (["{$root}/src", $root] as $dir) { if (!is_dir($dir)) continue; foreach (glob("{$dir}/*.xml") as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, 'addVResult('XML manifest', 'FAIL', 'No Joomla manifest found'); } - else { if (preg_match('/([^<]+)<\/version>/', file_get_contents($manifest), $m)) { $mVer = trim($m[1]); $this->addVResult('Manifest version', $mVer === $version ? 'PASS' : 'FAIL', $mVer === $version ? "`{$mVer}` matches" : "`{$mVer}` != `{$version}`"); } else { $this->addVResult('Manifest version', 'FAIL', 'No tag'); } } - if (!file_exists("{$root}/updates.xml")) { $this->addVResult('updates.xml', 'WARN', 'Not found'); } - else { $ux = file_get_contents("{$root}/updates.xml"); $this->addVResult('updates.xml version', preg_match('/' . preg_quote($version, '/') . '<\/version>/', $ux) ? 'PASS' : 'FAIL', preg_match('/' . preg_quote($version, '/') . '<\/version>/', $ux) ? "`{$version}` found" : "`{$version}` not found"); } - } elseif ($platform === 'dolibarr') { - $modFile = null; foreach (['src', 'htdocs'] as $sd) { $matches = glob("{$root}/{$sd}/mod*.class.php"); if (!empty($matches)) { $modFile = $matches[0]; break; } } - if ($modFile === null) { $this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found'); } - else { $mc = file_get_contents($modFile); $this->addVResult('Dolibarr version', preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc) ? 'PASS' : 'FAIL', preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc) ? "`{$version}` matches" : "`{$version}` not found"); } - } - if (file_exists("{$root}/composer.json")) { $composer = json_decode(file_get_contents("{$root}/composer.json"), true); if (isset($composer['version'])) { $this->addVResult('composer.json version', $composer['version'] === $version ? 'PASS' : 'WARN', $composer['version'] === $version ? "`{$version}` matches" : "`{$composer['version']}` != `{$version}`"); } } - $table = "| Check | Result | Details |\n|-------|--------|--------|\n"; - foreach ($this->results as $r) { $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; } - $table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n"; - echo $table; - if ($outputSummary) { $summaryFile = getenv('GITHUB_STEP_SUMMARY'); if ($summaryFile) { file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND); } } - if ($githubOutput) { $ghOutput = getenv('GITHUB_OUTPUT'); if ($ghOutput) { file_put_contents($ghOutput, "validation_pass={$this->pass}\nvalidation_fail={$this->fail}\nvalidation_warn={$this->warn}\nvalidation_platform={$platform}\n", FILE_APPEND); } } - return $this->fail > 0 ? 1 : 0; - } + protected function run(): int + { + $path = $this->getArgument('--path'); + $version = $this->getArgument('--version'); + $platform = $this->getArgument('--platform'); + $outputSummary = (bool) $this->getArgument('--output-summary'); + $githubOutput = (bool) $this->getArgument('--github-output'); + if ($version === null) { + $this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]"); + return 1; + } + $root = realpath($path) ?: $path; + if ($platform === null) { + $manifestXml = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($manifestXml)) { + $mContent = file_get_contents($manifestXml); + if (preg_match('/([^<]+)<\/platform>/', $mContent, $pm)) { + $platform = trim($pm[1]); + } + } + if (in_array($platform, ['waas-component'], true)) { + $platform = 'joomla'; + } + if (in_array($platform, ['crm-module'], true)) { + $platform = 'dolibarr'; + } + if ($platform === null) { + $platform = 'generic'; + } + } + $hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs"); + $this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory'); + if (!file_exists("{$root}/README.md")) { + $this->addVResult('README.md', 'FAIL', 'Not found'); + } else { + $readme = file_get_contents("{$root}/README.md"); + $quotedVer = preg_quote($version, '/'); + $readmeHasVer = preg_match( + '/VERSION:\s*' . $quotedVer . '/', + $readme + ) || strpos($readme, $version) !== false; + $this->addVResult( + 'README.md version', + $readmeHasVer ? 'PASS' : 'FAIL', + $readmeHasVer + ? "`{$version}` found" + : "`{$version}` not found" + ); + } + if (!file_exists("{$root}/CHANGELOG.md")) { + $this->addVResult('CHANGELOG.md', 'WARN', 'Not found'); + } else { + $cl = file_get_contents("{$root}/CHANGELOG.md"); + $clHasVer = preg_match( + '/^##\s.*' . preg_quote($version, '/') . '/m', + $cl + ); + $this->addVResult( + 'CHANGELOG.md version', + $clHasVer ? 'PASS' : 'WARN', + $clHasVer ? "Section found" : "No section header" + ); + } + $licenseFound = false; + foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) { + if (file_exists("{$root}/{$lf}")) { + $licenseFound = true; + break; + } + } + $this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); + if ($platform === 'joomla') { + $manifest = null; + foreach (["{$root}/src", $root] as $dir) { + if (!is_dir($dir)) { + continue; + } foreach (glob("{$dir}/*.xml") as $xmlFile) { + $content = file_get_contents($xmlFile); + if (strpos($content, 'addVResult('XML manifest', 'FAIL', 'No Joomla manifest found'); + } else { + $manifestContent = file_get_contents($manifest); + if (preg_match('/([^<]+)<\/version>/', $manifestContent, $m)) { + $mVer = trim($m[1]); + $this->addVResult( + 'Manifest version', + $mVer === $version ? 'PASS' : 'FAIL', + $mVer === $version + ? "`{$mVer}` matches" + : "`{$mVer}` != `{$version}`" + ); + } else { + $this->addVResult('Manifest version', 'FAIL', 'No tag'); + } + } + if (!file_exists("{$root}/updates.xml")) { + $this->addVResult('updates.xml', 'WARN', 'Not found'); + } else { + $ux = file_get_contents("{$root}/updates.xml"); + $uxHasVer = preg_match( + '/' . preg_quote($version, '/') + . '<\/version>/', + $ux + ); + $this->addVResult( + 'updates.xml version', + $uxHasVer ? 'PASS' : 'FAIL', + $uxHasVer + ? "`{$version}` found" + : "`{$version}` not found" + ); + } + } elseif ($platform === 'dolibarr') { + $modFile = null; + foreach (['src', 'htdocs'] as $sd) { + $matches = glob("{$root}/{$sd}/mod*.class.php"); + if (!empty($matches)) { + $modFile = $matches[0]; + break; + } + } + if ($modFile === null) { + $this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found'); + } else { + $mc = file_get_contents($modFile); + $dolPattern = "/\\\$this->version\s*=\s*'" + . preg_quote($version, '/') . "'/"; + $dolMatch = preg_match($dolPattern, $mc); + $this->addVResult( + 'Dolibarr version', + $dolMatch ? 'PASS' : 'FAIL', + $dolMatch + ? "`{$version}` matches" + : "`{$version}` not found" + ); + } + } + if (file_exists("{$root}/composer.json")) { + $composer = json_decode(file_get_contents("{$root}/composer.json"), true); + if (isset($composer['version'])) { + $compMatch = $composer['version'] === $version; + $this->addVResult( + 'composer.json version', + $compMatch ? 'PASS' : 'WARN', + $compMatch + ? "`{$version}` matches" + : "`{$composer['version']}` != `{$version}`" + ); + } + } + $table = "| Check | Result | Details |\n|-------|--------|--------|\n"; + foreach ($this->results as $r) { + $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; + } + $table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n"; + echo $table; + if ($outputSummary) { + $summaryFile = getenv('GITHUB_STEP_SUMMARY'); + if ($summaryFile) { + file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND); + } + } + if ($githubOutput) { + $ghOutput = getenv('GITHUB_OUTPUT'); + if ($ghOutput) { + file_put_contents( + $ghOutput, + "validation_pass={$this->pass}\n" + . "validation_fail={$this->fail}\n" + . "validation_warn={$this->warn}\n" + . "validation_platform={$platform}\n", + FILE_APPEND + ); + } + } + return $this->fail > 0 ? 1 : 0; + } - private function addVResult(string $check, string $status, string $details): void - { - $this->results[] = ['check' => $check, 'status' => $status, 'details' => $details]; - if ($status === 'PASS') { $this->pass++; } elseif ($status === 'FAIL') { $this->fail++; } elseif ($status === 'WARN') { $this->warn++; } - } + private function addVResult(string $check, string $status, string $details): void + { + $this->results[] = ['check' => $check, 'status' => $status, 'details' => $details]; + if ($status === 'PASS') { + $this->pass++; + } elseif ($status === 'FAIL') { + $this->fail++; + } elseif ($status === 'WARN') { + $this->warn++; + } + } } $app = new ReleaseValidateCli(); diff --git a/cli/release_verify.php b/cli/release_verify.php index 59752b8..1166ab2 100644 --- a/cli/release_verify.php +++ b/cli/release_verify.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -103,7 +104,13 @@ class ReleaseVerifyCli extends CliFramework if ($zipSha === $expectedSha) { $this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`'); } else { - $this->addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`"); + $this->addResult( + 'SHA256 vs updates.xml', + 'FAIL', + "ZIP=`" . substr($zipSha, 0, 16) + . "...` updates.xml=`" + . substr($expectedSha, 0, 16) . "...`" + ); } } else { $this->addResult('SHA256 vs updates.xml', 'WARN', 'No in updates.xml'); @@ -145,7 +152,13 @@ class ReleaseVerifyCli extends CliFramework } // Clean up - $rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + $rit = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $tmpDir, + \RecursiveDirectoryIterator::SKIP_DOTS + ), + \RecursiveIteratorIterator::CHILD_FIRST + ); foreach ($rit as $file) { $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } diff --git a/cli/scaffold_client.php b/cli/scaffold_client.php index 7a8016b..eed377c 100644 --- a/cli/scaffold_client.php +++ b/cli/scaffold_client.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -23,60 +24,111 @@ use MokoEnterprise\CliFramework; class ScaffoldClientCli extends CliFramework { - protected function configure(): void - { - $this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS'); - $this->addArgument('--name', 'Client name', ''); - $this->addArgument('--org', 'Gitea organization', ''); - $this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech'); - $this->addArgument('--token', 'Gitea API token', ''); - } + protected function configure(): void + { + $this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS'); + $this->addArgument('--name', 'Client name', ''); + $this->addArgument('--org', 'Gitea organization', ''); + $this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech'); + $this->addArgument('--token', 'Gitea API token', ''); + } - protected function run(): int - { - $name = $this->getArgument('--name'); $org = $this->getArgument('--org'); - $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $token = $this->getArgument('--token'); - if ($name === '' || $org === '' || $token === '') { $this->log('ERROR', '--name, --org, and --token are required.'); return 1; } - $repoName = 'client-waas-' . $name; - $this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}"); - $this->log('INFO', "Gitea URL: {$giteaUrl}"); - if ($this->dryRun) { - $this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); - $this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}"); - $this->printPostSetupInstructions($repoName, $giteaUrl, $org); - return 0; - } - $this->log('INFO', 'Step 1: Creating repo from template...'); - $createPayload = json_encode(['owner' => $org, 'name' => $repoName, 'description' => "{$name} WaaS site", 'private' => true, 'git_content' => true, 'topics' => true, 'labels' => true]); - $response = $this->apiRequest('POST', "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", $giteaUrl, $token, $createPayload); - if ($response['code'] < 200 || $response['code'] >= 300) { $this->log('ERROR', "Failed to create repo (HTTP {$response['code']})."); return 1; } - $this->log('INFO', "Repo created: {$org}/{$repoName}"); - $this->log('INFO', 'Step 2: Updating repo description...'); - $this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"])); - $this->log('INFO', 'Step 3: Creating dev branch from main...'); - $response = $this->apiRequest('POST', "/api/v1/repos/{$org}/{$repoName}/branches", $giteaUrl, $token, json_encode(['new_branch_name' => 'dev', 'old_branch_name' => 'main'])); - if ($response['code'] >= 200 && $response['code'] < 300) { $this->log('INFO', 'Branch "dev" created from "main".'); } - else { $this->log('WARN', "Could not create dev branch (HTTP {$response['code']})."); } - $this->printPostSetupInstructions($repoName, $giteaUrl, $org); - $this->log('INFO', 'Scaffold complete.'); - return 0; - } + protected function run(): int + { + $name = $this->getArgument('--name'); + $org = $this->getArgument('--org'); + $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); + $token = $this->getArgument('--token'); + if ($name === '' || $org === '' || $token === '') { + $this->log('ERROR', '--name, --org, and --token are required.'); + return 1; + } + $repoName = 'client-waas-' . $name; + $this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}"); + $this->log('INFO', "Gitea URL: {$giteaUrl}"); + if ($this->dryRun) { + $this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); + $this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}"); + $this->printPostSetupInstructions($repoName, $giteaUrl, $org); + return 0; + } + $this->log('INFO', 'Step 1: Creating repo from template...'); + $createPayload = json_encode([ + 'owner' => $org, + 'name' => $repoName, + 'description' => "{$name} WaaS site", + 'private' => true, + 'git_content' => true, + 'topics' => true, + 'labels' => true, + ]); + $response = $this->apiRequest( + 'POST', + "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", + $giteaUrl, + $token, + $createPayload + ); + if ($response['code'] < 200 || $response['code'] >= 300) { + $this->log('ERROR', "Failed to create repo (HTTP {$response['code']})."); + return 1; + } + $this->log('INFO', "Repo created: {$org}/{$repoName}"); + $this->log('INFO', 'Step 2: Updating repo description...'); + $this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"])); + $this->log('INFO', 'Step 3: Creating dev branch from main...'); + $response = $this->apiRequest( + 'POST', + "/api/v1/repos/{$org}/{$repoName}/branches", + $giteaUrl, + $token, + json_encode([ + 'new_branch_name' => 'dev', + 'old_branch_name' => 'main', + ]) + ); + if ($response['code'] >= 200 && $response['code'] < 300) { + $this->log('INFO', 'Branch "dev" created from "main".'); + } else { + $this->log('WARN', "Could not create dev branch (HTTP {$response['code']})."); + } + $this->printPostSetupInstructions($repoName, $giteaUrl, $org); + $this->log('INFO', 'Scaffold complete.'); + return 0; + } - private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void - { - fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\nNavigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\nSet REPO VARIABLES:\n DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\nSet REPO SECRETS:\n DEV_SYNC_KEY, LIVE_SSH_KEY\n\n================================\n"); - } + private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void + { + fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\n" + . "Navigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\n" + . "Set REPO VARIABLES:\n" + . " DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n" + . " LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\n" + . "Set REPO SECRETS:\n" + . " DEV_SYNC_KEY, LIVE_SSH_KEY\n\n" + . "================================\n"); + } - private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array - { - $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint); - 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 {$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 apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint); + 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 {$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]; + } } $app = new ScaffoldClientCli(); diff --git a/cli/sync_rulesets.php b/cli/sync_rulesets.php index 9aae686..1c6e264 100644 --- a/cli/sync_rulesets.php +++ b/cli/sync_rulesets.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. diff --git a/cli/theme_lint.php b/cli/theme_lint.php index 1692249..748692a 100644 --- a/cli/theme_lint.php +++ b/cli/theme_lint.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -20,159 +21,172 @@ use MokoEnterprise\CliFramework; class ThemeLintCli extends CliFramework { - protected function configure(): void - { - $this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs'); - $this->addArgument('--path', 'Repository root', '.'); - $this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500'); - $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); - $this->addArgument('--strict', 'Exit 1 on any warning', false); - } + protected function configure(): void + { + $this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500'); + $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); + $this->addArgument('--strict', 'Exit 1 on any warning', false); + } - protected function run(): int - { - $path = $this->getArgument('--path'); - $maxImageKb = (int) $this->getArgument('--max-image-kb'); - $ghOutput = (bool) $this->getArgument('--github-output'); - $strict = (bool) $this->getArgument('--strict'); + protected function run(): int + { + $path = $this->getArgument('--path'); + $maxImageKb = (int) $this->getArgument('--max-image-kb'); + $ghOutput = (bool) $this->getArgument('--github-output'); + $strict = (bool) $this->getArgument('--strict'); - $root = realpath($path) ?: $path; - $errors = 0; - $warnings = 0; + $root = realpath($path) ?: $path; + $errors = 0; + $warnings = 0; - $srcDir = null; - foreach (['src', 'htdocs'] as $d) { - if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; } - } - if ($srcDir === null) { - $this->log('ERROR', "No src/ or htdocs/ directory in {$root}"); - return 1; - } + $srcDir = null; + foreach (['src', 'htdocs'] as $d) { + if (is_dir("{$root}/{$d}")) { + $srcDir = "{$root}/{$d}"; + break; + } + } + if ($srcDir === null) { + $this->log('ERROR', "No src/ or htdocs/ directory in {$root}"); + return 1; + } - echo "Theme Lint: {$srcDir}\n\n"; + echo "Theme Lint: {$srcDir}\n\n"; - echo "--- CSS Syntax ---\n"; - $cssFiles = $this->findFiles($srcDir, '*.css'); - $cssMinFiles = $this->findFiles($srcDir, '*.min.css'); - $cssToCheck = array_diff($cssFiles, $cssMinFiles); + echo "--- CSS Syntax ---\n"; + $cssFiles = $this->findFiles($srcDir, '*.css'); + $cssMinFiles = $this->findFiles($srcDir, '*.min.css'); + $cssToCheck = array_diff($cssFiles, $cssMinFiles); - if (empty($cssToCheck)) { - echo " No CSS files to check\n"; - } else { - foreach ($cssToCheck as $file) { - $content = file_get_contents($file); - $relPath = str_replace($root . '/', '', $file); - $openBraces = substr_count($content, '{'); - $closeBraces = substr_count($content, '}'); - if ($openBraces !== $closeBraces) { - echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n"; - $errors++; - } - if (preg_match_all('/\{[\s]*\}/', $content, $m)) { - $count = count($m[0]); - echo " WARN: {$relPath}: {$count} empty rule(s)\n"; - $warnings++; - } - $importantCount = substr_count($content, '!important'); - if ($importantCount > 10) { - echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n"; - $warnings++; - } - } - if ($errors === 0) { - echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n"; - } - } + if (empty($cssToCheck)) { + echo " No CSS files to check\n"; + } else { + foreach ($cssToCheck as $file) { + $content = file_get_contents($file); + $relPath = str_replace($root . '/', '', $file); + $openBraces = substr_count($content, '{'); + $closeBraces = substr_count($content, '}'); + if ($openBraces !== $closeBraces) { + echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n"; + $errors++; + } + if (preg_match_all('/\{[\s]*\}/', $content, $m)) { + $count = count($m[0]); + echo " WARN: {$relPath}: {$count} empty rule(s)\n"; + $warnings++; + } + $importantCount = substr_count($content, '!important'); + if ($importantCount > 10) { + echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n"; + $warnings++; + } + } + if ($errors === 0) { + echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n"; + } + } - echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n"; - $imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp']; - $images = []; - foreach ($imageExts as $ext) { - $images = array_merge($images, $this->findFiles($srcDir, $ext)); - } - if (is_dir("{$root}/images")) { - foreach ($imageExts as $ext) { - $images = array_merge($images, $this->findFiles("{$root}/images", $ext)); - } - } + echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n"; + $imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp']; + $images = []; + foreach ($imageExts as $ext) { + $images = array_merge($images, $this->findFiles($srcDir, $ext)); + } + if (is_dir("{$root}/images")) { + foreach ($imageExts as $ext) { + $images = array_merge($images, $this->findFiles("{$root}/images", $ext)); + } + } - $oversized = 0; - $totalSize = 0; - foreach ($images as $file) { - $size = filesize($file); - $totalSize += $size; - $relPath = str_replace($root . '/', '', $file); - $sizeKb = round($size / 1024); - if ($sizeKb > $maxImageKb) { - echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n"; - $oversized++; - $warnings++; - } - } + $oversized = 0; + $totalSize = 0; + foreach ($images as $file) { + $size = filesize($file); + $totalSize += $size; + $relPath = str_replace($root . '/', '', $file); + $sizeKb = round($size / 1024); + if ($sizeKb > $maxImageKb) { + echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n"; + $oversized++; + $warnings++; + } + } - $totalMb = round($totalSize / 1024 / 1024, 1); - echo " " . count($images) . " image(s), {$totalMb}MB total"; - if ($oversized > 0) { echo ", {$oversized} oversized"; } - echo "\n"; + $totalMb = round($totalSize / 1024 / 1024, 1); + echo " " . count($images) . " image(s), {$totalMb}MB total"; + if ($oversized > 0) { + echo ", {$oversized} oversized"; + } + echo "\n"; - echo "\n--- Hardcoded URLs ---\n"; - $codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js')); - $codeFiles = array_filter($codeFiles, function ($f) { - return !preg_match('/\.min\.(css|js)$/', $f); - }); - $urlPatterns = [ - '/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL', - '/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL', - '/https?:\/\/localhost/' => 'localhost reference', - ]; - $urlIssues = 0; - foreach ($codeFiles as $file) { - $content = file_get_contents($file); - $relPath = str_replace($root . '/', '', $file); - foreach ($urlPatterns as $pattern => $desc) { - if (preg_match_all($pattern, $content, $matches)) { - $count = count($matches[0]); - echo " WARN: {$relPath}: {$count} {$desc}\n"; - $urlIssues++; - $warnings++; - } - } - } - if ($urlIssues === 0) { echo " OK: No hardcoded URLs found\n"; } + echo "\n--- Hardcoded URLs ---\n"; + $codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js')); + $codeFiles = array_filter($codeFiles, function ($f) { + return !preg_match('/\.min\.(css|js)$/', $f); + }); + $urlPatterns = [ + '/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL', + '/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL', + '/https?:\/\/localhost/' => 'localhost reference', + ]; + $urlIssues = 0; + foreach ($codeFiles as $file) { + $content = file_get_contents($file); + $relPath = str_replace($root . '/', '', $file); + foreach ($urlPatterns as $pattern => $desc) { + if (preg_match_all($pattern, $content, $matches)) { + $count = count($matches[0]); + echo " WARN: {$relPath}: {$count} {$desc}\n"; + $urlIssues++; + $warnings++; + } + } + } + if ($urlIssues === 0) { + echo " OK: No hardcoded URLs found\n"; + } - echo "\n=== Summary ===\n"; - echo "Errors: {$errors}\n"; - echo "Warnings: {$warnings}\n"; + echo "\n=== Summary ===\n"; + echo "Errors: {$errors}\n"; + echo "Warnings: {$warnings}\n"; - if ($ghOutput) { - $ghFile = getenv('GITHUB_OUTPUT'); - if ($ghFile) { - file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND); - file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND); - file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND); - file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND); - } - } + if ($ghOutput) { + $ghFile = getenv('GITHUB_OUTPUT'); + if ($ghFile) { + file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND); + file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND); + file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND); + file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND); + } + } - if ($errors > 0) { return 1; } - if ($strict && $warnings > 0) { return 1; } - return 0; - } + if ($errors > 0) { + return 1; + } + if ($strict && $warnings > 0) { + return 1; + } + return 0; + } - private function findFiles(string $dir, string $pattern): array - { - $results = []; - if (!is_dir($dir)) { return $results; } - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) - ); - foreach ($iterator as $file) { - if (fnmatch($pattern, $file->getFilename())) { - $results[] = $file->getPathname(); - } - } - return $results; - } + private function findFiles(string $dir, string $pattern): array + { + $results = []; + if (!is_dir($dir)) { + return $results; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (fnmatch($pattern, $file->getFilename())) { + $results[] = $file->getPathname(); + } + } + return $results; + } } $app = new ThemeLintCli(); diff --git a/cli/updates_xml_sync.php b/cli/updates_xml_sync.php index f9600a8..1e79aca 100644 --- a/cli/updates_xml_sync.php +++ b/cli/updates_xml_sync.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -94,7 +95,8 @@ class UpdatesXmlSyncCli extends CliFramework $discovered = []; foreach ($branchList as $b) { $name = $b['name'] ?? ''; - if ($name !== '' && $name !== $current + if ( + $name !== '' && $name !== $current && !str_starts_with($name, 'version/') && !str_starts_with($name, 'feature/') && !str_starts_with($name, 'patch/') @@ -144,8 +146,14 @@ class UpdatesXmlSyncCli extends CliFramework continue; } - $ok = $this->putFile($apiBase, $token, $branch, $encoded, $sha, - "chore: sync updates.xml{$vLabel} from {$current} [skip ci]"); + $ok = $this->putFile( + $apiBase, + $token, + $branch, + $encoded, + $sha, + "chore: sync updates.xml{$vLabel} from {$current} [skip ci]" + ); if ($ok) { $this->log('INFO', "Synced to {$branch}"); @@ -166,9 +174,14 @@ class UpdatesXmlSyncCli extends CliFramework return $resp['sha'] ?? null; } - private function putFile(string $apiBase, string $token, string $branch, - string $encoded, string $sha, string $msg): bool - { + private function putFile( + string $apiBase, + string $token, + string $branch, + string $encoded, + string $sha, + string $msg + ): bool { $resp = $this->apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [ 'content' => $encoded, 'sha' => $sha, @@ -194,8 +207,11 @@ class UpdatesXmlSyncCli extends CliFramework curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); if ($data !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, - json_encode($data, JSON_UNESCAPED_SLASHES)); + curl_setopt( + $ch, + CURLOPT_POSTFIELDS, + json_encode($data, JSON_UNESCAPED_SLASHES) + ); } $body = curl_exec($ch); diff --git a/cli/version_auto_bump.php b/cli/version_auto_bump.php index cc47925..b30d2fb 100644 --- a/cli/version_auto_bump.php +++ b/cli/version_auto_bump.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -82,8 +83,11 @@ class VersionAutoBumpCli extends CliFramework $shouldBump = true; if (!empty($watchPath)) { $root = realpath($path) ?: $path; + $cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd "; $diffOutput = trim((string) @shell_exec( - (PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --name-only HEAD~1 HEAD -- " . escapeshellarg($watchPath) . " 2>/dev/null" + $cdCmd . escapeshellarg($root) + . " && git diff --name-only HEAD~1 HEAD -- " + . escapeshellarg($watchPath) . " 2>/dev/null" )); if (empty($diffOutput)) { echo "No changes in {$watchPath} — skipping version bump\n"; @@ -148,28 +152,50 @@ class VersionAutoBumpCli extends CliFramework $root = realpath($path) ?: $path; // Check if anything changed - $diffStatus = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty")); + $cdPrefix = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd "; + $diffStatus = trim((string) @shell_exec( + $cdPrefix . escapeshellarg($root) + . " && git diff --quiet && git diff --cached --quiet" + . " 2>&1 && echo clean || echo dirty" + )); if ($diffStatus === 'clean') { echo "No version changes to commit\n"; return 0; } // Configure git - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\""); - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\""); + $cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd "; + $cdRoot = $cd . escapeshellarg($root); + @shell_exec( + $cdRoot . " && git config --local user.email" + . " \"gitea-actions[bot]@mokoconsulting.tech\"" + ); + @shell_exec( + $cdRoot . " && git config --local user.name" + . " \"gitea-actions[bot]\"" + ); if (!empty($repoUrl)) { - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl)); + @shell_exec( + $cdRoot . " && git remote set-url origin " + . escapeshellarg($repoUrl) + ); } - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A"); + @shell_exec($cdRoot . " && git add -A"); $commitMsg = $shouldBump ? "chore(version): auto-bump patch {$displayVersion} [skip ci]" : "chore(version): set {$stability} suffix {$displayVersion} [skip ci]"; - @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg($commitMsg) - . " --author=\"gitea-actions[bot] \""); + @shell_exec( + $cdRoot . " && git commit -m " . escapeshellarg($commitMsg) + . " --author=\"gitea-actions[bot]" + . " \"" + ); - $pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); + $pushResult = @shell_exec( + $cdRoot . " && git push origin " + . escapeshellarg($branch) . " 2>&1" + ); echo $pushResult ?? ''; echo "Bumped to {$displayVersion}\n"; diff --git a/cli/version_bump.php b/cli/version_bump.php index 6573ec1..6181c9a 100644 --- a/cli/version_bump.php +++ b/cli/version_bump.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -20,71 +21,233 @@ use MokoEnterprise\CliFramework; class VersionBumpCli extends CliFramework { - protected function configure(): void - { - $this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files'); - $this->addArgument('--path', 'Repository root', '.'); - $this->addArgument('--minor', 'Bump minor version', false); - $this->addArgument('--major', 'Bump major version', false); - } + protected function configure(): void + { + $this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--minor', 'Bump minor version', false); + $this->addArgument('--major', 'Bump major version', false); + } - protected function run(): int - { - $path = $this->getArgument('--path'); $type = 'patch'; - if ($this->getArgument('--minor')) $type = 'minor'; - if ($this->getArgument('--major')) $type = 'major'; - $root = realpath($path) ?: $path; - $mokoVersion = null; $mokoManifest = "{$root}/.mokogitea/manifest.xml"; $mokoContent = ''; - if (file_exists($mokoManifest)) { $mokoContent = file_get_contents($mokoManifest); if (preg_match('#(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?#', $mokoContent, $m)) { $mokoVersion = $m[1]; } } - $readmeVersion = null; $readme = "{$root}/README.md"; $readmeContent = ''; - if (file_exists($readme)) { $readmeContent = file_get_contents($readme); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) { $readmeVersion = $m[1]; } } - $manifestVersion = null; - $manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/src/packages/*/mokowaas.xml") ?: [], glob("{$root}/src/packages/*/*.xml") ?: [], glob("{$root}/*.xml") ?: []); - foreach ($manifestFiles as $xmlFile) { $xmlContent = file_get_contents($xmlFile); if (strpos($xmlContent, '') === false) { continue; } if (preg_match('#(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?#', $xmlContent, $xm)) { $candidate = $xm[1]; if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) { $manifestVersion = $candidate; } } } - $baseVersion = null; $candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]); - foreach ($candidates as $v) { if ($baseVersion === null || version_compare($v, $baseVersion, '>')) { $baseVersion = $v; } } - if ($baseVersion === null) { $this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML"); return 1; } - if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) { $this->log('ERROR', "Invalid version format: {$baseVersion}"); return 1; } - $major = (int)$parts[1]; $minor = (int)$parts[2]; $patch = (int)$parts[3]; - $old = sprintf('%02d.%02d.%02d', $major, $minor, $patch); - switch ($type) { case 'major': $major++; $minor = 0; $patch = 0; break; case 'minor': $minor++; $patch = 0; break; default: $patch++; if ($patch > 99) { $minor++; $patch = 0; } if ($minor > 99) { $major++; $minor = 0; } break; } - $newFull = sprintf('%02d.%02d.%02d', $major, $minor, $patch); - if (file_exists($mokoManifest) && !empty($mokoContent)) { $updated = preg_replace('#\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?#', "{$newFull}", $mokoContent, 1); if ($updated !== null) { file_put_contents($mokoManifest, $updated); } } - if (file_exists($readme) && !empty($readmeContent)) { $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $readmeContent, 1); if ($updated !== null) { file_put_contents($readme, $updated); } } - $updatedFiles = []; - foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $pattern) { - foreach (glob($pattern) ?: [] as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, '\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?#', "{$newFull}", $content); if ($newContent !== null && $newContent !== $content) { file_put_contents($xmlFile, $newContent); $updatedFiles[] = substr($xmlFile, strlen($root) + 1); } } - } - if (!empty($updatedFiles)) { fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n"); } - $packageJsonFile = "{$root}/package.json"; - if (file_exists($packageJsonFile)) { $pkgContent = file_get_contents($packageJsonFile); $updatedPkg = preg_replace('/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $newFull . '${2}', $pkgContent); if ($updatedPkg !== $pkgContent) { file_put_contents($packageJsonFile, $updatedPkg); fwrite(STDERR, "Updated package.json\n"); } } - $pyprojectFile = "{$root}/pyproject.toml"; - if (file_exists($pyprojectFile)) { $pyContent = file_get_contents($pyprojectFile); $updatedPy = preg_replace('/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $newFull . '${2}', $pyContent); if ($updatedPy !== $pyContent) { file_put_contents($pyprojectFile, $updatedPy); fwrite(STDERR, "Updated pyproject.toml\n"); } } - $changelogFile = "{$root}/CHANGELOG.md"; - if (file_exists($changelogFile)) { $clContent = file_get_contents($changelogFile); $updatedCl = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $clContent); if ($updatedCl !== null && $updatedCl !== $clContent) { file_put_contents($changelogFile, $updatedCl); fwrite(STDERR, "Updated CHANGELOG.md\n"); } } - $scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js']; - $excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude']; - $versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m'; - $directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS); - $filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) { if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) { return false; } return true; }); - $iterator = new RecursiveIteratorIterator($filter); - $genericUpdated = []; - foreach ($iterator as $fileInfo) { - if ($fileInfo->isDir()) { continue; } - $ext = strtolower($fileInfo->getExtension()); if (!in_array($ext, $scanExtensions, true)) { continue; } - $filePath = $fileInfo->getPathname(); $relPath = str_replace([$root . '/', $root . '\\'], '', $filePath); - if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) { continue; } - if (in_array($relPath, $updatedFiles ?? [], true)) { continue; } - if (strpos($relPath, '.mokogitea/manifest.xml') !== false) { continue; } - $content = @file_get_contents($filePath); if ($content === false) { continue; } - if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) { continue; } - $updated = preg_replace($versionPattern, '${1}' . $newFull, $content); - if ($updated !== null && $updated !== $content) { file_put_contents($filePath, $updated); $genericUpdated[] = $relPath; } - } - if (!empty($genericUpdated)) { fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n"); } - echo "{$old} -> {$newFull}\n"; - return 0; - } + protected function run(): int + { + $path = $this->getArgument('--path'); + $type = 'patch'; + if ($this->getArgument('--minor')) { + $type = 'minor'; + } + if ($this->getArgument('--major')) { + $type = 'major'; + } + $root = realpath($path) ?: $path; + $mokoVersion = null; + $mokoManifest = "{$root}/.mokogitea/manifest.xml"; + $mokoContent = ''; + if (file_exists($mokoManifest)) { + $mokoContent = file_get_contents($mokoManifest); + if (preg_match('#(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?#', $mokoContent, $m)) { + $mokoVersion = $m[1]; + } + } + $readmeVersion = null; + $readme = "{$root}/README.md"; + $readmeContent = ''; + if (file_exists($readme)) { + $readmeContent = file_get_contents($readme); + if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) { + $readmeVersion = $m[1]; + } + } + $manifestVersion = null; + $manifestFiles = array_merge( + glob("{$root}/src/pkg_*.xml") ?: [], + glob("{$root}/src/*.xml") ?: [], + glob("{$root}/src/packages/*/mokowaas.xml") ?: [], + glob("{$root}/src/packages/*/*.xml") ?: [], + glob("{$root}/*.xml") ?: [] + ); + foreach ($manifestFiles as $xmlFile) { + $xmlContent = file_get_contents($xmlFile); + if (strpos($xmlContent, '') === false) { + continue; + } if (preg_match('#(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?#', $xmlContent, $xm)) { + $candidate = $xm[1]; + if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) { + $manifestVersion = $candidate; + } + } + } + $baseVersion = null; + $candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]); + foreach ($candidates as $v) { + if ($baseVersion === null || version_compare($v, $baseVersion, '>')) { + $baseVersion = $v; + } + } + if ($baseVersion === null) { + $this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML"); + return 1; + } + if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) { + $this->log('ERROR', "Invalid version format: {$baseVersion}"); + return 1; + } + $major = (int)$parts[1]; + $minor = (int)$parts[2]; + $patch = (int)$parts[3]; + $old = sprintf('%02d.%02d.%02d', $major, $minor, $patch); + switch ($type) { + case 'major': + $major++; + $minor = 0; + $patch = 0; + break; + case 'minor': + $minor++; + $patch = 0; + break; + default: + $patch++; + if ($patch > 99) { + $minor++; + $patch = 0; + } if ($minor > 99) { + $major++; + $minor = 0; + } + break; + } + $newFull = sprintf('%02d.%02d.%02d', $major, $minor, $patch); + if (file_exists($mokoManifest) && !empty($mokoContent)) { + $pattern = '#\d{2}\.\d{2}\.\d{2}' + . '(?:(?:-(?:dev|alpha|beta|rc))+)?#'; + $updated = preg_replace( + $pattern, + "{$newFull}", + $mokoContent, + 1 + ); + if ($updated !== null) { + file_put_contents($mokoManifest, $updated); + } + } + if (file_exists($readme) && !empty($readmeContent)) { + $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $readmeContent, 1); + if ($updated !== null) { + file_put_contents($readme, $updated); + } + } + $updatedFiles = []; + foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $pattern) { + foreach (glob($pattern) ?: [] as $xmlFile) { + $content = file_get_contents($xmlFile); + if (strpos($content, '#'; + $newContent = preg_replace( + $xmlPattern, + "{$newFull}", + $content + ); + if ($newContent !== null && $newContent !== $content) { + file_put_contents($xmlFile, $newContent); + $updatedFiles[] = substr($xmlFile, strlen($root) + 1); + } + } + } + if (!empty($updatedFiles)) { + fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n"); + } + $packageJsonFile = "{$root}/package.json"; + if (file_exists($packageJsonFile)) { + $pkgContent = file_get_contents($packageJsonFile); + $pkgPattern = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}' + . '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m'; + $updatedPkg = preg_replace( + $pkgPattern, + '${1}' . $newFull . '${2}', + $pkgContent + ); + if ($updatedPkg !== $pkgContent) { + file_put_contents($packageJsonFile, $updatedPkg); + fwrite(STDERR, "Updated package.json\n"); + } + } + $pyprojectFile = "{$root}/pyproject.toml"; + if (file_exists($pyprojectFile)) { + $pyContent = file_get_contents($pyprojectFile); + $pyPattern = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}' + . '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m'; + $updatedPy = preg_replace( + $pyPattern, + '${1}' . $newFull . '${2}', + $pyContent + ); + if ($updatedPy !== $pyContent) { + file_put_contents($pyprojectFile, $updatedPy); + fwrite(STDERR, "Updated pyproject.toml\n"); + } + } + $changelogFile = "{$root}/CHANGELOG.md"; + if (file_exists($changelogFile)) { + $clContent = file_get_contents($changelogFile); + $updatedCl = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $clContent); + if ($updatedCl !== null && $updatedCl !== $clContent) { + file_put_contents($changelogFile, $updatedCl); + fwrite(STDERR, "Updated CHANGELOG.md\n"); + } + } + $scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js']; + $excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude']; + $versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m'; + $directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS); + $filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) { + if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) { + return false; + } return true; + }); + $iterator = new RecursiveIteratorIterator($filter); + $genericUpdated = []; + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir()) { + continue; + } + $ext = strtolower($fileInfo->getExtension()); + if (!in_array($ext, $scanExtensions, true)) { + continue; + } + $filePath = $fileInfo->getPathname(); + $relPath = str_replace([$root . '/', $root . '\\'], '', $filePath); + if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) { + continue; + } + if (in_array($relPath, $updatedFiles ?? [], true)) { + continue; + } + if (strpos($relPath, '.mokogitea/manifest.xml') !== false) { + continue; + } + $content = @file_get_contents($filePath); + if ($content === false) { + continue; + } + if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) { + continue; + } + $updated = preg_replace($versionPattern, '${1}' . $newFull, $content); + if ($updated !== null && $updated !== $content) { + file_put_contents($filePath, $updated); + $genericUpdated[] = $relPath; + } + } + if (!empty($genericUpdated)) { + fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n"); + } + echo "{$old} -> {$newFull}\n"; + return 0; + } } $app = new VersionBumpCli(); diff --git a/cli/version_bump_remote.php b/cli/version_bump_remote.php index 2feb6e6..ad35739 100644 --- a/cli/version_bump_remote.php +++ b/cli/version_bump_remote.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -20,95 +21,180 @@ use MokoEnterprise\CliFramework; class VersionBumpRemoteCli extends CliFramework { - protected function configure(): void - { - $this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API'); - $this->addArgument('--path', 'Repository root', '.'); - $this->addArgument('--branch', 'Target branch to bump (required)', null); - $this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor'); - $this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null); - $this->addArgument('--api-base', 'Gitea API base URL for the repo', null); - $this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false); - $this->addArgument('--repo', 'Repository path (owner/repo)', null); - $this->addArgument('--gitea-url', 'Gitea instance URL', null); - } + protected function configure(): void + { + $this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--branch', 'Target branch to bump (required)', null); + $this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor'); + $this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null); + $this->addArgument('--api-base', 'Gitea API base URL for the repo', null); + $this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false); + $this->addArgument('--repo', 'Repository path (owner/repo)', null); + $this->addArgument('--gitea-url', 'Gitea instance URL', null); + } - protected function run(): int - { - $path = $this->getArgument('--path'); $branch = $this->getArgument('--branch'); - $bumpType = $this->getArgument('--bump'); $token = $this->getArgument('--token'); - $apiBase = $this->getArgument('--api-base'); $noChangelog = (bool) $this->getArgument('--no-changelog'); - $repo = $this->getArgument('--repo'); $giteaUrl = $this->getArgument('--gitea-url'); - if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; - if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; - if ($apiBase === null && $repo !== null) { $apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo; } - if ($branch === null || $token === null || $apiBase === null) { - $this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]"); - return 1; - } - $root = realpath($path) ?: $path; - $version = null; $manifestFile = null; - foreach (["{$root}/src", $root] as $dir) { - if (!is_dir($dir)) continue; - foreach (glob("{$dir}/*.xml") ?: [] as $f) { - $xml = file_get_contents($f); - if (strpos($xml, '') !== false) { - if (preg_match('|(\d{2}\.\d{2}\.\d{2})|', $xml, $m)) { - if ($version === null || version_compare($m[1], $version, '>')) { $version = $m[1]; $manifestFile = basename($f); } - } - } - } - } - if ($version === null) { $this->log('ERROR', "No version found in manifest XML"); return 1; } - if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) { $this->log('ERROR', "Invalid version format: {$version}"); return 1; } - $major = (int)$parts[1]; $minor = (int)$parts[2]; $patch = (int)$parts[3]; - switch ($bumpType) { case 'major': $major++; $minor = 0; $patch = 0; break; case 'minor': $minor++; $patch = 0; break; default: $patch++; break; } - $nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); - echo "{$version} -> {$nextVersion} ({$branch})\n"; + protected function run(): int + { + $path = $this->getArgument('--path'); + $branch = $this->getArgument('--branch'); + $bumpType = $this->getArgument('--bump'); + $token = $this->getArgument('--token'); + $apiBase = $this->getArgument('--api-base'); + $noChangelog = (bool) $this->getArgument('--no-changelog'); + $repo = $this->getArgument('--repo'); + $giteaUrl = $this->getArgument('--gitea-url'); + if ($token === null) { + $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; + } + if ($giteaUrl === null) { + $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; + } + if ($apiBase === null && $repo !== null) { + $apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo; + } + if ($branch === null || $token === null || $apiBase === null) { + $this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]"); + return 1; + } + $root = realpath($path) ?: $path; + $version = null; + $manifestFile = null; + foreach (["{$root}/src", $root] as $dir) { + if (!is_dir($dir)) { + continue; + } + foreach (glob("{$dir}/*.xml") ?: [] as $f) { + $xml = file_get_contents($f); + if (strpos($xml, '') !== false) { + if (preg_match('|(\d{2}\.\d{2}\.\d{2})|', $xml, $m)) { + if ($version === null || version_compare($m[1], $version, '>')) { + $version = $m[1]; + $manifestFile = basename($f); + } + } + } + } + } + if ($version === null) { + $this->log('ERROR', "No version found in manifest XML"); + return 1; + } + if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) { + $this->log('ERROR', "Invalid version format: {$version}"); + return 1; + } + $major = (int)$parts[1]; + $minor = (int)$parts[2]; + $patch = (int)$parts[3]; + switch ($bumpType) { + case 'major': + $major++; + $minor = 0; + $patch = 0; + break; + case 'minor': + $minor++; + $patch = 0; + break; + default: + $patch++; + break; + } + $nextVersion = sprintf('%02d.%02d.%02d', $major, $minor, $patch); + echo "{$version} -> {$nextVersion} ({$branch})\n"; - $manifestPaths = []; - if ($manifestFile !== null) { $manifestPaths[] = "src/{$manifestFile}"; } - $manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']); - $manifestUpdated = false; - foreach ($manifestPaths as $mPath) { - $result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string { return str_replace("{$version}", "{$nextVersion}", $content); }, "chore(version): bump {$version} -> {$nextVersion} [skip ci]"); - if ($result) { $manifestUpdated = true; break; } - } - if (!$manifestUpdated) { $this->log('WARN', "could not update manifest on {$branch}"); } - if (!$noChangelog) { - $this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string { - $content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content); - if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) { - $marker = "## [{$version}]"; - if (strpos($content, $marker) !== false) { $content = str_replace($marker, "## [{$nextVersion}] - Unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n\n" . $marker, $content); } - } - return $content; - }, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]"); - } - return 0; - } + $manifestPaths = []; + if ($manifestFile !== null) { + $manifestPaths[] = "src/{$manifestFile}"; + } + $manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']); + $manifestUpdated = false; + foreach ($manifestPaths as $mPath) { + $result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string { + return str_replace("{$version}", "{$nextVersion}", $content); + }, "chore(version): bump {$version} -> {$nextVersion} [skip ci]"); + if ($result) { + $manifestUpdated = true; + break; + } + } + if (!$manifestUpdated) { + $this->log('WARN', "could not update manifest on {$branch}"); + } + if (!$noChangelog) { + $this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string { + $content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content); + if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) { + $marker = "## [{$version}]"; + if (strpos($content, $marker) !== false) { + $header = "## [{$nextVersion}] - Unreleased\n\n" + . "### Added\n\n### Changed\n\n" + . "### Fixed\n\n"; + $content = str_replace( + $marker, + $header . $marker, + $content + ); + } + } + return $content; + }, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]"); + } + return 0; + } - private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array - { - $ch = curl_init($url); - curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'Content-Type: application/json'], CURLOPT_CUSTOMREQUEST => $method, CURLOPT_TIMEOUT => 30]); - if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } - $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - if ($httpCode >= 400 || $response === false) { return null; } - return json_decode($response, true) ?: []; - } + private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + "Authorization: token {$token}", + 'Content-Type: application/json', + ], + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_TIMEOUT => 30, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($httpCode >= 400 || $response === false) { + return null; + } + return json_decode($response, true) ?: []; + } - private function updateRemoteFile(string $apiBase, string $token, string $filePath, string $branch, callable $transform, string $commitMessage): bool - { - $file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token); - if ($file === null || !isset($file['sha']) || !isset($file['content'])) { return false; } - $content = base64_decode($file['content']); $newContent = $transform($content); - if ($newContent === $content) { $this->log('INFO', "{$filePath}: no changes needed"); return true; } - $payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]); - $result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload); - if ($result === null) { $this->log('ERROR', "{$filePath}: failed to update"); return false; } - echo " {$filePath}: updated on {$branch}\n"; return true; - } + private function updateRemoteFile( + string $apiBase, + string $token, + string $filePath, + string $branch, + callable $transform, + string $commitMessage + ): bool { + $file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token); + if ($file === null || !isset($file['sha']) || !isset($file['content'])) { + return false; + } + $content = base64_decode($file['content']); + $newContent = $transform($content); + if ($newContent === $content) { + $this->log('INFO', "{$filePath}: no changes needed"); + return true; + } + $payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]); + $result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload); + if ($result === null) { + $this->log('ERROR', "{$filePath}: failed to update"); + return false; + } + echo " {$filePath}: updated on {$branch}\n"; + return true; + } } $app = new VersionBumpRemoteCli(); diff --git a/cli/version_check.php b/cli/version_check.php index 79aebb8..052db3d 100644 --- a/cli/version_check.php +++ b/cli/version_check.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -21,57 +22,181 @@ use MokoEnterprise\CliFramework; class VersionCheckCli extends CliFramework { - protected function configure(): void - { - $this->setDescription('Validate version consistency across README, manifests, and sub-packages'); - $this->addArgument('--path', 'Repository root', '.'); - $this->addArgument('--strict', 'Exit 1 on mismatch', false); - $this->addArgument('--fix', 'Fix mismatches to highest version', false); - } + protected function configure(): void + { + $this->setDescription('Validate version consistency across README, manifests, and sub-packages'); + $this->addArgument('--path', 'Repository root', '.'); + $this->addArgument('--strict', 'Exit 1 on mismatch', false); + $this->addArgument('--fix', 'Fix mismatches to highest version', false); + } - protected function run(): int - { - $path = $this->getArgument('--path'); $strict = (bool) $this->getArgument('--strict'); $fix = (bool) $this->getArgument('--fix'); - $root = realpath($path) ?: $path; $errors = 0; $versions = []; - $mokoManifest = "{$root}/.mokogitea/manifest.xml"; - if (file_exists($mokoManifest)) { $xml = @simplexml_load_file($mokoManifest); if ($xml !== false) { $v = (string)($xml->identity->version ?? ''); $base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v); if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) { $versions['.mokogitea/manifest.xml'] = $base; } } } - $readme = "{$root}/README.md"; - if (file_exists($readme)) { $content = file_get_contents($readme); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { $versions['README.md'] = $m[1]; } } - $changelog = "{$root}/CHANGELOG.md"; - if (file_exists($changelog)) { $content = file_get_contents($changelog); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { $versions['CHANGELOG.md'] = $m[1]; } } - $packageJson = "{$root}/package.json"; - if (file_exists($packageJson)) { $pkg = json_decode(file_get_contents($packageJson), true); if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) { $versions['package.json'] = $pkg['version']; } } - $pyproject = "{$root}/pyproject.toml"; - if (file_exists($pyproject)) { $content = file_get_contents($pyproject); if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) { $versions['pyproject.toml'] = $m[1]; } } - foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) { - foreach (glob($glob) ?: [] as $file) { - if (basename($file) === 'updates.xml') continue; - $xmlContent = file_get_contents($file); if (strpos($xmlContent, '(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?#', $xmlContent, $xm)) { $relPath = str_replace([$root . '/', $root . '\\'], '', $file); $versions[$relPath] = $xm[1]; } - } - } - if (empty($versions)) { $this->log('ERROR', "No version sources found"); return 1; } - $uniqueVersions = array_unique(array_values($versions)); $highestVersion = '00.00.00'; - foreach ($versions as $v) { if (version_compare($v, $highestVersion, '>')) { $highestVersion = $v; } } - echo "=== Version Consistency Check ===\n"; - foreach ($versions as $source => $ver) { $status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH'; if ($status === 'MISMATCH') $errors++; echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})"); } - if (count($uniqueVersions) === 1) { echo "\nAll {$ver} -- consistent.\n"; } - else { - echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n"; - if ($fix) { - echo "\n=== Fixing mismatches to {$highestVersion} ===\n"; - if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) { $content = file_get_contents($readme); $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '${1}' . $highestVersion, $content, 1); if ($updated !== null) { file_put_contents($readme, $updated); } echo " Fixed: README.md -> {$highestVersion}\n"; } - if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) { $content = file_get_contents($mokoManifest); $updated = preg_replace('#\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?#', "{$highestVersion}", $content); if ($updated !== null) { file_put_contents($mokoManifest, $updated); } echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n"; } - if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) { $content = file_get_contents($changelog); $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $highestVersion, $content); if ($updated !== null) { file_put_contents($changelog, $updated); } echo " Fixed: CHANGELOG.md -> {$highestVersion}\n"; } - if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) { $content = file_get_contents($packageJson); $updated = preg_replace('/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $highestVersion . '${2}', $content); if ($updated !== null) { file_put_contents($packageJson, $updated); } echo " Fixed: package.json -> {$highestVersion}\n"; } - if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) { $content = file_get_contents($pyproject); $updated = preg_replace('/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $highestVersion . '${2}', $content); if ($updated !== null) { file_put_contents($pyproject, $updated); } echo " Fixed: pyproject.toml -> {$highestVersion}\n"; } - foreach ($versions as $source => $ver) { if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) continue; if ($ver === $highestVersion) continue; $file = "{$root}/{$source}"; if (!file_exists($file)) continue; $content = file_get_contents($file); $updated = preg_replace('#[^<]*#', "{$highestVersion}", $content); if ($updated !== null) { file_put_contents($file, $updated); } echo " Fixed: {$source} -> {$highestVersion}\n"; } - echo "Done.\n"; - } - } - if ($strict && $errors > 0) { return 1; } - return 0; - } + protected function run(): int + { + $path = $this->getArgument('--path'); + $strict = (bool) $this->getArgument('--strict'); + $fix = (bool) $this->getArgument('--fix'); + $root = realpath($path) ?: $path; + $errors = 0; + $versions = []; + $mokoManifest = "{$root}/.mokogitea/manifest.xml"; + if (file_exists($mokoManifest)) { + $xml = @simplexml_load_file($mokoManifest); + if ($xml !== false) { + $v = (string)($xml->identity->version ?? ''); + $base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v); + if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) { + $versions['.mokogitea/manifest.xml'] = $base; + } + } + } + $readme = "{$root}/README.md"; + if (file_exists($readme)) { + $content = file_get_contents($readme); + if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { + $versions['README.md'] = $m[1]; + } + } + $changelog = "{$root}/CHANGELOG.md"; + if (file_exists($changelog)) { + $content = file_get_contents($changelog); + if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { + $versions['CHANGELOG.md'] = $m[1]; + } + } + $packageJson = "{$root}/package.json"; + if (file_exists($packageJson)) { + $pkg = json_decode(file_get_contents($packageJson), true); + if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) { + $versions['package.json'] = $pkg['version']; + } + } + $pyproject = "{$root}/pyproject.toml"; + if (file_exists($pyproject)) { + $content = file_get_contents($pyproject); + if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) { + $versions['pyproject.toml'] = $m[1]; + } + } + foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) { + foreach (glob($glob) ?: [] as $file) { + if (basename($file) === 'updates.xml') { + continue; + } + $xmlContent = file_get_contents($file); + if (strpos($xmlContent, '(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?#', $xmlContent, $xm)) { + $relPath = str_replace([$root . '/', $root . '\\'], '', $file); + $versions[$relPath] = $xm[1]; + } + } + } + if (empty($versions)) { + $this->log('ERROR', "No version sources found"); + return 1; + } + $uniqueVersions = array_unique(array_values($versions)); + $highestVersion = '00.00.00'; + foreach ($versions as $v) { + if (version_compare($v, $highestVersion, '>')) { + $highestVersion = $v; + } + } + echo "=== Version Consistency Check ===\n"; + foreach ($versions as $source => $ver) { + $status = ($ver === $highestVersion) ? 'OK' : 'MISMATCH'; + if ($status === 'MISMATCH') { + $errors++; + } echo sprintf(" %-50s %s %s\n", $source, $ver, $status === 'OK' ? '' : "** MISMATCH (expected {$highestVersion})"); + } + if (count($uniqueVersions) === 1) { + echo "\nAll {$ver} -- consistent.\n"; + } else { + echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n"; + if ($fix) { + echo "\n=== Fixing mismatches to {$highestVersion} ===\n"; + if (isset($versions['README.md']) && $versions['README.md'] !== $highestVersion) { + $content = file_get_contents($readme); + $updated = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '${1}' . $highestVersion, $content, 1); + if ($updated !== null) { + file_put_contents($readme, $updated); + } echo " Fixed: README.md -> {$highestVersion}\n"; + } + if (isset($versions['.mokogitea/manifest.xml']) && $versions['.mokogitea/manifest.xml'] !== $highestVersion) { + $content = file_get_contents($mokoManifest); + $vPat = '#\d{2}\.\d{2}\.\d{2}' + . '(?:(?:-(?:dev|alpha|beta|rc))+)?#'; + $updated = preg_replace( + $vPat, + "{$highestVersion}", + $content + ); + if ($updated !== null) { + file_put_contents($mokoManifest, $updated); + } echo " Fixed: .mokogitea/manifest.xml -> {$highestVersion}\n"; + } + if (isset($versions['CHANGELOG.md']) && $versions['CHANGELOG.md'] !== $highestVersion) { + $content = file_get_contents($changelog); + $clPat = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}' + . '(?:(?:-(?:dev|alpha|beta|rc))+)?/m'; + $updated = preg_replace( + $clPat, + '${1}' . $highestVersion, + $content + ); + if ($updated !== null) { + file_put_contents($changelog, $updated); + } echo " Fixed: CHANGELOG.md -> {$highestVersion}\n"; + } + if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) { + $content = file_get_contents($packageJson); + $pkPat = '/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}' + . '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m'; + $updated = preg_replace( + $pkPat, + '${1}' . $highestVersion . '${2}', + $content + ); + if ($updated !== null) { + file_put_contents($packageJson, $updated); + } echo " Fixed: package.json -> {$highestVersion}\n"; + } + if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) { + $content = file_get_contents($pyproject); + $pyPat = '/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}' + . '(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m'; + $updated = preg_replace( + $pyPat, + '${1}' . $highestVersion . '${2}', + $content + ); + if ($updated !== null) { + file_put_contents($pyproject, $updated); + } echo " Fixed: pyproject.toml -> {$highestVersion}\n"; + } + foreach ($versions as $source => $ver) { + if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) { + continue; + } if ($ver === $highestVersion) { + continue; + } $file = "{$root}/{$source}"; + if (!file_exists($file)) { + continue; + } $content = file_get_contents($file); + $updated = preg_replace('#[^<]*#', "{$highestVersion}", $content); + if ($updated !== null) { + file_put_contents($file, $updated); + } echo " Fixed: {$source} -> {$highestVersion}\n"; + } + echo "Done.\n"; + } + } + if ($strict && $errors > 0) { + return 1; + } + return 0; + } } $app = new VersionCheckCli(); diff --git a/cli/version_read.php b/cli/version_read.php index b50d505..fa2865b 100644 --- a/cli/version_read.php +++ b/cli/version_read.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/version_set_platform.php b/cli/version_set_platform.php index af3694a..0bce98e 100644 --- a/cli/version_set_platform.php +++ b/cli/version_set_platform.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later diff --git a/cli/wiki_sync.php b/cli/wiki_sync.php index c64e453..bef62c9 100644 --- a/cli/wiki_sync.php +++ b/cli/wiki_sync.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later @@ -204,7 +205,9 @@ class WikiSyncCli extends CliFramework ]; $ctx = stream_context_create($opts); $result = @file_get_contents($url, false, $ctx); - if ($result === false) return null; + if ($result === false) { + return null; + } $data = json_decode($result, true); return is_array($data) ? $data : null; } @@ -232,7 +235,9 @@ class WikiSyncCli extends CliFramework ]; $ctx = stream_context_create($opts); $result = @file_get_contents($url, false, $ctx); - if ($result === false) return null; + if ($result === false) { + return null; + } $data = json_decode($result, true); return is_array($data) ? $data : null; } diff --git a/deploy/backup-before-deploy.php b/deploy/backup-before-deploy.php index 6f6af33..7d2c7c9 100644 --- a/deploy/backup-before-deploy.php +++ b/deploy/backup-before-deploy.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -23,171 +24,171 @@ use MokoEnterprise\CliFramework; class BackupBeforeDeployCli extends CliFramework { - private const JOOMLA_DIRS = [ - 'administrator/components', - 'administrator/language', - 'administrator/modules', - 'administrator/templates', - 'components', - 'language', - 'layouts', - 'libraries', - 'media', - 'modules', - 'plugins', - 'templates', - ]; + private const JOOMLA_DIRS = [ + 'administrator/components', + 'administrator/language', + 'administrator/modules', + 'administrator/templates', + 'components', + 'language', + 'layouts', + 'libraries', + 'media', + 'modules', + 'plugins', + 'templates', + ]; - protected function configure(): void - { - $this->setDescription('Snapshot Joomla directories before deployment for rollback capability'); - $this->addArgument('--config', 'Path to sftp-config.json', ''); - $this->addArgument('--output', 'Local output directory for snapshot', ''); - } + protected function configure(): void + { + $this->setDescription('Snapshot Joomla directories before deployment for rollback capability'); + $this->addArgument('--config', 'Path to sftp-config.json', ''); + $this->addArgument('--output', 'Local output directory for snapshot', ''); + } - protected function run(): int - { - $configPath = $this->getArgument('--config'); - $outputDir = $this->getArgument('--output'); + protected function run(): int + { + $configPath = $this->getArgument('--config'); + $outputDir = $this->getArgument('--output'); - if ($configPath === '') { - $this->log('ERROR', 'Usage: backup-before-deploy.php --config [--output ] [--verbose]'); - return 1; - } + if ($configPath === '') { + $this->log('ERROR', 'Usage: backup-before-deploy.php --config [--output ] [--verbose]'); + return 1; + } - if ($outputDir === '') { - $outputDir = '/tmp/moko-snapshot-' . date('Ymd-His'); - } + if ($outputDir === '') { + $outputDir = '/tmp/moko-snapshot-' . date('Ymd-His'); + } - $config = $this->loadConfig($configPath); - if ($config === null) { - return 1; - } + $config = $this->loadConfig($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'] ?? ''; + $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; - } + if ($host === '' || $user === '' || $remotePath === '') { + $this->log('ERROR', 'Config must contain host, user, and remote_path.'); + return 1; + } - // Create output directory - if (!is_dir($outputDir)) { - if (!mkdir($outputDir, 0755, true)) { - $this->log('ERROR', "Could not create output directory: {$outputDir}"); - return 1; - } - } + // Create output directory + if (!is_dir($outputDir)) { + if (!mkdir($outputDir, 0755, true)) { + $this->log('ERROR', "Could not create output directory: {$outputDir}"); + return 1; + } + } - $this->log('INFO', 'Starting pre-deploy snapshot...'); - $this->log('INFO', "Source: {$user}@{$host}:{$remotePath}"); - $this->log('INFO', "Output: {$outputDir}"); + $this->log('INFO', 'Starting pre-deploy snapshot...'); + $this->log('INFO', "Source: {$user}@{$host}:{$remotePath}"); + $this->log('INFO', "Output: {$outputDir}"); - $failed = 0; + $failed = 0; - foreach (self::JOOMLA_DIRS as $dir) { - $remoteSource = "{$remotePath}/{$dir}/"; - $localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/'; + foreach (self::JOOMLA_DIRS as $dir) { + $remoteSource = "{$remotePath}/{$dir}/"; + $localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/'; - // Ensure local subdirectory exists - if (!is_dir($localTarget)) { - mkdir($localTarget, 0755, true); - } + // Ensure local subdirectory exists + if (!is_dir($localTarget)) { + mkdir($localTarget, 0755, true); + } - $sshCmd = "ssh -p {$port}"; - if ($sshKey !== '') { - $sshCmd .= " -i " . escapeshellarg($sshKey); - } + $sshCmd = "ssh -p {$port}"; + if ($sshKey !== '') { + $sshCmd .= " -i " . escapeshellarg($sshKey); + } - $cmd = $this->buildRsyncCommand( - $sshCmd, - "{$user}@{$host}:{$remoteSource}", - $localTarget - ); + $cmd = $this->buildRsyncCommand( + $sshCmd, + "{$user}@{$host}:{$remoteSource}", + $localTarget + ); - $this->log('INFO', "Downloading: {$dir}"); - if ($this->verbose) { - $this->log('INFO', "CMD: {$cmd}"); - } + $this->log('INFO', "Downloading: {$dir}"); + if ($this->verbose) { + $this->log('INFO', "CMD: {$cmd}"); + } - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); + $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('ERROR', " {$line}"); - } - $failed++; - } else { - if ($this->verbose) { - foreach ($output as $line) { - $this->log('INFO', " {$line}"); - } - } - } - } + if ($exitCode !== 0) { + $this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log('ERROR', " {$line}"); + } + $failed++; + } else { + if ($this->verbose) { + foreach ($output as $line) { + $this->log('INFO', " {$line}"); + } + } + } + } - if ($failed > 0) { - $this->log('ERROR', "Snapshot completed with {$failed} error(s)."); - return 1; - } + if ($failed > 0) { + $this->log('ERROR', "Snapshot completed with {$failed} error(s)."); + return 1; + } - $this->log('INFO', ''); - $this->log('INFO', 'Snapshot completed successfully.'); - $this->log('INFO', "SNAPSHOT_PATH={$outputDir}"); - $this->log('INFO', ''); - $this->log('INFO', 'To rollback, run:'); - $this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}"); + $this->log('INFO', ''); + $this->log('INFO', 'Snapshot completed successfully.'); + $this->log('INFO', "SNAPSHOT_PATH={$outputDir}"); + $this->log('INFO', ''); + $this->log('INFO', 'To rollback, run:'); + $this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}"); - return 0; - } + return 0; + } - private function loadConfig(string $path): ?array - { - if (!is_file($path)) { - $this->log('ERROR', "Config file not found: {$path}"); - 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; - } + $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); + // 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; - } + if (!is_array($config)) { + $this->log('ERROR', 'Invalid JSON in config file.'); + return null; + } - return $config; - } + return $config; + } - private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string - { - $parts = ['rsync', '-rlptz', '--exclude=configuration.php']; + private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string + { + $parts = ['rsync', '-rlptz', '--exclude=configuration.php']; - if ($this->verbose) { - $parts[] = '-v'; - } + if ($this->verbose) { + $parts[] = '-v'; + } - $parts[] = '-e'; - $parts[] = escapeshellarg($sshCmd); - $parts[] = escapeshellarg($source); - $parts[] = escapeshellarg($dest); + $parts[] = '-e'; + $parts[] = escapeshellarg($sshCmd); + $parts[] = escapeshellarg($source); + $parts[] = escapeshellarg($dest); - return implode(' ', $parts); - } + return implode(' ', $parts); + } } $app = new BackupBeforeDeployCli(); diff --git a/deploy/deploy-dolibarr.php b/deploy/deploy-dolibarr.php index 494b761..eba2f4e 100644 --- a/deploy/deploy-dolibarr.php +++ b/deploy/deploy-dolibarr.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -23,258 +24,258 @@ use MokoEnterprise\CliFramework; class DeployDolibarrCli extends CliFramework { - private string $source = ''; + private string $source = ''; - private const MODULE_DIRS = [ - 'core/modules', - 'class', - 'lib', - 'sql', - 'langs', - 'css', - 'js', - 'img', - ]; + private const MODULE_DIRS = [ + 'core/modules', + 'class', + 'lib', + 'sql', + 'langs', + 'css', + 'js', + 'img', + ]; - private const EXCLUDES = [ - '.git/', - 'vendor/', - 'tests/', - 'node_modules/', - ]; + private const EXCLUDES = [ + '.git/', + 'vendor/', + 'tests/', + 'node_modules/', + ]; - protected function configure(): void - { - $this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync'); - $this->addArgument('--source', 'Local source directory', ''); - $this->addArgument('--config', 'Path to sftp-config.json', ''); - } + protected function configure(): void + { + $this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync'); + $this->addArgument('--source', 'Local source directory', ''); + $this->addArgument('--config', 'Path to sftp-config.json', ''); + } - protected function run(): int - { - $configPath = $this->getArgument('--config'); - $this->source = $this->getArgument('--source'); + protected function run(): int + { + $configPath = $this->getArgument('--config'); + $this->source = $this->getArgument('--source'); - if ($configPath === '' || $this->source === '') { - $this->log('ERROR', 'Usage: deploy-dolibarr.php --source --config [--dry-run] [--verbose]'); - return 1; - } + if ($configPath === '' || $this->source === '') { + $this->log('ERROR', '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; - } + 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; - } + $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($configPath); - if ($config === null) { - return 1; - } + $config = $this->loadConfig($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'] ?? ''; + $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; - } + if ($host === '' || $user === '' || $remotePath === '') { + $this->log('ERROR', 'Config must contain host, user, and remote_path.'); + return 1; + } - $remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}"; + $remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}"; - $this->log('INFO', "Deploying Dolibarr module: {$moduleName}"); - $this->log('INFO', "Source: {$this->source}"); - $this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}"); + $this->log('INFO', "Deploying Dolibarr module: {$moduleName}"); + $this->log('INFO', "Source: {$this->source}"); + $this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}"); - if ($this->dryRun) { - $this->log('INFO', '*** DRY RUN — no changes will be made ***'); - } + if ($this->dryRun) { + $this->log('INFO', '*** DRY RUN — no changes will be made ***'); + } - $failed = 0; + $failed = 0; - // Deploy subdirectories - foreach (self::MODULE_DIRS as $dir) { - $localDir = rtrim($this->source, '/\\') . '/' . $dir . '/'; + // Deploy subdirectories + foreach (self::MODULE_DIRS as $dir) { + $localDir = rtrim($this->source, '/\\') . '/' . $dir . '/'; - if (!is_dir($localDir)) { - if ($this->verbose) { - $this->log('INFO', "SKIP: {$dir} (not present in source)"); - } - continue; - } + if (!is_dir($localDir)) { + if ($this->verbose) { + $this->log('INFO', "SKIP: {$dir} (not present in source)"); + } + continue; + } - $remoteTarget = "{$remoteBase}/{$dir}/"; - $result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey); + $remoteTarget = "{$remoteBase}/{$dir}/"; + $result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey); - if (!$result) { - $failed++; - } - } + if (!$result) { + $failed++; + } + } - // Deploy root PHP files - $rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php'); - if (!empty($rootPhpFiles)) { - $this->log('INFO', 'Syncing root PHP files...'); - $sourceRoot = rtrim($this->source, '/\\') . '/'; - $remoteTarget = "{$remoteBase}/"; + // Deploy root PHP files + $rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php'); + if (!empty($rootPhpFiles)) { + $this->log('INFO', 'Syncing root PHP files...'); + $sourceRoot = rtrim($this->source, '/\\') . '/'; + $remoteTarget = "{$remoteBase}/"; - $sshCmd = "ssh -p {$port}"; - if ($sshKey !== '') { - $sshCmd .= " -i " . escapeshellarg($sshKey); - } + $sshCmd = "ssh -p {$port}"; + if ($sshKey !== '') { + $sshCmd .= " -i " . escapeshellarg($sshKey); + } - $cmd = $this->buildRsyncCommand( - $sshCmd, - $sourceRoot, - "{$user}@{$host}:{$remoteTarget}", - ['--include=*.php', '--exclude=*/', '--exclude=.*'] - ); + $cmd = $this->buildRsyncCommand( + $sshCmd, + $sourceRoot, + "{$user}@{$host}:{$remoteTarget}", + ['--include=*.php', '--exclude=*/', '--exclude=.*'] + ); - if ($this->verbose) { - $this->log('INFO', "CMD: {$cmd}"); - } + if ($this->verbose) { + $this->log('INFO', "CMD: {$cmd}"); + } - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); + $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('ERROR', " {$line}"); - } - $failed++; - } else { - if ($this->verbose) { - foreach ($output as $line) { - $this->log('INFO', " {$line}"); - } - } - } - } + if ($exitCode !== 0) { + $this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log('ERROR', " {$line}"); + } + $failed++; + } else { + if ($this->verbose) { + foreach ($output as $line) { + $this->log('INFO', " {$line}"); + } + } + } + } - if ($failed > 0) { - $this->log('ERROR', "Deployment completed with {$failed} error(s)."); - return 1; - } + if ($failed > 0) { + $this->log('ERROR', "Deployment completed with {$failed} error(s)."); + return 1; + } - $this->log('INFO', 'Deployment completed successfully.'); - return 0; - } + $this->log('INFO', 'Deployment completed successfully.'); + return 0; + } - private function detectModuleName(): ?string - { - $pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php'; - $matches = glob($pattern); + private function detectModuleName(): ?string + { + $pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php'; + $matches = glob($pattern); - if (empty($matches)) { - return null; - } + 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]); - } + $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; - } + return null; + } - private function loadConfig(string $path): ?array - { - if (!is_file($path)) { - $this->log('ERROR', "Config file not found: {$path}"); - 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; - } + $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); + // 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; - } + if (!is_array($config)) { + $this->log('ERROR', 'Invalid JSON in config file.'); + return null; + } - return $config; - } + 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); - } + 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}"); + $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); - $this->log('INFO', "Syncing: {$dirName}"); - if ($this->verbose) { - $this->log('INFO', "CMD: {$cmd}"); - } + $this->log('INFO', "Syncing: {$dirName}"); + if ($this->verbose) { + $this->log('INFO', "CMD: {$cmd}"); + } - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); + $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('ERROR', " {$line}"); - } - return false; - } + if ($exitCode !== 0) { + $this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log('ERROR', " {$line}"); + } + return false; + } - if ($this->verbose) { - foreach ($output as $line) { - $this->log('INFO', " {$line}"); - } - } + if ($this->verbose) { + foreach ($output as $line) { + $this->log('INFO', " {$line}"); + } + } - return true; - } + return true; + } - private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string - { - $parts = ['rsync', '-rlptz', '--delete']; + 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 (self::EXCLUDES as $exclude) { + $parts[] = '--exclude=' . $exclude; + } - foreach ($extraArgs as $arg) { - $parts[] = $arg; - } + foreach ($extraArgs as $arg) { + $parts[] = $arg; + } - if ($this->dryRun) { - $parts[] = '--dry-run'; - } + if ($this->dryRun) { + $parts[] = '--dry-run'; + } - if ($this->verbose) { - $parts[] = '-v'; - } + if ($this->verbose) { + $parts[] = '-v'; + } - $parts[] = '-e'; - $parts[] = escapeshellarg($sshCmd); - $parts[] = escapeshellarg($source); - $parts[] = escapeshellarg($dest); + $parts[] = '-e'; + $parts[] = escapeshellarg($sshCmd); + $parts[] = escapeshellarg($source); + $parts[] = escapeshellarg($dest); - return implode(' ', $parts); - } + return implode(' ', $parts); + } } $app = new DeployDolibarrCli(); diff --git a/deploy/deploy-joomla.php b/deploy/deploy-joomla.php index b694883..645de96 100644 --- a/deploy/deploy-joomla.php +++ b/deploy/deploy-joomla.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -68,499 +69,499 @@ use phpseclib3\Crypt\PublicKeyLoader; */ class DeployJoomlaCli extends CliFramework { - private string $repoPath = '.'; - private string $srcDir = 'src'; - private array $config = []; - private int $uploaded = 0; - private int $unchanged = 0; - private int $skipped = 0; - private int $deleted = 0; - private array $ignorePatterns = []; + private string $repoPath = '.'; + private string $srcDir = 'src'; + private array $config = []; + private int $uploaded = 0; + private int $unchanged = 0; + private int $skipped = 0; + private int $deleted = 0; + private array $ignorePatterns = []; - protected function configure(): void - { - $this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest'); - $this->addArgument('--path', 'Repository root path', '.'); - $this->addArgument('--src-dir', 'Source directory relative to path', 'src'); - $this->addArgument('--config', 'Path to sftp-config.json', ''); - $this->addArgument('--key-passphrase', 'SSH key passphrase', ''); - } + protected function configure(): void + { + $this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--src-dir', 'Source directory relative to path', 'src'); + $this->addArgument('--config', 'Path to sftp-config.json', ''); + $this->addArgument('--key-passphrase', 'SSH key passphrase', ''); + } - protected function run(): int - { - $this->repoPath = $this->getArgument('--path'); - $this->srcDir = $this->getArgument('--src-dir'); - $configPath = $this->getArgument('--config'); - $keyPassphrase = $this->getArgument('--key-passphrase'); + protected function run(): int + { + $this->repoPath = $this->getArgument('--path'); + $this->srcDir = $this->getArgument('--src-dir'); + $configPath = $this->getArgument('--config'); + $keyPassphrase = $this->getArgument('--key-passphrase'); - if ($keyPassphrase !== '') { - $this->config['key_passphrase'] = $keyPassphrase; - } + if ($keyPassphrase !== '') { + $this->config['key_passphrase'] = $keyPassphrase; + } - $this->repoPath = realpath($this->repoPath) ?: $this->repoPath; + $this->repoPath = realpath($this->repoPath) ?: $this->repoPath; - // Resolve src dir - if (!str_starts_with($this->srcDir, '/')) { - $this->srcDir = "{$this->repoPath}/{$this->srcDir}"; - } - // Try htdocs/ as fallback - if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) { - $this->srcDir = "{$this->repoPath}/htdocs"; - } + // Resolve src dir + if (!str_starts_with($this->srcDir, '/')) { + $this->srcDir = "{$this->repoPath}/{$this->srcDir}"; + } + // Try htdocs/ as fallback + if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) { + $this->srcDir = "{$this->repoPath}/htdocs"; + } - // Load config - if ($configPath !== '' && file_exists($configPath)) { - $json = file_get_contents($configPath); - $json = preg_replace('#^\s*//.*$#m', '', $json); - $json = preg_replace('#,\s*([\]}])#', '$1', $json); - $parsed = json_decode($json, true); - if (is_array($parsed)) { - $this->config = array_merge($this->config, $parsed); - } - } + // Load config + if ($configPath !== '' && file_exists($configPath)) { + $json = file_get_contents($configPath); + $json = preg_replace('#^\s*//.*$#m', '', $json); + $json = preg_replace('#,\s*([\]}])#', '$1', $json); + $parsed = json_decode($json, true); + if (is_array($parsed)) { + $this->config = array_merge($this->config, $parsed); + } + } - $manifest = $this->findManifest(); - if ($manifest === null) { - $this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}"); - return 1; - } + $manifest = $this->findManifest(); + if ($manifest === null) { + $this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}"); + return 1; + } - $ext = $this->parseManifest($manifest); - if ($ext === null) { - $this->log('ERROR', "Failed to parse manifest: {$manifest}"); - return 1; - } + $ext = $this->parseManifest($manifest); + if ($ext === null) { + $this->log('ERROR', "Failed to parse manifest: {$manifest}"); + return 1; + } - $this->log('INFO', "Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})"); + $this->log('INFO', "Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})"); - $deployMap = $this->buildDeployMap($ext); - if (empty($deployMap)) { - $this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}"); - return 1; - } + $deployMap = $this->buildDeployMap($ext); + if (empty($deployMap)) { + $this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}"); + return 1; + } - $this->log('INFO', "Deploy mappings:"); - foreach ($deployMap as $map) { - $this->log('INFO', " {$map['local']} -> {$map['remote']}"); - } + $this->log('INFO', "Deploy mappings:"); + foreach ($deployMap as $map) { + $this->log('INFO', " {$map['local']} -> {$map['remote']}"); + } - // Load ignore patterns - $this->ignorePatterns = $this->loadFtpIgnore(); + // Load ignore patterns + $this->ignorePatterns = $this->loadFtpIgnore(); - // Check if manifest changed (warn user about reinstall) - $this->checkManifestChange($ext, $manifest); + // Check if manifest changed (warn user about reinstall) + $this->checkManifestChange($ext, $manifest); - if ($this->dryRun) { - $this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings"); - foreach ($deployMap as $map) { - if (is_dir($map['local'])) { - $count = iterator_count( - new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS) - ) - ); - $this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}"); - } - } - return 0; - } + if ($this->dryRun) { + $this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings"); + foreach ($deployMap as $map) { + if (is_dir($map['local'])) { + $count = iterator_count( + new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS) + ) + ); + $this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}"); + } + } + return 0; + } - // Connect - $sftp = $this->connectSftp(); - if ($sftp === null) { - return 1; - } + // Connect + $sftp = $this->connectSftp(); + if ($sftp === null) { + return 1; + } - // Deploy each mapping - $errors = 0; - foreach ($deployMap as $map) { - if (!is_dir($map['local'])) { - if ($this->verbose) { - $this->log('INFO', " SKIP: {$map['local']} (directory not found)"); - } - continue; - } + // Deploy each mapping + $errors = 0; + foreach ($deployMap as $map) { + if (!is_dir($map['local'])) { + if ($this->verbose) { + $this->log('INFO', " SKIP: {$map['local']} (directory not found)"); + } + continue; + } - // Ensure remote directory exists - $stat = @$sftp->stat($map['remote']); - if ($stat === false) { - $this->log('INFO', " MKDIR: {$map['remote']}"); - $sftp->mkdir($map['remote'], -1, true); - } + // Ensure remote directory exists + $stat = @$sftp->stat($map['remote']); + if ($stat === false) { + $this->log('INFO', " MKDIR: {$map['remote']}"); + $sftp->mkdir($map['remote'], -1, true); + } - $result = $this->uploadDirectory($sftp, $map['local'], $map['remote']); - if ($result !== 0) { - $errors++; - } - } + $result = $this->uploadDirectory($sftp, $map['local'], $map['remote']); + if ($result !== 0) { + $errors++; + } + } - // Also deploy the manifest file itself to the admin component directory - if ($ext['type'] === 'component' && file_exists($manifest)) { - $adminRemote = $this->getRemotePath($ext, 'admin'); - $manifestName = basename($manifest); - $remoteDest = "{$adminRemote}/{$manifestName}"; - $this->uploadFile($sftp, $manifest, $remoteDest); - $this->log('INFO', " Manifest: {$manifestName} -> {$remoteDest}"); - } + // Also deploy the manifest file itself to the admin component directory + if ($ext['type'] === 'component' && file_exists($manifest)) { + $adminRemote = $this->getRemotePath($ext, 'admin'); + $manifestName = basename($manifest); + $remoteDest = "{$adminRemote}/{$manifestName}"; + $this->uploadFile($sftp, $manifest, $remoteDest); + $this->log('INFO', " Manifest: {$manifestName} -> {$remoteDest}"); + } - $this->log('INFO', "Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}"); + $this->log('INFO', "Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}"); - return $errors > 0 ? 1 : 0; - } + return $errors > 0 ? 1 : 0; + } - /** - * Find the Joomla XML manifest file. - */ - private function findManifest(): ?string - { - $searchDirs = [$this->srcDir, $this->repoPath]; - foreach ($searchDirs as $dir) { - $iterator = new \DirectoryIterator($dir); - foreach ($iterator as $file) { - if ($file->isFile() && $file->getExtension() === 'xml') { - $content = file_get_contents($file->getPathname()); - if ($content !== false && str_contains($content, 'getPathname(); - } - } - } - // Also check one level deep - foreach (new \DirectoryIterator($dir) as $subdir) { - if ($subdir->isDir() && !$subdir->isDot()) { - foreach (new \DirectoryIterator($subdir->getPathname()) as $file) { - if ($file->isFile() && $file->getExtension() === 'xml') { - $content = file_get_contents($file->getPathname()); - if ($content !== false && str_contains($content, 'getPathname(); - } - } - } - } - } - } - return null; - } + /** + * Find the Joomla XML manifest file. + */ + private function findManifest(): ?string + { + $searchDirs = [$this->srcDir, $this->repoPath]; + foreach ($searchDirs as $dir) { + $iterator = new \DirectoryIterator($dir); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'xml') { + $content = file_get_contents($file->getPathname()); + if ($content !== false && str_contains($content, 'getPathname(); + } + } + } + // Also check one level deep + foreach (new \DirectoryIterator($dir) as $subdir) { + if ($subdir->isDir() && !$subdir->isDot()) { + foreach (new \DirectoryIterator($subdir->getPathname()) as $file) { + if ($file->isFile() && $file->getExtension() === 'xml') { + $content = file_get_contents($file->getPathname()); + if ($content !== false && str_contains($content, 'getPathname(); + } + } + } + } + } + } + return null; + } - /** - * Parse extension metadata from the XML manifest. - * - * @return array{type: string, element: string, client: string, group: string, name: string}|null - */ - private function parseManifest(string $path): ?array - { - $xml = @simplexml_load_file($path); - if ($xml === false || $xml->getName() !== 'extension') { - return null; - } + /** + * Parse extension metadata from the XML manifest. + * + * @return array{type: string, element: string, client: string, group: string, name: string}|null + */ + private function parseManifest(string $path): ?array + { + $xml = @simplexml_load_file($path); + if ($xml === false || $xml->getName() !== 'extension') { + return null; + } - $type = (string) ($xml['type'] ?? 'component'); - $client = (string) ($xml['client'] ?? 'site'); - $group = (string) ($xml['group'] ?? ''); - $element = (string) ($xml->element ?? ''); - $name = (string) ($xml->name ?? ''); + $type = (string) ($xml['type'] ?? 'component'); + $client = (string) ($xml['client'] ?? 'site'); + $group = (string) ($xml['group'] ?? ''); + $element = (string) ($xml->element ?? ''); + $name = (string) ($xml->name ?? ''); - // Derive element from type + name if not explicit - if (empty($element)) { - $cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name)); - $element = match ($type) { - 'component' => "com_{$cleanName}", - 'module' => "mod_{$cleanName}", - 'plugin' => "plg_{$group}_{$cleanName}", - 'template' => "tpl_{$cleanName}", - 'library' => "lib_{$cleanName}", - default => $cleanName, - }; - } + // Derive element from type + name if not explicit + if (empty($element)) { + $cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name)); + $element = match ($type) { + 'component' => "com_{$cleanName}", + 'module' => "mod_{$cleanName}", + 'plugin' => "plg_{$group}_{$cleanName}", + 'template' => "tpl_{$cleanName}", + 'library' => "lib_{$cleanName}", + default => $cleanName, + }; + } - // For plugins, derive the short name (without plg_group_ prefix) - $shortName = $element; - if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) { - $shortName = $m[1]; - } elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) { - $shortName = $m[1]; - } + // For plugins, derive the short name (without plg_group_ prefix) + $shortName = $element; + if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) { + $shortName = $m[1]; + } elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) { + $shortName = $m[1]; + } - return [ - 'type' => $type, - 'element' => $element, - 'client' => $client, - 'group' => $group, - 'name' => $name, - 'shortName' => $shortName, - ]; - } + return [ + 'type' => $type, + 'element' => $element, + 'client' => $client, + 'group' => $group, + 'name' => $name, + 'shortName' => $shortName, + ]; + } - /** - * Build the local->remote deploy mapping based on extension type. - * - * @return array - */ - private function buildDeployMap(array $ext): array - { - $remotePath = rtrim((string) $this->config['remote_path'], '/'); - $src = $this->srcDir; - $map = []; + /** + * Build the local->remote deploy mapping based on extension type. + * + * @return array + */ + private function buildDeployMap(array $ext): array + { + $remotePath = rtrim((string) $this->config['remote_path'], '/'); + $src = $this->srcDir; + $map = []; - switch ($ext['type']) { - case 'component': - // Admin files - if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) { - $adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator"; - $map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"]; - } - // Site files - if (is_dir("{$src}/site")) { - $map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"]; - } - // Media files - if (is_dir("{$src}/media")) { - $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; - } - // API files (Joomla 4+) - if (is_dir("{$src}/api")) { - $map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"]; - } - // Language files (admin) - if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) { - $langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language"; - $map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"]; - } - // Language files (site) - if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) { - $langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language"; - $map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"]; - } - break; + switch ($ext['type']) { + case 'component': + // Admin files + if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) { + $adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator"; + $map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"]; + } + // Site files + if (is_dir("{$src}/site")) { + $map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"]; + } + // Media files + if (is_dir("{$src}/media")) { + $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; + } + // API files (Joomla 4+) + if (is_dir("{$src}/api")) { + $map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"]; + } + // Language files (admin) + if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) { + $langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language"; + $map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"]; + } + // Language files (site) + if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) { + $langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language"; + $map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"]; + } + break; - case 'module': - $base = $ext['client'] === 'administrator' - ? "{$remotePath}/administrator/modules/{$ext['element']}" - : "{$remotePath}/modules/{$ext['element']}"; - $map[] = ['local' => $src, 'remote' => $base]; - if (is_dir("{$src}/media")) { - $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; - } - break; + case 'module': + $base = $ext['client'] === 'administrator' + ? "{$remotePath}/administrator/modules/{$ext['element']}" + : "{$remotePath}/modules/{$ext['element']}"; + $map[] = ['local' => $src, 'remote' => $base]; + if (is_dir("{$src}/media")) { + $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; + } + break; - case 'plugin': - $map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"]; - if (is_dir("{$src}/media")) { - $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; - } - break; + case 'plugin': + $map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"]; + if (is_dir("{$src}/media")) { + $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; + } + break; - case 'template': - $clientDir = $ext['client'] === 'administrator' ? 'administrator/' : ''; - $map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"]; - if (is_dir("{$src}/media")) { - $mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site'; - $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"]; - } - break; + case 'template': + $clientDir = $ext['client'] === 'administrator' ? 'administrator/' : ''; + $map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"]; + if (is_dir("{$src}/media")) { + $mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site'; + $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"]; + } + break; - case 'library': - $map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"]; - if (is_dir("{$src}/media")) { - $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; - } - break; + case 'library': + $map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"]; + if (is_dir("{$src}/media")) { + $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; + } + break; - case 'package': - // Packages deploy their sub-extensions individually - // For now, deploy to administrator/manifests/packages/ - $map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"]; - break; - } + case 'package': + // Packages deploy their sub-extensions individually + // For now, deploy to administrator/manifests/packages/ + $map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"]; + break; + } - return $map; - } + return $map; + } - /** - * Get the remote path for a specific section of the extension. - */ - private function getRemotePath(array $ext, string $section): string - { - $remotePath = rtrim((string) $this->config['remote_path'], '/'); - return match ($section) { - 'admin' => "{$remotePath}/administrator/components/{$ext['element']}", - 'site' => "{$remotePath}/components/{$ext['element']}", - 'media' => "{$remotePath}/media/{$ext['element']}", - default => $remotePath, - }; - } + /** + * Get the remote path for a specific section of the extension. + */ + private function getRemotePath(array $ext, string $section): string + { + $remotePath = rtrim((string) $this->config['remote_path'], '/'); + return match ($section) { + 'admin' => "{$remotePath}/administrator/components/{$ext['element']}", + 'site' => "{$remotePath}/components/{$ext['element']}", + 'media' => "{$remotePath}/media/{$ext['element']}", + default => $remotePath, + }; + } - /** - * Check if the XML manifest has changed and warn about reinstall. - */ - private function checkManifestChange(array $ext, string $manifestPath): void - { - $manifestName = basename($manifestPath); - $this->log('INFO', ''); - $this->log('INFO', "NOTE: If {$manifestName} has changed (new fields, permissions, menu items,"); - $this->log('INFO', ' database schema), you must reinstall the extension through Joomla.'); - $this->log('INFO', ' Code changes (PHP, JS, CSS, language) do NOT require reinstall.'); - $this->log('INFO', ''); - } + /** + * Check if the XML manifest has changed and warn about reinstall. + */ + private function checkManifestChange(array $ext, string $manifestPath): void + { + $manifestName = basename($manifestPath); + $this->log('INFO', ''); + $this->log('INFO', "NOTE: If {$manifestName} has changed (new fields, permissions, menu items,"); + $this->log('INFO', ' database schema), you must reinstall the extension through Joomla.'); + $this->log('INFO', ' Code changes (PHP, JS, CSS, language) do NOT require reinstall.'); + $this->log('INFO', ''); + } - /** - * Upload a directory recursively to the remote server. - */ - private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int - { - $errors = 0; - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::SELF_FIRST - ); + /** + * Upload a directory recursively to the remote server. + */ + private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int + { + $errors = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); - foreach ($iterator as $item) { - $relativePath = substr($item->getPathname(), strlen($localDir) + 1); - $relativePath = str_replace('\\', '/', $relativePath); - $remotePath = "{$remoteDir}/{$relativePath}"; + foreach ($iterator as $item) { + $relativePath = substr($item->getPathname(), strlen($localDir) + 1); + $relativePath = str_replace('\\', '/', $relativePath); + $remotePath = "{$remoteDir}/{$relativePath}"; - // Check ignore patterns - if ($this->shouldIgnore($relativePath)) { - $this->skipped++; - continue; - } + // Check ignore patterns + if ($this->shouldIgnore($relativePath)) { + $this->skipped++; + continue; + } - if ($item->isDir()) { - $stat = @$sftp->stat($remotePath); - if ($stat === false) { - $sftp->mkdir($remotePath, -1, true); - } - } else { - $result = $this->uploadFile($sftp, $item->getPathname(), $remotePath); - if (!$result) { - $errors++; - } - } - } + if ($item->isDir()) { + $stat = @$sftp->stat($remotePath); + if ($stat === false) { + $sftp->mkdir($remotePath, -1, true); + } + } else { + $result = $this->uploadFile($sftp, $item->getPathname(), $remotePath); + if (!$result) { + $errors++; + } + } + } - return $errors; - } + return $errors; + } - /** - * Upload a single file with smart diff (skip if unchanged). - */ - private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool - { - $localSize = filesize($localPath); - $remoteStat = @$sftp->stat($remotePath); + /** + * Upload a single file with smart diff (skip if unchanged). + */ + private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool + { + $localSize = filesize($localPath); + $remoteStat = @$sftp->stat($remotePath); - // Smart diff: skip if same size and hash - if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) { - $remoteContent = @$sftp->get($remotePath); - if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) { - $this->unchanged++; - return true; - } - } + // Smart diff: skip if same size and hash + if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) { + $remoteContent = @$sftp->get($remotePath); + if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) { + $this->unchanged++; + return true; + } + } - // Ensure parent directory exists - $parentDir = dirname($remotePath); - $parentStat = @$sftp->stat($parentDir); - if ($parentStat === false) { - $sftp->mkdir($parentDir, -1, true); - } + // Ensure parent directory exists + $parentDir = dirname($remotePath); + $parentStat = @$sftp->stat($parentDir); + if ($parentStat === false) { + $sftp->mkdir($parentDir, -1, true); + } - $result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE); - if ($result) { - $this->uploaded++; - if ($this->verbose) { - $this->log('INFO', " UPLOAD: {$remotePath}"); - } - } else { - $this->log('ERROR', " FAIL: {$remotePath}"); - } - return $result; - } + $result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE); + if ($result) { + $this->uploaded++; + if ($this->verbose) { + $this->log('INFO', " UPLOAD: {$remotePath}"); + } + } else { + $this->log('ERROR', " FAIL: {$remotePath}"); + } + return $result; + } - /** - * Check if a relative path should be ignored. - */ - private function shouldIgnore(string $relativePath): bool - { - foreach ($this->ignorePatterns as $pattern) { - if (preg_match($pattern, $relativePath)) { - return true; - } - } - // Always skip dotfiles and common non-deploy files - $basename = basename($relativePath); - if (str_starts_with($basename, '.') && $basename !== '.htaccess') { - return true; - } - return false; - } + /** + * Check if a relative path should be ignored. + */ + private function shouldIgnore(string $relativePath): bool + { + foreach ($this->ignorePatterns as $pattern) { + if (preg_match($pattern, $relativePath)) { + return true; + } + } + // Always skip dotfiles and common non-deploy files + $basename = basename($relativePath); + if (str_starts_with($basename, '.') && $basename !== '.htaccess') { + return true; + } + return false; + } - /** - * Load .ftpignore patterns. - * - * @return string[] Regex patterns - */ - private function loadFtpIgnore(): array - { - $patterns = []; - foreach ([$this->srcDir, $this->repoPath] as $dir) { - $file = "{$dir}/.ftpignore"; - if (!file_exists($file)) { - continue; - } - foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - $line = trim($line); - if ($line === '' || str_starts_with($line, '#')) { - continue; - } - // Convert glob to regex - $regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line); - $patterns[] = "#^{$regex}(/|$)#i"; - } - } - return $patterns; - } + /** + * Load .ftpignore patterns. + * + * @return string[] Regex patterns + */ + private function loadFtpIgnore(): array + { + $patterns = []; + foreach ([$this->srcDir, $this->repoPath] as $dir) { + $file = "{$dir}/.ftpignore"; + if (!file_exists($file)) { + continue; + } + foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + // Convert glob to regex + $regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line); + $patterns[] = "#^{$regex}(/|$)#i"; + } + } + return $patterns; + } - /** - * Connect to the SFTP server. - */ - private function connectSftp(): ?SFTP - { - $host = (string) $this->config['host']; - $port = (int) ($this->config['port'] ?? 22); - $user = (string) $this->config['user']; + /** + * Connect to the SFTP server. + */ + private function connectSftp(): ?SFTP + { + $host = (string) $this->config['host']; + $port = (int) ($this->config['port'] ?? 22); + $user = (string) $this->config['user']; - $this->log('INFO', "Connecting to {$user}@{$host}:{$port}..."); + $this->log('INFO', "Connecting to {$user}@{$host}:{$port}..."); - $sftp = new SFTP($host, $port, 30); + $sftp = new SFTP($host, $port, 30); - // Try key auth first - if (!empty($this->config['ssh_key_file'])) { - $keyPath = $this->config['ssh_key_file']; - if (!file_exists($keyPath)) { - $keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}"; - } - if (file_exists($keyPath)) { - $passphrase = $this->config['key_passphrase'] ?? ''; - $key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase); - if ($sftp->login($user, $key)) { - $this->log('INFO', 'Connected via SSH key'); - return $sftp; - } - $this->warning('Key auth failed'); - } - } + // Try key auth first + if (!empty($this->config['ssh_key_file'])) { + $keyPath = $this->config['ssh_key_file']; + if (!file_exists($keyPath)) { + $keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}"; + } + if (file_exists($keyPath)) { + $passphrase = $this->config['key_passphrase'] ?? ''; + $key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase); + if ($sftp->login($user, $key)) { + $this->log('INFO', 'Connected via SSH key'); + return $sftp; + } + $this->warning('Key auth failed'); + } + } - // Fallback to password - if (!empty($this->config['password'])) { - if ($sftp->login($user, $this->config['password'])) { - $this->log('INFO', 'Connected via password'); - return $sftp; - } - } + // Fallback to password + if (!empty($this->config['password'])) { + if ($sftp->login($user, $this->config['password'])) { + $this->log('INFO', 'Connected via password'); + return $sftp; + } + } - $this->log('ERROR', 'Authentication failed'); - return null; - } + $this->log('ERROR', 'Authentication failed'); + return null; + } } $app = new DeployJoomlaCli(); diff --git a/deploy/deploy-sftp.php b/deploy/deploy-sftp.php index 74fde31..443bc16 100644 --- a/deploy/deploy-sftp.php +++ b/deploy/deploy-sftp.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -33,673 +34,673 @@ use phpseclib3\Crypt\PublicKeyLoader; */ class DeploySftp extends CliFramework { - /** @var array Parsed sftp-config.json contents */ - private array $config = []; - - /** @var int Count of files uploaded in the current run */ - private int $uploaded = 0; - - /** @var int Count of files skipped due to ignore rules */ - private int $skipped = 0; - - /** @var int Count of files unchanged (smart deploy) */ - private int $unchanged = 0; - - /** @var int Count of remote files deleted (smart deploy) */ - private int $deleted = 0; - - protected function configure(): void - { - $this->setDescription('Deploy a repository src/ directory to a remote web server via SFTP'); - $this->addArgument('--path', 'Repository root (default: current directory)', '.'); - $this->addArgument('--src-dir', 'Source sub-directory to upload (default: src)', 'src'); - $this->addArgument('--env', 'Target environment: dev or rs', ''); - $this->addArgument('--config', 'Explicit config file path — overrides --env', ''); - $this->addArgument('--key-passphrase', 'Passphrase for the SSH private key', ''); - } - - /** - * Main execution logic. - * - * @return int POSIX exit code - */ - protected function run(): int - { - $repoPath = $this->resolveRepoPath(); - $srcDir = $this->resolveSrcDir($repoPath); - $configPath = $this->resolveConfigPath($repoPath); - - $this->log("Repository : {$repoPath}"); - $this->log("Source dir : {$srcDir}"); - $this->log("Config file: {$configPath}"); - - if (!$this->loadConfig($configPath)) { - return 1; - } - - if (!$this->validateConfig()) { - return 1; - } - - $host = (string) $this->config['host']; - $port = (int) ($this->config['port'] ?? 22); - $user = (string) $this->config['user']; - $remotePath = rtrim((string) $this->config['remote_path'], '/'); - // Load .ftpignore from src/ directory (primary) and repo root (fallback) - $ignores = array_merge( - $this->buildIgnorePatterns(), - $this->loadFtpIgnorePatterns($srcDir), - $this->loadFtpIgnorePatterns($repoPath) - ); - - $this->log("Connecting to {$user}@{$host}:{$port} ..."); - - if ($this->dryRun) { - $this->log("[DRY RUN] Would connect and upload {$srcDir} → {$remotePath}"); - return $this->walkAndDryRun($srcDir, $remotePath, $srcDir, $ignores); - } - - $sftp = $this->connect($host, $port, $user, $repoPath); - if ($sftp === null) { - return 1; - } - - $this->log("Connected. Uploading {$srcDir} → {$remotePath}"); - - // Ensure the remote destination directory exists; create it (recursively) if not. - // phpseclib3 has a channel reuse bug — is_dir() opens the SFTP subsystem, then - // mkdir() tries to open it again causing "Please close the channel" error. - // Fix: use nlist() which reuses the channel, then mkdir() works on the same channel. - $dirCheck = @$sftp->nlist(dirname($remotePath)); - $baseName = basename($remotePath); - $dirExists = is_array($dirCheck) && in_array($baseName, $dirCheck, true); - - if (!$dirExists) { - $this->log("Remote directory not found — creating: {$remotePath}"); - if (!$sftp->mkdir($remotePath, -1, true)) { - $this->log('ERROR', "Failed to create remote directory: {$remotePath}"); - return 1; - } - } - - $exitCode = $this->uploadDirectory($sftp, $srcDir, $remotePath, $srcDir, $ignores); - - $this->log("Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Deleted: {$this->deleted}, Skipped: {$this->skipped}"); - - return $exitCode; - } - - // ─── Private helpers ────────────────────────────────────────────────────── - - /** - * Resolve the absolute path to the repository root. - * - * @return string Absolute repository path - * @throws \RuntimeException When the path does not exist - */ - private function resolveRepoPath(): string - { - $raw = $this->getArgument('--path', '.'); - $path = realpath($raw); - - if ($path === false || !is_dir($path)) { - $this->error("Repository path does not exist or is not a directory: {$raw}"); - } - - return $path; - } - - /** - * Resolve the source directory that will be uploaded. - * - * @param string $repoPath Absolute repository root - * @return string Absolute path to the source directory - */ - private function resolveSrcDir(string $repoPath): string - { - $sub = $this->getArgument('--src-dir', 'src'); - $dir = $repoPath . DIRECTORY_SEPARATOR . $sub; - - if (!is_dir($dir)) { - $this->error("Source directory does not exist: {$dir}"); - } - - return $dir; - } - - /** Map of --env values to their sftp-config filename. */ - private const ENV_CONFIG_MAP = [ - 'dev' => 'sftp-config.dev.json', - 'rs' => 'sftp-config.rs.json', - ]; - - /** - * Resolve the absolute path to the sftp-config file. - * - * Resolution order (first match wins): - * 1. --config — explicit override - * 2. --env dev|rs — maps to sftp-config.{env}.json in {path}/scripts/sftp-config/ - * 3. sftp-config.json — generic fallback in {path}/scripts/sftp-config/ - * - * @param string $repoPath Absolute repository root - * @return string Absolute path to the config file - */ - private function resolveConfigPath(string $repoPath): string - { - $configDir = $repoPath . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'sftp-config'; - - // 1. Explicit --config wins unconditionally - $explicit = $this->getArgument('--config') ?: null; - if ($explicit !== null) { - $path = realpath($explicit); - if ($path === false) { - $this->error("Config file not found: {$explicit}"); - } - return $path; - } - - // 2. --env selects the named config file - $env = $this->getArgument('--env') ?: null; - if ($env !== null) { - $env = strtolower((string) $env); - if (!isset(self::ENV_CONFIG_MAP[$env])) { - $valid = implode(', ', array_keys(self::ENV_CONFIG_MAP)); - $this->error("Unknown --env value '{$env}'. Valid values: {$valid}", self::EXIT_USAGE); - } - $envConfig = $configDir . DIRECTORY_SEPARATOR . self::ENV_CONFIG_MAP[$env]; - if (!file_exists($envConfig)) { - $this->log('ERROR', "Copy templates/scripts/deploy/sftp-config.{$env}.json.example → {$envConfig}"); - $this->error("Config file not found for --env {$env}: {$envConfig}"); - } - return $envConfig; - } - - // 3. Generic fallback - $default = $configDir . DIRECTORY_SEPARATOR . 'sftp-config.json'; - if (!file_exists($default)) { - $this->log('ERROR', "Use --env dev, --env rs, or --config ."); - $this->error("No config file found. Tried: {$default}"); - } - - return $default; - } - - /** - * Load and parse sftp-config.json, stripping JS-style // comments. - * - * The Sublime Text SFTP plugin allows // comments and trailing commas, - * so we strip those before passing the text to json_decode. - * - * @param string $configPath Absolute path to the config file - * @return bool True on success - */ - private function loadConfig(string $configPath): bool - { - $raw = file_get_contents($configPath); - if ($raw === false) { - $this->log('ERROR', "Cannot read config file: {$configPath}"); - return false; - } - - // Strip // line comments (not inside strings — good enough for this format) - $stripped = preg_replace('#(?log('ERROR', "Failed to parse config file: " . json_last_error_msg()); - return false; - } - - $this->config = $decoded; - return true; - } - - /** - * Validate that required config keys are present. - * - * @return bool True when all required fields exist - */ - private function validateConfig(): bool - { - $required = ['host', 'user', 'remote_path']; - $missing = []; - - foreach ($required as $key) { - if (empty($this->config[$key])) { - $missing[] = $key; - } - } - - if (empty($this->config['ssh_key_file']) && empty($this->config['password'])) { - $missing[] = 'ssh_key_file or password'; - } - - if (!empty($missing)) { - $this->log('ERROR', "Missing required config fields: " . implode(', ', $missing)); - return false; - } - - return true; - } - - /** - * Build the list of ignore regex patterns from config. - * - * @return array Array of PCRE patterns - */ - private function buildIgnorePatterns(): array - { - $raw = $this->config['ignore_regexes'] ?? []; - return array_values(array_filter(array_map('strval', $raw))); - } - - /** - * Load ignore patterns from a .ftpignore file in the source directory. - * - * Follows gitignore syntax: blank lines and # comments are skipped; - * glob wildcards (* ? **) are converted to PCRE; a trailing slash matches - * directories; a leading slash anchors the pattern to the upload root. - * - * @param string $srcDir Absolute path to the source directory being uploaded - * @return array PCRE patterns ready for shouldIgnore() - */ - private function loadFtpIgnorePatterns(string $srcDir): array - { - $ignoreFile = $srcDir . DIRECTORY_SEPARATOR . '.ftpignore'; - if (!is_file($ignoreFile)) { - return []; - } - - $this->log('DEBUG', "Loading ignore rules from .ftpignore"); - - $patterns = []; - $lines = file($ignoreFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - if ($lines === false) { - return []; - } - - foreach ($lines as $line) { - // Strip inline comments and trim whitespace - $line = trim((string) preg_replace('/#.*$/', '', $line)); - if ($line === '' || str_starts_with($line, '#')) { - continue; - } - - // Negation patterns (!) are not supported — log and skip - if (str_starts_with($line, '!')) { - $this->log('DEBUG', " .ftpignore: negation patterns not supported, skipping: {$line}"); - continue; - } - - // A trailing slash means "match directories only"; we match the prefix - $line = rtrim($line, '/'); - - // A leading slash anchors to the root; strip it and anchor in regex - $anchored = str_starts_with($line, '/'); - $line = ltrim($line, '/'); - - // Convert glob to PCRE - $regex = preg_quote($line, '/'); - $regex = str_replace('\*\*', '.*', $regex); // ** → any path segment - $regex = str_replace('\*', '[^/]*', $regex); // * → any name chars - $regex = str_replace('\?', '[^/]', $regex); // ? → single char - - $regex = $anchored ? '^' . $regex : '(^|/)' . $regex; - - $patterns[] = $regex . '(/|$)'; - $this->log('DEBUG', " .ftpignore rule: {$line} → /{$regex}/i"); - } - - return $patterns; - } - - /** - * Establish an authenticated SFTP connection. - * - * @param string $host Remote hostname - * @param int $port SSH port - * @param string $user SSH username - * @param string $repoPath Absolute repository root (for key resolution) - * @return SFTP|null Authenticated SFTP object, or null on failure - */ - private function connect(string $host, int $port, string $user, string $repoPath): ?SFTP - { - try { - $sftp = new SFTP($host, $port, timeout: 30); - } catch (\Throwable $e) { - $this->log('ERROR', "Cannot reach {$host}:{$port} — " . $e->getMessage()); - return null; - } - - $rawKeyFile = $this->config['ssh_key_file'] ?? null; - - if (!empty($rawKeyFile)) { - $keyFile = $this->resolveKeyPath((string) $rawKeyFile, $repoPath); - $this->log('DEBUG', "Using SSH key: {$keyFile}"); - return $this->authenticateWithKey($sftp, $user, $keyFile); - } - - // Password fallback - $password = (string) ($this->config['password'] ?? ''); - if (!$sftp->login($user, $password)) { - $this->log('ERROR', "SFTP password authentication failed for {$user}@{$host}"); - return null; - } - - return $sftp; - } - - /** - * Resolve the SSH key file path. - * - * If the configured path is not absolute, look for the file under - * {repo_path}/scripts/keys/ before falling back to the raw value. - * - * @param string $configured Raw value from sftp-config.json - * @param string $repoPath Absolute repository root - * @return string Resolved absolute path - */ - private function resolveKeyPath(string $configured, string $repoPath): string - { - // Already absolute — use as-is - if (str_starts_with($configured, '/') || preg_match('/^[A-Za-z]:[\/\\\\]/', $configured)) { - return $configured; - } - - // Relative path — check scripts/keys/ first - $keysDir = $repoPath . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'keys'; - $candidate = $keysDir . DIRECTORY_SEPARATOR . ltrim($configured, '/\\'); - if (file_exists($candidate)) { - return $candidate; - } - - // Fall back to relative from CWD - return $configured; - } - - /** - * Authenticate the SFTP session using a private key file. - * - * Supports both PuTTY .ppk keys and OpenSSH PEM keys via phpseclib. - * - * @param SFTP $sftp Open SFTP connection - * @param string $user SSH username - * @param string $keyFile Path to the private key file - * @return SFTP|null Authenticated connection, or null on failure - */ - private function authenticateWithKey(SFTP $sftp, string $user, string $keyFile): ?SFTP - { - if (!file_exists($keyFile)) { - $this->log('ERROR', "SSH key file not found: {$keyFile}"); - return null; - } - - $passphrase = $this->getArgument('--key-passphrase') ?: null; - - try { - $keyData = file_get_contents($keyFile); - if ($keyData === false) { - throw new \RuntimeException("Cannot read key file: {$keyFile}"); - } - - $key = $passphrase !== null - ? PublicKeyLoader::load($keyData, $passphrase) - : PublicKeyLoader::load($keyData); - } catch (\Throwable $e) { - $this->log('ERROR', "Failed to load SSH key: " . $e->getMessage()); - return null; - } - - if (!$sftp->login($user, $key)) { - $this->log('ERROR', "SFTP key authentication failed for {$user}"); - return null; - } - - return $sftp; - } - - /** - * Check whether a relative file path should be ignored. - * - * @param string $relativePath Path relative to the upload root - * @param array $patterns PCRE patterns from sftp-config.json - * @return bool True when the file should be skipped - */ - private function shouldIgnore(string $relativePath, array $patterns): bool - { - foreach ($patterns as $pattern) { - if (preg_match('#' . $pattern . '#i', $relativePath) === 1) { - return true; - } - } - return false; - } - - /** - * Recursively upload a local directory to the remote server. - * - * @param SFTP $sftp Authenticated SFTP connection - * @param string $localDir Absolute local directory path - * @param string $remotePath Absolute remote directory path - * @param string $srcRoot Absolute local root (for relative-path calculation) - * @param array $ignorePatterns PCRE patterns to skip - * @return int POSIX exit code (0 = success) - */ - /** - * Smart deploy: upload only changed/new files, delete removed files. - * - * Compares local files against remote by size. If sizes match, compares - * MD5 hashes. Only uploads when content differs. Removes remote files - * that no longer exist locally (respecting ignore patterns). - */ - private function uploadDirectory( - SFTP $sftp, - string $localDir, - string $remotePath, - string $srcRoot, - array $ignorePatterns - ): int { - $entries = scandir($localDir); - if ($entries === false) { - $this->log('ERROR', "Cannot read directory: {$localDir}"); - return 1; - } - - // Build set of local entries for deletion detection - $localEntryNames = []; - - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - - if (str_starts_with($entry, '.')) { - $this->log('DEBUG', " SKIP {$entry} (dotfile)"); - $this->skipped++; - continue; - } - - $localEntry = $localDir . DIRECTORY_SEPARATOR . $entry; - $remoteEntry = $remotePath . '/' . $entry; - $relative = ltrim(str_replace($srcRoot, '', $localEntry), DIRECTORY_SEPARATOR . '/'); - - if ($this->shouldIgnore($relative, $ignorePatterns)) { - $this->log('DEBUG', " SKIP {$relative}"); - $this->skipped++; - continue; - } - - $localEntryNames[$entry] = true; - - if (is_dir($localEntry)) { - $stat = @$sftp->stat($remoteEntry); - if ($stat === false) { - $this->log('DEBUG', " MKDIR {$remoteEntry}"); - $sftp->mkdir($remoteEntry, -1, true); - } - $result = $this->uploadDirectory($sftp, $localEntry, $remoteEntry, $srcRoot, $ignorePatterns); - if ($result !== 0) { - return $result; - } - } else { - // Smart diff: compare local vs remote before uploading - if ($this->isFileUnchanged($sftp, $localEntry, $remoteEntry)) { - $this->log('DEBUG', " SAME {$relative}"); - $this->unchanged++; - continue; - } - - $this->log(" PUT {$relative} → {$remoteEntry}"); - if (!$sftp->put($remoteEntry, $localEntry, SFTP::SOURCE_LOCAL_FILE)) { - $this->log('ERROR', "Failed to upload: {$relative}"); - return 1; - } - $this->uploaded++; - } - } - - // Delete remote files that no longer exist locally - $remoteEntries = $sftp->nlist($remotePath); - if (is_array($remoteEntries)) { - foreach ($remoteEntries as $remoteEntry) { - if ($remoteEntry === '.' || $remoteEntry === '..') { - continue; - } - if (!isset($localEntryNames[$remoteEntry])) { - $remoteFull = $remotePath . '/' . $remoteEntry; - $relative = ltrim(str_replace($srcRoot, '', $localDir . DIRECTORY_SEPARATOR . $remoteEntry), DIRECTORY_SEPARATOR . '/'); - - // Don't delete files that match ignore patterns - if ($this->shouldIgnore($relative, $ignorePatterns)) { - continue; - } - - $remoteStat = @$sftp->stat($remoteFull); - if ($remoteStat !== false && ($remoteStat['type'] ?? 0) === 2) { - $this->deleteRemoteDirectory($sftp, $remoteFull); - $this->log(" RMDIR {$remoteFull}"); - } else { - $sftp->delete($remoteFull); - $this->log(" DEL {$remoteFull}"); - } - $this->deleted++; - } - } - } - - return 0; - } - - /** - * Check if a local file is identical to its remote counterpart. - * - * Uses size comparison first (fast), then MD5 hash if sizes match. - */ - private function isFileUnchanged(SFTP $sftp, string $localPath, string $remotePath): bool - { - $remoteStat = $sftp->stat($remotePath); - if ($remoteStat === false) { - return false; // Remote file doesn't exist — needs upload - } - - $localSize = filesize($localPath); - $remoteSize = $remoteStat['size'] ?? -1; - - if ($localSize !== $remoteSize) { - return false; // Different sizes — needs upload - } - - // Sizes match — compare MD5 hashes - $localMd5 = md5_file($localPath); - $remoteContent = $sftp->get($remotePath); - if ($remoteContent === false) { - return false; - } - - return $localMd5 === md5($remoteContent); - } - - /** - * Recursively delete a remote directory and all its contents. - */ - private function deleteRemoteDirectory(SFTP $sftp, string $path): void - { - $entries = $sftp->nlist($path); - if (!is_array($entries)) { - return; - } - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - $full = "{$path}/{$entry}"; - $entryStat = @$sftp->stat($full); - if ($entryStat !== false && ($entryStat['type'] ?? 0) === 2) { - $this->deleteRemoteDirectory($sftp, $full); - $sftp->rmdir($full); - } else { - $sftp->delete($full); - } - } - $sftp->rmdir($path); - } - - /** - * Walk the source directory and log what would be uploaded, without connecting. - * - * @param string $localDir Absolute local directory path - * @param string $remotePath Remote destination path - * @param string $srcRoot Absolute local root for relative paths - * @param array $ignorePatterns PCRE patterns to skip - * @return int Always 0 in dry-run mode - */ - private function walkAndDryRun( - string $localDir, - string $remotePath, - string $srcRoot, - array $ignorePatterns - ): int { - $entries = scandir($localDir); - if ($entries === false) { - $this->log('ERROR', "Cannot read directory: {$localDir}"); - return 1; - } - - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - - if (str_starts_with($entry, '.')) { - $this->log("[DRY RUN] SKIP {$entry} (dotfile)"); - $this->skipped++; - continue; - } - - $localEntry = $localDir . DIRECTORY_SEPARATOR . $entry; - $remoteEntry = $remotePath . '/' . $entry; - $relative = ltrim(str_replace($srcRoot, '', $localEntry), DIRECTORY_SEPARATOR . '/'); - - if ($this->shouldIgnore($relative, $ignorePatterns)) { - $this->log("[DRY RUN] SKIP {$relative}"); - $this->skipped++; - continue; - } - - if (is_dir($localEntry)) { - $this->log("[DRY RUN] MKDIR {$remoteEntry}"); - $this->walkAndDryRun($localEntry, $remoteEntry, $srcRoot, $ignorePatterns); - } else { - $this->log("[DRY RUN] PUT {$relative} → {$remoteEntry}"); - $this->uploaded++; - } - } - - return 0; - } + /** @var array Parsed sftp-config.json contents */ + private array $config = []; + + /** @var int Count of files uploaded in the current run */ + private int $uploaded = 0; + + /** @var int Count of files skipped due to ignore rules */ + private int $skipped = 0; + + /** @var int Count of files unchanged (smart deploy) */ + private int $unchanged = 0; + + /** @var int Count of remote files deleted (smart deploy) */ + private int $deleted = 0; + + protected function configure(): void + { + $this->setDescription('Deploy a repository src/ directory to a remote web server via SFTP'); + $this->addArgument('--path', 'Repository root (default: current directory)', '.'); + $this->addArgument('--src-dir', 'Source sub-directory to upload (default: src)', 'src'); + $this->addArgument('--env', 'Target environment: dev or rs', ''); + $this->addArgument('--config', 'Explicit config file path — overrides --env', ''); + $this->addArgument('--key-passphrase', 'Passphrase for the SSH private key', ''); + } + + /** + * Main execution logic. + * + * @return int POSIX exit code + */ + protected function run(): int + { + $repoPath = $this->resolveRepoPath(); + $srcDir = $this->resolveSrcDir($repoPath); + $configPath = $this->resolveConfigPath($repoPath); + + $this->log("Repository : {$repoPath}"); + $this->log("Source dir : {$srcDir}"); + $this->log("Config file: {$configPath}"); + + if (!$this->loadConfig($configPath)) { + return 1; + } + + if (!$this->validateConfig()) { + return 1; + } + + $host = (string) $this->config['host']; + $port = (int) ($this->config['port'] ?? 22); + $user = (string) $this->config['user']; + $remotePath = rtrim((string) $this->config['remote_path'], '/'); + // Load .ftpignore from src/ directory (primary) and repo root (fallback) + $ignores = array_merge( + $this->buildIgnorePatterns(), + $this->loadFtpIgnorePatterns($srcDir), + $this->loadFtpIgnorePatterns($repoPath) + ); + + $this->log("Connecting to {$user}@{$host}:{$port} ..."); + + if ($this->dryRun) { + $this->log("[DRY RUN] Would connect and upload {$srcDir} → {$remotePath}"); + return $this->walkAndDryRun($srcDir, $remotePath, $srcDir, $ignores); + } + + $sftp = $this->connect($host, $port, $user, $repoPath); + if ($sftp === null) { + return 1; + } + + $this->log("Connected. Uploading {$srcDir} → {$remotePath}"); + + // Ensure the remote destination directory exists; create it (recursively) if not. + // phpseclib3 has a channel reuse bug — is_dir() opens the SFTP subsystem, then + // mkdir() tries to open it again causing "Please close the channel" error. + // Fix: use nlist() which reuses the channel, then mkdir() works on the same channel. + $dirCheck = @$sftp->nlist(dirname($remotePath)); + $baseName = basename($remotePath); + $dirExists = is_array($dirCheck) && in_array($baseName, $dirCheck, true); + + if (!$dirExists) { + $this->log("Remote directory not found — creating: {$remotePath}"); + if (!$sftp->mkdir($remotePath, -1, true)) { + $this->log('ERROR', "Failed to create remote directory: {$remotePath}"); + return 1; + } + } + + $exitCode = $this->uploadDirectory($sftp, $srcDir, $remotePath, $srcDir, $ignores); + + $this->log("Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Deleted: {$this->deleted}, Skipped: {$this->skipped}"); + + return $exitCode; + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + /** + * Resolve the absolute path to the repository root. + * + * @return string Absolute repository path + * @throws \RuntimeException When the path does not exist + */ + private function resolveRepoPath(): string + { + $raw = $this->getArgument('--path', '.'); + $path = realpath($raw); + + if ($path === false || !is_dir($path)) { + $this->error("Repository path does not exist or is not a directory: {$raw}"); + } + + return $path; + } + + /** + * Resolve the source directory that will be uploaded. + * + * @param string $repoPath Absolute repository root + * @return string Absolute path to the source directory + */ + private function resolveSrcDir(string $repoPath): string + { + $sub = $this->getArgument('--src-dir', 'src'); + $dir = $repoPath . DIRECTORY_SEPARATOR . $sub; + + if (!is_dir($dir)) { + $this->error("Source directory does not exist: {$dir}"); + } + + return $dir; + } + + /** Map of --env values to their sftp-config filename. */ + private const ENV_CONFIG_MAP = [ + 'dev' => 'sftp-config.dev.json', + 'rs' => 'sftp-config.rs.json', + ]; + + /** + * Resolve the absolute path to the sftp-config file. + * + * Resolution order (first match wins): + * 1. --config — explicit override + * 2. --env dev|rs — maps to sftp-config.{env}.json in {path}/scripts/sftp-config/ + * 3. sftp-config.json — generic fallback in {path}/scripts/sftp-config/ + * + * @param string $repoPath Absolute repository root + * @return string Absolute path to the config file + */ + private function resolveConfigPath(string $repoPath): string + { + $configDir = $repoPath . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'sftp-config'; + + // 1. Explicit --config wins unconditionally + $explicit = $this->getArgument('--config') ?: null; + if ($explicit !== null) { + $path = realpath($explicit); + if ($path === false) { + $this->error("Config file not found: {$explicit}"); + } + return $path; + } + + // 2. --env selects the named config file + $env = $this->getArgument('--env') ?: null; + if ($env !== null) { + $env = strtolower((string) $env); + if (!isset(self::ENV_CONFIG_MAP[$env])) { + $valid = implode(', ', array_keys(self::ENV_CONFIG_MAP)); + $this->error("Unknown --env value '{$env}'. Valid values: {$valid}", self::EXIT_USAGE); + } + $envConfig = $configDir . DIRECTORY_SEPARATOR . self::ENV_CONFIG_MAP[$env]; + if (!file_exists($envConfig)) { + $this->log('ERROR', "Copy templates/scripts/deploy/sftp-config.{$env}.json.example → {$envConfig}"); + $this->error("Config file not found for --env {$env}: {$envConfig}"); + } + return $envConfig; + } + + // 3. Generic fallback + $default = $configDir . DIRECTORY_SEPARATOR . 'sftp-config.json'; + if (!file_exists($default)) { + $this->log('ERROR', "Use --env dev, --env rs, or --config ."); + $this->error("No config file found. Tried: {$default}"); + } + + return $default; + } + + /** + * Load and parse sftp-config.json, stripping JS-style // comments. + * + * The Sublime Text SFTP plugin allows // comments and trailing commas, + * so we strip those before passing the text to json_decode. + * + * @param string $configPath Absolute path to the config file + * @return bool True on success + */ + private function loadConfig(string $configPath): bool + { + $raw = file_get_contents($configPath); + if ($raw === false) { + $this->log('ERROR', "Cannot read config file: {$configPath}"); + return false; + } + + // Strip // line comments (not inside strings — good enough for this format) + $stripped = preg_replace('#(?log('ERROR', "Failed to parse config file: " . json_last_error_msg()); + return false; + } + + $this->config = $decoded; + return true; + } + + /** + * Validate that required config keys are present. + * + * @return bool True when all required fields exist + */ + private function validateConfig(): bool + { + $required = ['host', 'user', 'remote_path']; + $missing = []; + + foreach ($required as $key) { + if (empty($this->config[$key])) { + $missing[] = $key; + } + } + + if (empty($this->config['ssh_key_file']) && empty($this->config['password'])) { + $missing[] = 'ssh_key_file or password'; + } + + if (!empty($missing)) { + $this->log('ERROR', "Missing required config fields: " . implode(', ', $missing)); + return false; + } + + return true; + } + + /** + * Build the list of ignore regex patterns from config. + * + * @return array Array of PCRE patterns + */ + private function buildIgnorePatterns(): array + { + $raw = $this->config['ignore_regexes'] ?? []; + return array_values(array_filter(array_map('strval', $raw))); + } + + /** + * Load ignore patterns from a .ftpignore file in the source directory. + * + * Follows gitignore syntax: blank lines and # comments are skipped; + * glob wildcards (* ? **) are converted to PCRE; a trailing slash matches + * directories; a leading slash anchors the pattern to the upload root. + * + * @param string $srcDir Absolute path to the source directory being uploaded + * @return array PCRE patterns ready for shouldIgnore() + */ + private function loadFtpIgnorePatterns(string $srcDir): array + { + $ignoreFile = $srcDir . DIRECTORY_SEPARATOR . '.ftpignore'; + if (!is_file($ignoreFile)) { + return []; + } + + $this->log('DEBUG', "Loading ignore rules from .ftpignore"); + + $patterns = []; + $lines = file($ignoreFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return []; + } + + foreach ($lines as $line) { + // Strip inline comments and trim whitespace + $line = trim((string) preg_replace('/#.*$/', '', $line)); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + // Negation patterns (!) are not supported — log and skip + if (str_starts_with($line, '!')) { + $this->log('DEBUG', " .ftpignore: negation patterns not supported, skipping: {$line}"); + continue; + } + + // A trailing slash means "match directories only"; we match the prefix + $line = rtrim($line, '/'); + + // A leading slash anchors to the root; strip it and anchor in regex + $anchored = str_starts_with($line, '/'); + $line = ltrim($line, '/'); + + // Convert glob to PCRE + $regex = preg_quote($line, '/'); + $regex = str_replace('\*\*', '.*', $regex); // ** → any path segment + $regex = str_replace('\*', '[^/]*', $regex); // * → any name chars + $regex = str_replace('\?', '[^/]', $regex); // ? → single char + + $regex = $anchored ? '^' . $regex : '(^|/)' . $regex; + + $patterns[] = $regex . '(/|$)'; + $this->log('DEBUG', " .ftpignore rule: {$line} → /{$regex}/i"); + } + + return $patterns; + } + + /** + * Establish an authenticated SFTP connection. + * + * @param string $host Remote hostname + * @param int $port SSH port + * @param string $user SSH username + * @param string $repoPath Absolute repository root (for key resolution) + * @return SFTP|null Authenticated SFTP object, or null on failure + */ + private function connect(string $host, int $port, string $user, string $repoPath): ?SFTP + { + try { + $sftp = new SFTP($host, $port, timeout: 30); + } catch (\Throwable $e) { + $this->log('ERROR', "Cannot reach {$host}:{$port} — " . $e->getMessage()); + return null; + } + + $rawKeyFile = $this->config['ssh_key_file'] ?? null; + + if (!empty($rawKeyFile)) { + $keyFile = $this->resolveKeyPath((string) $rawKeyFile, $repoPath); + $this->log('DEBUG', "Using SSH key: {$keyFile}"); + return $this->authenticateWithKey($sftp, $user, $keyFile); + } + + // Password fallback + $password = (string) ($this->config['password'] ?? ''); + if (!$sftp->login($user, $password)) { + $this->log('ERROR', "SFTP password authentication failed for {$user}@{$host}"); + return null; + } + + return $sftp; + } + + /** + * Resolve the SSH key file path. + * + * If the configured path is not absolute, look for the file under + * {repo_path}/scripts/keys/ before falling back to the raw value. + * + * @param string $configured Raw value from sftp-config.json + * @param string $repoPath Absolute repository root + * @return string Resolved absolute path + */ + private function resolveKeyPath(string $configured, string $repoPath): string + { + // Already absolute — use as-is + if (str_starts_with($configured, '/') || preg_match('/^[A-Za-z]:[\/\\\\]/', $configured)) { + return $configured; + } + + // Relative path — check scripts/keys/ first + $keysDir = $repoPath . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'keys'; + $candidate = $keysDir . DIRECTORY_SEPARATOR . ltrim($configured, '/\\'); + if (file_exists($candidate)) { + return $candidate; + } + + // Fall back to relative from CWD + return $configured; + } + + /** + * Authenticate the SFTP session using a private key file. + * + * Supports both PuTTY .ppk keys and OpenSSH PEM keys via phpseclib. + * + * @param SFTP $sftp Open SFTP connection + * @param string $user SSH username + * @param string $keyFile Path to the private key file + * @return SFTP|null Authenticated connection, or null on failure + */ + private function authenticateWithKey(SFTP $sftp, string $user, string $keyFile): ?SFTP + { + if (!file_exists($keyFile)) { + $this->log('ERROR', "SSH key file not found: {$keyFile}"); + return null; + } + + $passphrase = $this->getArgument('--key-passphrase') ?: null; + + try { + $keyData = file_get_contents($keyFile); + if ($keyData === false) { + throw new \RuntimeException("Cannot read key file: {$keyFile}"); + } + + $key = $passphrase !== null + ? PublicKeyLoader::load($keyData, $passphrase) + : PublicKeyLoader::load($keyData); + } catch (\Throwable $e) { + $this->log('ERROR', "Failed to load SSH key: " . $e->getMessage()); + return null; + } + + if (!$sftp->login($user, $key)) { + $this->log('ERROR', "SFTP key authentication failed for {$user}"); + return null; + } + + return $sftp; + } + + /** + * Check whether a relative file path should be ignored. + * + * @param string $relativePath Path relative to the upload root + * @param array $patterns PCRE patterns from sftp-config.json + * @return bool True when the file should be skipped + */ + private function shouldIgnore(string $relativePath, array $patterns): bool + { + foreach ($patterns as $pattern) { + if (preg_match('#' . $pattern . '#i', $relativePath) === 1) { + return true; + } + } + return false; + } + + /** + * Recursively upload a local directory to the remote server. + * + * @param SFTP $sftp Authenticated SFTP connection + * @param string $localDir Absolute local directory path + * @param string $remotePath Absolute remote directory path + * @param string $srcRoot Absolute local root (for relative-path calculation) + * @param array $ignorePatterns PCRE patterns to skip + * @return int POSIX exit code (0 = success) + */ + /** + * Smart deploy: upload only changed/new files, delete removed files. + * + * Compares local files against remote by size. If sizes match, compares + * MD5 hashes. Only uploads when content differs. Removes remote files + * that no longer exist locally (respecting ignore patterns). + */ + private function uploadDirectory( + SFTP $sftp, + string $localDir, + string $remotePath, + string $srcRoot, + array $ignorePatterns + ): int { + $entries = scandir($localDir); + if ($entries === false) { + $this->log('ERROR', "Cannot read directory: {$localDir}"); + return 1; + } + + // Build set of local entries for deletion detection + $localEntryNames = []; + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + if (str_starts_with($entry, '.')) { + $this->log('DEBUG', " SKIP {$entry} (dotfile)"); + $this->skipped++; + continue; + } + + $localEntry = $localDir . DIRECTORY_SEPARATOR . $entry; + $remoteEntry = $remotePath . '/' . $entry; + $relative = ltrim(str_replace($srcRoot, '', $localEntry), DIRECTORY_SEPARATOR . '/'); + + if ($this->shouldIgnore($relative, $ignorePatterns)) { + $this->log('DEBUG', " SKIP {$relative}"); + $this->skipped++; + continue; + } + + $localEntryNames[$entry] = true; + + if (is_dir($localEntry)) { + $stat = @$sftp->stat($remoteEntry); + if ($stat === false) { + $this->log('DEBUG', " MKDIR {$remoteEntry}"); + $sftp->mkdir($remoteEntry, -1, true); + } + $result = $this->uploadDirectory($sftp, $localEntry, $remoteEntry, $srcRoot, $ignorePatterns); + if ($result !== 0) { + return $result; + } + } else { + // Smart diff: compare local vs remote before uploading + if ($this->isFileUnchanged($sftp, $localEntry, $remoteEntry)) { + $this->log('DEBUG', " SAME {$relative}"); + $this->unchanged++; + continue; + } + + $this->log(" PUT {$relative} → {$remoteEntry}"); + if (!$sftp->put($remoteEntry, $localEntry, SFTP::SOURCE_LOCAL_FILE)) { + $this->log('ERROR', "Failed to upload: {$relative}"); + return 1; + } + $this->uploaded++; + } + } + + // Delete remote files that no longer exist locally + $remoteEntries = $sftp->nlist($remotePath); + if (is_array($remoteEntries)) { + foreach ($remoteEntries as $remoteEntry) { + if ($remoteEntry === '.' || $remoteEntry === '..') { + continue; + } + if (!isset($localEntryNames[$remoteEntry])) { + $remoteFull = $remotePath . '/' . $remoteEntry; + $relative = ltrim(str_replace($srcRoot, '', $localDir . DIRECTORY_SEPARATOR . $remoteEntry), DIRECTORY_SEPARATOR . '/'); + + // Don't delete files that match ignore patterns + if ($this->shouldIgnore($relative, $ignorePatterns)) { + continue; + } + + $remoteStat = @$sftp->stat($remoteFull); + if ($remoteStat !== false && ($remoteStat['type'] ?? 0) === 2) { + $this->deleteRemoteDirectory($sftp, $remoteFull); + $this->log(" RMDIR {$remoteFull}"); + } else { + $sftp->delete($remoteFull); + $this->log(" DEL {$remoteFull}"); + } + $this->deleted++; + } + } + } + + return 0; + } + + /** + * Check if a local file is identical to its remote counterpart. + * + * Uses size comparison first (fast), then MD5 hash if sizes match. + */ + private function isFileUnchanged(SFTP $sftp, string $localPath, string $remotePath): bool + { + $remoteStat = $sftp->stat($remotePath); + if ($remoteStat === false) { + return false; // Remote file doesn't exist — needs upload + } + + $localSize = filesize($localPath); + $remoteSize = $remoteStat['size'] ?? -1; + + if ($localSize !== $remoteSize) { + return false; // Different sizes — needs upload + } + + // Sizes match — compare MD5 hashes + $localMd5 = md5_file($localPath); + $remoteContent = $sftp->get($remotePath); + if ($remoteContent === false) { + return false; + } + + return $localMd5 === md5($remoteContent); + } + + /** + * Recursively delete a remote directory and all its contents. + */ + private function deleteRemoteDirectory(SFTP $sftp, string $path): void + { + $entries = $sftp->nlist($path); + if (!is_array($entries)) { + return; + } + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $full = "{$path}/{$entry}"; + $entryStat = @$sftp->stat($full); + if ($entryStat !== false && ($entryStat['type'] ?? 0) === 2) { + $this->deleteRemoteDirectory($sftp, $full); + $sftp->rmdir($full); + } else { + $sftp->delete($full); + } + } + $sftp->rmdir($path); + } + + /** + * Walk the source directory and log what would be uploaded, without connecting. + * + * @param string $localDir Absolute local directory path + * @param string $remotePath Remote destination path + * @param string $srcRoot Absolute local root for relative paths + * @param array $ignorePatterns PCRE patterns to skip + * @return int Always 0 in dry-run mode + */ + private function walkAndDryRun( + string $localDir, + string $remotePath, + string $srcRoot, + array $ignorePatterns + ): int { + $entries = scandir($localDir); + if ($entries === false) { + $this->log('ERROR', "Cannot read directory: {$localDir}"); + return 1; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + if (str_starts_with($entry, '.')) { + $this->log("[DRY RUN] SKIP {$entry} (dotfile)"); + $this->skipped++; + continue; + } + + $localEntry = $localDir . DIRECTORY_SEPARATOR . $entry; + $remoteEntry = $remotePath . '/' . $entry; + $relative = ltrim(str_replace($srcRoot, '', $localEntry), DIRECTORY_SEPARATOR . '/'); + + if ($this->shouldIgnore($relative, $ignorePatterns)) { + $this->log("[DRY RUN] SKIP {$relative}"); + $this->skipped++; + continue; + } + + if (is_dir($localEntry)) { + $this->log("[DRY RUN] MKDIR {$remoteEntry}"); + $this->walkAndDryRun($localEntry, $remoteEntry, $srcRoot, $ignorePatterns); + } else { + $this->log("[DRY RUN] PUT {$relative} → {$remoteEntry}"); + $this->uploaded++; + } + } + + return 0; + } } $app = new DeploySftp(); diff --git a/deploy/health-check.php b/deploy/health-check.php index 9e30f30..5666d43 100644 --- a/deploy/health-check.php +++ b/deploy/health-check.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -23,192 +24,192 @@ use MokoEnterprise\CliFramework; class HealthCheckCli extends CliFramework { - private string $url = ''; - private int $timeout = 30; - private array $checks = ['http']; + private string $url = ''; + private int $timeout = 30; + private array $checks = ['http']; - private int $passed = 0; - private int $failed = 0; + private int $passed = 0; + private int $failed = 0; - protected function configure(): void - { - $this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly'); - $this->addArgument('--url', 'Site URL to check', ''); - $this->addArgument('--timeout', 'Request timeout in seconds', '30'); - $this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http'); - } + protected function configure(): void + { + $this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly'); + $this->addArgument('--url', 'Site URL to check', ''); + $this->addArgument('--timeout', 'Request timeout in seconds', '30'); + $this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http'); + } - protected function run(): int - { - $this->url = $this->getArgument('--url'); - $this->timeout = (int) $this->getArgument('--timeout'); - $checksRaw = $this->getArgument('--checks'); - $this->checks = array_map('trim', explode(',', $checksRaw)); + protected function run(): int + { + $this->url = $this->getArgument('--url'); + $this->timeout = (int) $this->getArgument('--timeout'); + $checksRaw = $this->getArgument('--checks'); + $this->checks = array_map('trim', explode(',', $checksRaw)); - if ($this->url === '') { - $this->log('ERROR', 'Usage: health-check.php --url [--timeout ] [--checks ]'); - return 1; - } + if ($this->url === '') { + $this->log('ERROR', 'Usage: health-check.php --url [--timeout ] [--checks ]'); + return 1; + } - $this->url = rtrim($this->url, '/'); + $this->url = rtrim($this->url, '/'); - $this->log('INFO', "Health check for: {$this->url}"); - $this->log('INFO', "Timeout: {$this->timeout}s"); - $this->log('INFO', "Checks: " . implode(', ', $this->checks)); - $this->log('INFO', ''); + $this->log('INFO', "Health check for: {$this->url}"); + $this->log('INFO', "Timeout: {$this->timeout}s"); + $this->log('INFO', "Checks: " . implode(', ', $this->checks)); + $this->log('INFO', ''); - 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('WARN', "UNKNOWN CHECK: {$check} — skipping"); - break; - } - } + 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('WARN', "UNKNOWN CHECK: {$check} — skipping"); + break; + } + } - $this->log('INFO', ''); - $this->log('INFO', "Results: {$this->passed} passed, {$this->failed} failed"); + $this->log('INFO', ''); + $this->log('INFO', "Results: {$this->passed} passed, {$this->failed} failed"); - return $this->failed > 0 ? 1 : 0; - } + return $this->failed > 0 ? 1 : 0; + } - private function checkHttp(): void - { - $this->log('INFO', '[http] GET ' . $this->url); + private function checkHttp(): void + { + $this->log('INFO', '[http] GET ' . $this->url); - $result = $this->curlGet($this->url); + $result = $this->curlGet($this->url); - if ($result === null) { - $this->fail('http', 'Request failed — could not connect'); - return; - } + 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 ($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; - } + 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)"); - } + $this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)"); + } - private function checkAdmin(): void - { - $adminUrl = $this->url . '/administrator/'; - $this->log('INFO', '[admin] GET ' . $adminUrl); + private function checkAdmin(): void + { + $adminUrl = $this->url . '/administrator/'; + $this->log('INFO', '[admin] GET ' . $adminUrl); - $result = $this->curlGet($adminUrl); + $result = $this->curlGet($adminUrl); - if ($result === null) { - $this->fail('admin', 'Request failed — could not connect'); - return; - } + 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; - } + 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)"); - } + $this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)"); + } - private function checkApi(): void - { - $apiUrl = $this->url . '/api/index.php/v1'; - $this->log('INFO', '[api] GET ' . $apiUrl); + private function checkApi(): void + { + $apiUrl = $this->url . '/api/index.php/v1'; + $this->log('INFO', '[api] GET ' . $apiUrl); - $result = $this->curlGet($apiUrl); + $result = $this->curlGet($apiUrl); - if ($result === null) { - $this->fail('api', 'Request failed — could not connect'); - return; - } + 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; - } + 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)"); - } + $this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)"); + } - private function curlGet(string $url): ?array - { - $ch = curl_init(); + 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', - ]); + 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); + $body = curl_exec($ch); - if (curl_errno($ch)) { - $error = curl_error($ch); - $this->log('ERROR', " cURL error: {$error}"); - curl_close($ch); - return null; - } + if (curl_errno($ch)) { + $error = curl_error($ch); + $this->log('ERROR', " 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); + $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), - ]; - } + 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:', - ]; + 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; - } - } + foreach ($patterns as $pattern) { + if (stripos($body, $pattern) !== false) { + return true; + } + } - return false; - } + return false; + } - private function pass(string $check, string $message): void - { - $this->passed++; - $this->log('INFO', "[{$check}] PASS: {$message}"); - } + private function pass(string $check, string $message): void + { + $this->passed++; + $this->log('INFO', "[{$check}] PASS: {$message}"); + } - private function fail(string $check, string $message): void - { - $this->failed++; - $this->log('ERROR', "[{$check}] FAIL: {$message}"); - } + private function fail(string $check, string $message): void + { + $this->failed++; + $this->log('ERROR', "[{$check}] FAIL: {$message}"); + } } $app = new HealthCheckCli(); diff --git a/deploy/rollback-joomla.php b/deploy/rollback-joomla.php index cfa6cd0..df3da35 100644 --- a/deploy/rollback-joomla.php +++ b/deploy/rollback-joomla.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -23,164 +24,164 @@ use MokoEnterprise\CliFramework; class RollbackJoomlaCli extends CliFramework { - private const JOOMLA_DIRS = [ - 'administrator/components', - 'administrator/language', - 'administrator/modules', - 'administrator/templates', - 'components', - 'language', - 'layouts', - 'libraries', - 'media', - 'modules', - 'plugins', - 'templates', - ]; + private const JOOMLA_DIRS = [ + 'administrator/components', + 'administrator/language', + 'administrator/modules', + 'administrator/templates', + 'components', + 'language', + 'layouts', + 'libraries', + 'media', + 'modules', + 'plugins', + 'templates', + ]; - protected function configure(): void - { - $this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot'); - $this->addArgument('--config', 'Path to sftp-config.json', ''); - $this->addArgument('--snapshot-dir', 'Path to snapshot directory', ''); - } + protected function configure(): void + { + $this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot'); + $this->addArgument('--config', 'Path to sftp-config.json', ''); + $this->addArgument('--snapshot-dir', 'Path to snapshot directory', ''); + } - protected function run(): int - { - $configPath = $this->getArgument('--config'); - $snapshotDir = $this->getArgument('--snapshot-dir'); + protected function run(): int + { + $configPath = $this->getArgument('--config'); + $snapshotDir = $this->getArgument('--snapshot-dir'); - if ($configPath === '' || $snapshotDir === '') { - $this->log('ERROR', 'Usage: rollback-joomla.php --config --snapshot-dir [--dry-run] [--verbose]'); - return 1; - } + if ($configPath === '' || $snapshotDir === '') { + $this->log('ERROR', 'Usage: rollback-joomla.php --config --snapshot-dir [--dry-run] [--verbose]'); + return 1; + } - if (!is_dir($snapshotDir)) { - $this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}"); - return 1; - } + if (!is_dir($snapshotDir)) { + $this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}"); + return 1; + } - $config = $this->loadConfig($configPath); - if ($config === null) { - return 1; - } + $config = $this->loadConfig($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'] ?? ''; + $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; - } + if ($host === '' || $user === '' || $remotePath === '') { + $this->log('ERROR', 'Config must contain host, user, and remote_path.'); + return 1; + } - $this->log('INFO', 'Starting Joomla rollback from snapshot...'); - $this->log('INFO', "Snapshot: {$snapshotDir}"); - $this->log('INFO', "Target: {$user}@{$host}:{$remotePath}"); + $this->log('INFO', 'Starting Joomla rollback from snapshot...'); + $this->log('INFO', "Snapshot: {$snapshotDir}"); + $this->log('INFO', "Target: {$user}@{$host}:{$remotePath}"); - if ($this->dryRun) { - $this->log('INFO', '*** DRY RUN — no changes will be made ***'); - } + if ($this->dryRun) { + $this->log('INFO', '*** DRY RUN — no changes will be made ***'); + } - $failed = 0; + $failed = 0; - foreach (self::JOOMLA_DIRS as $dir) { - $localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/'; + foreach (self::JOOMLA_DIRS as $dir) { + $localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/'; - if (!is_dir($localDir)) { - if ($this->verbose) { - $this->log('INFO', "SKIP: {$dir} (not present in snapshot)"); - } - continue; - } + if (!is_dir($localDir)) { + if ($this->verbose) { + $this->log('INFO', "SKIP: {$dir} (not present in snapshot)"); + } + continue; + } - $remoteTarget = "{$remotePath}/{$dir}/"; - $sshCmd = "ssh -p {$port}"; - if ($sshKey !== '') { - $sshCmd .= " -i " . escapeshellarg($sshKey); - } + $remoteTarget = "{$remotePath}/{$dir}/"; + $sshCmd = "ssh -p {$port}"; + if ($sshKey !== '') { + $sshCmd .= " -i " . escapeshellarg($sshKey); + } - $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); + $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); - $this->log('INFO', "Restoring: {$dir}"); - if ($this->verbose) { - $this->log('INFO', "CMD: {$cmd}"); - } + $this->log('INFO', "Restoring: {$dir}"); + if ($this->verbose) { + $this->log('INFO', "CMD: {$cmd}"); + } - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); + $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('ERROR', " {$line}"); - } - $failed++; - } else { - if ($this->verbose) { - foreach ($output as $line) { - $this->log('INFO', " {$line}"); - } - } - } - } + if ($exitCode !== 0) { + $this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})"); + foreach ($output as $line) { + $this->log('ERROR', " {$line}"); + } + $failed++; + } else { + if ($this->verbose) { + foreach ($output as $line) { + $this->log('INFO', " {$line}"); + } + } + } + } - if ($failed > 0) { - $this->log('ERROR', "Rollback completed with {$failed} error(s)."); - return 1; - } + if ($failed > 0) { + $this->log('ERROR', "Rollback completed with {$failed} error(s)."); + return 1; + } - $this->log('INFO', 'Rollback completed successfully.'); - return 0; - } + $this->log('INFO', 'Rollback completed successfully.'); + return 0; + } - private function loadConfig(string $path): ?array - { - if (!is_file($path)) { - $this->log('ERROR', "Config file not found: {$path}"); - 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; - } + $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); + // 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; - } + if (!is_array($config)) { + $this->log('ERROR', 'Invalid JSON in config file.'); + return null; + } - return $config; - } + return $config; + } - private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string - { - $parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php']; + 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->dryRun) { + $parts[] = '--dry-run'; + } - if ($this->verbose) { - $parts[] = '-v'; - } + if ($this->verbose) { + $parts[] = '-v'; + } - $parts[] = '-e'; - $parts[] = escapeshellarg($sshCmd); - $parts[] = escapeshellarg($source); - $parts[] = escapeshellarg($dest); + $parts[] = '-e'; + $parts[] = escapeshellarg($sshCmd); + $parts[] = escapeshellarg($source); + $parts[] = escapeshellarg($dest); - return implode(' ', $parts); - } + return implode(' ', $parts); + } } $app = new RollbackJoomlaCli(); diff --git a/deploy/sync-joomla.php b/deploy/sync-joomla.php index 419c9ed..34101e4 100644 --- a/deploy/sync-joomla.php +++ b/deploy/sync-joomla.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. diff --git a/maintenance/pin_action_shas.php b/maintenance/pin_action_shas.php index aea1ca7..dbee8a8 100644 --- a/maintenance/pin_action_shas.php +++ b/maintenance/pin_action_shas.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * @@ -136,11 +137,13 @@ class PinActionShasCli extends CliFramework */ private function processLine(string $line, string $file, int $lineNum): ?string { - if (!preg_match( - '/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/', - $line, - $m - )) { + if ( + !preg_match( + '/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/', + $line, + $m + ) + ) { return null; } diff --git a/maintenance/repo_inventory.php b/maintenance/repo_inventory.php index 92db2df..73352bd 100644 --- a/maintenance/repo_inventory.php +++ b/maintenance/repo_inventory.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -22,125 +23,216 @@ use MokoEnterprise\CliFramework; class RepoInventoryCli extends CliFramework { - private $api = null; - private string $token = ''; - private $platformConfig = null; - private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; + private $api = null; + private string $token = ''; + private $platformConfig = null; + private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; - protected function configure(): void - { - $this->setDescription('Generate a live inventory dashboard of all governed repos'); - $this->addArgument('--org', 'Organization', 'mokoconsulting-tech'); - $this->addArgument('--json', 'JSON output to stdout', false); - } + protected function configure(): void + { + $this->setDescription('Generate a live inventory dashboard of all governed repos'); + $this->addArgument('--org', 'Organization', 'mokoconsulting-tech'); + $this->addArgument('--json', 'JSON output to stdout', false); + } - protected function initialize(): void - { - $this->platformConfig = \MokoEnterprise\Config::load(); - try { - $adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig); - $this->api = $adapter->getApiClient(); - } catch (\Exception $e) { - $this->log('ERROR', "Platform init failed: " . $e->getMessage()); - exit(1); - } - $this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea' - ? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', ''); - } + protected function initialize(): void + { + $this->platformConfig = \MokoEnterprise\Config::load(); + try { + $adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig); + $this->api = $adapter->getApiClient(); + } catch (\Exception $e) { + $this->log('ERROR', "Platform init failed: " . $e->getMessage()); + exit(1); + } + $this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea' + ? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', ''); + } - protected function run(): int - { - $org = $this->getArgument('--org'); - $jsonOut = (bool) $this->getArgument('--json'); - if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } - $allRepos = []; $page = 1; - do { - [$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null); - $allRepos = array_merge($allRepos, $batch); $page++; - } while (count($batch) === 100); - if (!$jsonOut) { echo "Found " . count($allRepos) . " total repositories\n\n"; } + protected function run(): int + { + $org = $this->getArgument('--org'); + $jsonOut = (bool) $this->getArgument('--json'); + if (!$jsonOut) { + echo "Fetching repositories from {$org}...\n"; + } + $allRepos = []; + $page = 1; + do { + [$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null); + $allRepos = array_merge($allRepos, $batch); + $page++; + } while (count($batch) === 100); + if (!$jsonOut) { + echo "Found " . count($allRepos) . " total repositories\n\n"; + } - $inventory = []; - foreach ($allRepos as $repo) { - $name = $repo['name']; - if (in_array($name, self::ALWAYS_EXCLUDE, true)) { continue; } - $entry = ['name' => $name, 'visibility' => $repo['private'] ? 'private' : 'public', 'archived' => $repo['archived'] ?? false, 'platform' => '-', 'version' => '-', 'last_push' => $repo['pushed_at'] ?? '-', 'open_issues' => $repo['open_issues_count'] ?? 0, 'has_project' => false, 'rulesets' => 0]; - if ($entry['archived']) { $inventory[] = $entry; continue; } - foreach (['.github/.mokostandards', '.mokostandards'] as $path) { - [$status, $data] = $this->ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null); - if ($status === 200 && !empty($data['content'])) { - $content = base64_decode($data['content']); - if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { $entry['platform'] = trim($m[1], " \t\n\r\"'"); } - if (preg_match('/^version:\s*(.+)/m', $content, $m)) { $entry['version'] = trim($m[1], " \t\n\r\"'"); } - break; - } - } - [$status, $rulesets] = $this->ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null); - if ($status === 200 && is_array($rulesets)) { $entry['rulesets'] = count($rulesets); } - $gql = $this->graphql('query($owner:String!,$name:String!){repository(owner:$owner,name:$name){projectsV2(first:1){totalCount}}}', ['owner' => $org, 'name' => $name]); - $entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0; - $inventory[] = $entry; - if (!$jsonOut) { echo " {$name}: {$entry['platform']} | v{$entry['version']} | rulesets:{$entry['rulesets']} | project:" . ($entry['has_project'] ? 'yes' : 'no') . "\n"; } - } + $inventory = []; + foreach ($allRepos as $repo) { + $name = $repo['name']; + if (in_array($name, self::ALWAYS_EXCLUDE, true)) { + continue; + } + $entry = [ + 'name' => $name, + 'visibility' => $repo['private'] ? 'private' : 'public', + 'archived' => $repo['archived'] ?? false, + 'platform' => '-', + 'version' => '-', + 'last_push' => $repo['pushed_at'] ?? '-', + 'open_issues' => $repo['open_issues_count'] ?? 0, + 'has_project' => false, + 'rulesets' => 0, + ]; + if ($entry['archived']) { + $inventory[] = $entry; + continue; + } + foreach (['.github/.mokostandards', '.mokostandards'] as $path) { + [$status, $data] = $this->ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null); + if ($status === 200 && !empty($data['content'])) { + $content = base64_decode($data['content']); + if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { + $entry['platform'] = trim($m[1], " \t\n\r\"'"); + } + if (preg_match('/^version:\s*(.+)/m', $content, $m)) { + $entry['version'] = trim($m[1], " \t\n\r\"'"); + } + break; + } + } + [$status, $rulesets] = $this->ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null); + if ($status === 200 && is_array($rulesets)) { + $entry['rulesets'] = count($rulesets); + } + $gql = $this->graphql( + 'query($owner:String!,$name:String!)' + . '{repository(owner:$owner,name:$name)' + . '{projectsV2(first:1){totalCount}}}', + ['owner' => $org, 'name' => $name] + ); + $entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0; + $inventory[] = $entry; + if (!$jsonOut) { + $proj = $entry['has_project'] ? 'yes' : 'no'; + echo " {$name}: {$entry['platform']}" + . " | v{$entry['version']}" + . " | rulesets:{$entry['rulesets']}" + . " | project:{$proj}\n"; + } + } - if ($jsonOut) { echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n"; return 0; } + if ($jsonOut) { + echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n"; + return 0; + } - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - $active = array_filter($inventory, fn($r) => !$r['archived']); - $archived = array_filter($inventory, fn($r) => $r['archived']); - $withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3)); - $withProj = count(array_filter($active, fn($r) => $r['has_project'])); - $activeN = count($active); $archivedN = count($archived); - $rows = []; - foreach ($inventory as $r) { - $vis = $r['visibility'] === 'private' ? 'prv' : 'pub'; - $arch = $r['archived'] ? ' archived' : ''; - $proj = $r['has_project'] ? 'yes' : '-'; - $rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3"); - $rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |"; - } - $table = implode("\n", $rows); - $body = "## Repository Inventory Dashboard\n\n**Organisation:** `{$org}`\n**Generated:** {$now}\n**Active:** {$activeN} | **Archived:** {$archivedN} | **Rulesets 3/3:** {$withRules} | **Projects:** {$withProj}\n\n| Repository | Visibility | Platform | Version | Rulesets | Project | Issues |\n|---|---|---|---|---|---|---|\n{$table}\n\n---\n*Auto-generated by `repo_inventory.php`*\n"; - echo "\n" . str_repeat('-', 50) . "\n"; - echo "Active: {$activeN} | Archived: {$archivedN} | Rulesets 3/3: {$withRules} | Projects: {$withProj}\n"; + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $active = array_filter($inventory, fn($r) => !$r['archived']); + $archived = array_filter($inventory, fn($r) => $r['archived']); + $withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3)); + $withProj = count(array_filter($active, fn($r) => $r['has_project'])); + $activeN = count($active); + $archivedN = count($archived); + $rows = []; + foreach ($inventory as $r) { + $vis = $r['visibility'] === 'private' ? 'prv' : 'pub'; + $arch = $r['archived'] ? ' archived' : ''; + $proj = $r['has_project'] ? 'yes' : '-'; + $rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3"); + $rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |"; + } + $table = implode("\n", $rows); + $body = "## Repository Inventory Dashboard\n\n" + . "**Organisation:** `{$org}`\n" + . "**Generated:** {$now}\n" + . "**Active:** {$activeN} | **Archived:** {$archivedN}" + . " | **Rulesets 3/3:** {$withRules}" + . " | **Projects:** {$withProj}\n\n" + . "| Repository | Visibility | Platform" + . " | Version | Rulesets | Project | Issues |\n" + . "|---|---|---|---|---|---|---|\n" + . "{$table}\n\n---\n" + . "*Auto-generated by `repo_inventory.php`*\n"; + echo "\n" . str_repeat('-', 50) . "\n"; + echo "Active: {$activeN} | Archived: {$archivedN}" + . " | Rulesets 3/3: {$withRules}" + . " | Projects: {$withProj}\n"; - if (!$this->dryRun) { - $title = "dashboard: repository inventory ({$org})"; - [$_, $existing] = $this->ghApi('GET', "repos/{$org}/moko-platform/issues?labels=inventory&state=all&per_page=1&sort=created&direction=desc", null); - if (!empty($existing[0]['number'])) { - $num = $existing[0]['number']; - $this->ghApi('PATCH', "repos/{$org}/moko-platform/issues/{$num}", ['title' => $title, 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']]); - echo "Updated inventory issue #{$num}\n"; - } else { - [$_, $issue] = $this->ghApi('POST', "repos/{$org}/moko-platform/issues", ['title' => $title, 'body' => $body, 'labels' => ['inventory', 'type: chore', 'automation'], 'assignees' => ['jmiller']]); - echo "Created inventory issue #{$issue['number']}\n"; - } - } else { echo "(dry-run) would post inventory dashboard issue\n"; } - return 0; - } + if (!$this->dryRun) { + $title = "dashboard: repository inventory ({$org})"; + $issueQuery = "repos/{$org}/moko-platform/issues" + . "?labels=inventory&state=all&per_page=1" + . "&sort=created&direction=desc"; + [$_, $existing] = $this->ghApi('GET', $issueQuery, null); + if (!empty($existing[0]['number'])) { + $num = $existing[0]['number']; + $this->ghApi( + 'PATCH', + "repos/{$org}/moko-platform/issues/{$num}", + [ + 'title' => $title, + 'body' => $body, + 'state' => 'open', + 'assignees' => ['jmiller'], + ] + ); + echo "Updated inventory issue #{$num}\n"; + } else { + [$_, $issue] = $this->ghApi( + 'POST', + "repos/{$org}/moko-platform/issues", + [ + 'title' => $title, + 'body' => $body, + 'labels' => ['inventory', 'type: chore', 'automation'], + 'assignees' => ['jmiller'], + ] + ); + echo "Created inventory issue #{$issue['number']}\n"; + } + } else { + echo "(dry-run) would post inventory dashboard issue\n"; + } + return 0; + } - private function ghApi(string $method, string $path, ?array $body): array - { - try { - $result = match ($method) { - 'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []), - 'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []), - 'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported: {$method}"), - }; - return [200, $result]; - } catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; } - } + private function ghApi(string $method, string $path, ?array $body): array + { + try { + $result = match ($method) { + 'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []), + 'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []), + 'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported: {$method}"), + }; + return [200, $result]; + } catch (\Exception $e) { + return [500, ['message' => $e->getMessage()]]; + } + } - private function graphql(string $query, array $variables): array - { - $pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea'; - if ($pf !== 'github') { return []; } - $payload = json_encode(['query' => $query, 'variables' => $variables]); - $ch = curl_init('https://api.github.com/graphql'); - curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Authorization: bearer ' . $this->token, 'Content-Type: application/json', 'User-Agent: moko-platform-Inventory']]); - $body = (string) curl_exec($ch); curl_close($ch); - return json_decode($body, true)['data'] ?? []; - } + private function graphql(string $query, array $variables): array + { + $pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea'; + if ($pf !== 'github') { + return []; + } + $payload = json_encode(['query' => $query, 'variables' => $variables]); + $ch = curl_init('https://api.github.com/graphql'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Authorization: bearer ' . $this->token, + 'Content-Type: application/json', + 'User-Agent: moko-platform-Inventory', + ], + ]); + $body = (string) curl_exec($ch); + curl_close($ch); + return json_decode($body, true)['data'] ?? []; + } } $app = new RepoInventoryCli(); diff --git a/maintenance/rotate_secrets.php b/maintenance/rotate_secrets.php index 19a9e31..e145955 100644 --- a/maintenance/rotate_secrets.php +++ b/maintenance/rotate_secrets.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -22,164 +23,242 @@ use MokoEnterprise\CliFramework; class RotateSecretsCli extends CliFramework { - private $api = null; - private string $token = ''; - private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; - private const ENVS = [ - 'DEV' => ['vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'], 'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD']], - 'DEMO' => ['vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'], 'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD']], - 'RS' => ['vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'], 'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD']], - ]; + private $api = null; + private string $token = ''; + private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; + private const ENVS = [ + 'DEV' => [ + 'vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'], + 'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD'], + ], + 'DEMO' => [ + 'vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'], + 'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD'], + ], + 'RS' => [ + 'vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'], + 'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD'], + ], + ]; - protected function configure(): void - { - $this->setDescription('Audit FTP secrets and variables across all governed repos'); - $this->addArgument('--all', 'Audit all repos', false); - $this->addArgument('--repo', 'Single repo name', null); - $this->addArgument('--org', 'Organization', 'mokoconsulting-tech'); - $this->addArgument('--json', 'JSON output', false); - $this->addArgument('--create-issue', 'Post results as issue', false); - } + protected function configure(): void + { + $this->setDescription('Audit FTP secrets and variables across all governed repos'); + $this->addArgument('--all', 'Audit all repos', false); + $this->addArgument('--repo', 'Single repo name', null); + $this->addArgument('--org', 'Organization', 'mokoconsulting-tech'); + $this->addArgument('--json', 'JSON output', false); + $this->addArgument('--create-issue', 'Post results as issue', false); + } - protected function initialize(): void - { - $config = \MokoEnterprise\Config::load(); - try { - $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); - $this->api = $adapter->getApiClient(); - } catch (\Exception $e) { - $this->log('ERROR', "Platform init failed: " . $e->getMessage()); - exit(1); - } - $this->token = $config->getString('platform', 'gitea') === 'gitea' - ? $config->getString('gitea.token', '') - : $config->getString('github.token', ''); - } + protected function initialize(): void + { + $config = \MokoEnterprise\Config::load(); + try { + $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); + $this->api = $adapter->getApiClient(); + } catch (\Exception $e) { + $this->log('ERROR', "Platform init failed: " . $e->getMessage()); + exit(1); + } + $this->token = $config->getString('platform', 'gitea') === 'gitea' + ? $config->getString('gitea.token', '') + : $config->getString('github.token', ''); + } - protected function run(): int - { - $allMode = (bool) $this->getArgument('--all'); - $jsonOut = (bool) $this->getArgument('--json'); - $createIssue = (bool) $this->getArgument('--create-issue'); - $org = $this->getArgument('--org'); - $repoName = $this->getArgument('--repo'); + protected function run(): int + { + $allMode = (bool) $this->getArgument('--all'); + $jsonOut = (bool) $this->getArgument('--json'); + $createIssue = (bool) $this->getArgument('--create-issue'); + $org = $this->getArgument('--org'); + $repoName = $this->getArgument('--repo'); - if (!$repoName && !$allMode) { - $this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo [--json] [--create-issue]"); - return 2; - } + if (!$repoName && !$allMode) { + $this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo [--json] [--create-issue]"); + return 2; + } - $repos = []; - if ($allMode) { - if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } - $page = 1; - do { - [$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null); - foreach ($batch as $r) { - if (!($r['archived'] ?? false) && !in_array($r['name'], self::ALWAYS_EXCLUDE, true)) { - $repos[] = $r['name']; - } - } - $page++; - } while (count($batch) === 100); - sort($repos); - if (!$jsonOut) { echo "Found " . count($repos) . " repositories\n\n"; } - } else { - $repos = [$repoName]; - } + $repos = []; + if ($allMode) { + if (!$jsonOut) { + echo "Fetching repositories from {$org}...\n"; + } + $page = 1; + do { + [$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null); + foreach ($batch as $r) { + if (!($r['archived'] ?? false) && !in_array($r['name'], self::ALWAYS_EXCLUDE, true)) { + $repos[] = $r['name']; + } + } + $page++; + } while (count($batch) === 100); + sort($repos); + if (!$jsonOut) { + echo "Found " . count($repos) . " repositories\n\n"; + } + } else { + $repos = [$repoName]; + } - $results = []; - $issueCount = 0; + $results = []; + $issueCount = 0; - foreach ($repos as $repo) { - $fullRepo = "{$org}/{$repo}"; - $repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables'); - $repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets'); - $result = ['repo' => $repo, 'envs' => [], 'missing' => []]; + foreach ($repos as $repo) { + $fullRepo = "{$org}/{$repo}"; + $repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables'); + $repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets'); + $result = ['repo' => $repo, 'envs' => [], 'missing' => []]; - foreach (self::ENVS as $env => $envConfig) { - $missingVars = array_diff($envConfig['vars'], $repoVars); - $hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets)); - $hostVar = "{$env}_FTP_HOST"; - $configured = in_array($hostVar, $repoVars, true); - $result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth]; - if ($configured) { - foreach ($missingVars as $v) { - if ($v !== "{$env}_FTP_SUFFIX") { $result['missing'][] = "{$env}: missing {$v}"; $issueCount++; } - } - if (!$hasAuth) { $result['missing'][] = "{$env}: no auth key/password"; $issueCount++; } - } - } + foreach (self::ENVS as $env => $envConfig) { + $missingVars = array_diff($envConfig['vars'], $repoVars); + $hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets)); + $hostVar = "{$env}_FTP_HOST"; + $configured = in_array($hostVar, $repoVars, true); + $result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth]; + if ($configured) { + foreach ($missingVars as $v) { + if ($v !== "{$env}_FTP_SUFFIX") { + $result['missing'][] = "{$env}: missing {$v}"; + $issueCount++; + } + } + if (!$hasAuth) { + $result['missing'][] = "{$env}: no auth key/password"; + $issueCount++; + } + } + } - if (!$jsonOut) { - $parts = []; - foreach (self::ENVS as $env => $_) { - $e = $result['envs'][$env]; - if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) { $parts[] = "{$env}:OK"; } - elseif ($e['configured']) { $parts[] = "{$env}:INCOMPLETE"; } - else { $parts[] = "{$env}:--"; } - } - echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n"; - } - $results[] = $result; - } + if (!$jsonOut) { + $parts = []; + foreach (self::ENVS as $env => $_) { + $e = $result['envs'][$env]; + if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) { + $parts[] = "{$env}:OK"; + } elseif ($e['configured']) { + $parts[] = "{$env}:INCOMPLETE"; + } else { + $parts[] = "{$env}:--"; + } + } + echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n"; + } + $results[] = $result; + } - if ($jsonOut) { - echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; - } else { - echo "\n" . str_repeat('-', 50) . "\n"; - $total = count($results); - $devReady = count(array_filter($results, fn($r) => ($r['envs']['DEV']['configured'] ?? false) && ($r['envs']['DEV']['has_auth'] ?? false))); - $demoReady = count(array_filter($results, fn($r) => ($r['envs']['DEMO']['configured'] ?? false) && ($r['envs']['DEMO']['has_auth'] ?? false))); - $rsReady = count(array_filter($results, fn($r) => ($r['envs']['RS']['configured'] ?? false) && ($r['envs']['RS']['has_auth'] ?? false))); - echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n"; - } + if ($jsonOut) { + echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; + } else { + echo "\n" . str_repeat('-', 50) . "\n"; + $total = count($results); + $devReady = count(array_filter( + $results, + fn($r) => ($r['envs']['DEV']['configured'] ?? false) + && ($r['envs']['DEV']['has_auth'] ?? false) + )); + $demoReady = count(array_filter( + $results, + fn($r) => ($r['envs']['DEMO']['configured'] ?? false) + && ($r['envs']['DEMO']['has_auth'] ?? false) + )); + $rsReady = count(array_filter( + $results, + fn($r) => ($r['envs']['RS']['configured'] ?? false) + && ($r['envs']['RS']['has_auth'] ?? false) + )); + echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n"; + } - if ($createIssue && $issueCount > 0) { - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - $rows = []; - foreach ($results as $r) { - foreach ($r['missing'] as $m) { $rows[] = "| `{$r['repo']}` | {$m} |"; } - } - $table = implode("\n", $rows); - $body = "## FTP Secret/Variable Audit\n\n**Date:** {$now}\n**Issues:** {$issueCount}\n\n| Repository | Issue |\n|---|---|\n{$table}\n\n---\n*Auto-created by `rotate_secrets.php`*\n"; - [$_, $existing] = $this->ghApi('GET', "repos/{$org}/moko-platform/issues?labels=secret-audit&state=all&per_page=1&sort=created&direction=desc", null); - if (!empty($existing[0]['number'])) { - $num = $existing[0]['number']; - $this->ghApi('PATCH', "repos/{$org}/moko-platform/issues/{$num}", ['title' => "audit: FTP secrets -- {$issueCount} issues", 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']]); - if (!$jsonOut) { echo "Updated audit issue #{$num}\n"; } - } else { - [$_, $issue] = $this->ghApi('POST', "repos/{$org}/moko-platform/issues", ['title' => "audit: FTP secrets -- {$issueCount} issues", 'body' => $body, 'labels' => ['secret-audit', 'type: chore', 'automation'], 'assignees' => ['jmiller']]); - if (!$jsonOut) { echo "Created audit issue #{$issue['number']}\n"; } - } - } - return $issueCount > 0 ? 1 : 0; - } + if ($createIssue && $issueCount > 0) { + $now = gmdate('Y-m-d H:i:s') . ' UTC'; + $rows = []; + foreach ($results as $r) { + foreach ($r['missing'] as $m) { + $rows[] = "| `{$r['repo']}` | {$m} |"; + } + } + $table = implode("\n", $rows); + $body = "## FTP Secret/Variable Audit\n\n" + . "**Date:** {$now}\n" + . "**Issues:** {$issueCount}\n\n" + . "| Repository | Issue |\n|---|---|\n" + . "{$table}\n\n---\n" + . "*Auto-created by `rotate_secrets.php`*\n"; + $auditQuery = "repos/{$org}/moko-platform/issues" + . "?labels=secret-audit&state=all" + . "&per_page=1&sort=created&direction=desc"; + [$_, $existing] = $this->ghApi('GET', $auditQuery, null); + $auditTitle = "audit: FTP secrets" + . " -- {$issueCount} issues"; + if (!empty($existing[0]['number'])) { + $num = $existing[0]['number']; + $this->ghApi( + 'PATCH', + "repos/{$org}/moko-platform/issues/{$num}", + [ + 'title' => $auditTitle, + 'body' => $body, + 'state' => 'open', + 'assignees' => ['jmiller'], + ] + ); + if (!$jsonOut) { + echo "Updated audit issue #{$num}\n"; + } + } else { + [$_, $issue] = $this->ghApi( + 'POST', + "repos/{$org}/moko-platform/issues", + [ + 'title' => $auditTitle, + 'body' => $body, + 'labels' => ['secret-audit', 'type: chore', 'automation'], + 'assignees' => ['jmiller'], + ] + ); + if (!$jsonOut) { + echo "Created audit issue #{$issue['number']}\n"; + } + } + } + return $issueCount > 0 ? 1 : 0; + } - private function ghApi(string $method, string $path, ?array $body): array - { - try { - $result = match ($method) { - 'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []), - 'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []), - 'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"), - }; - return [200, $result]; - } catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; } - } + private function ghApi(string $method, string $path, ?array $body): array + { + try { + $result = match ($method) { + 'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []), + 'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []), + 'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"), + }; + return [200, $result]; + } catch (\Exception $e) { + return [500, ['message' => $e->getMessage()]]; + } + } - private function listNames(string $path, string $key): array - { - $names = []; $page = 1; - do { - [$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null); - if ($status !== 200) { break; } - $items = ($key === '') ? $data : ($data[$key] ?? []); - foreach ($items as $item) { if (isset($item['name'])) { $names[] = $item['name']; } } - $page++; - } while (count($items) === 100); - return $names; - } + private function listNames(string $path, string $key): array + { + $names = []; + $page = 1; + do { + [$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null); + if ($status !== 200) { + break; + } + $items = ($key === '') ? $data : ($data[$key] ?? []); + foreach ($items as $item) { + if (isset($item['name'])) { + $names[] = $item['name']; + } + } + $page++; + } while (count($items) === 100); + return $names; + } } $app = new RotateSecretsCli(); diff --git a/maintenance/setup_labels.php b/maintenance/setup_labels.php index f03078b..e2a131e 100644 --- a/maintenance/setup_labels.php +++ b/maintenance/setup_labels.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * REQUIRED FILE: This file must be present in all moko-platform-compliant repositories @@ -33,218 +34,218 @@ use MokoEnterprise\PlatformAdapterFactory; */ class SetupLabels extends CliFramework { - private ?GitPlatformAdapter $adapter = null; - /** - * Label definitions — [name, hexColor (no #), description]. - * - * @var list - */ - private const LABELS = [ - // Project Type - ['joomla', '7F52FF', 'Joomla extension or component'], - ['dolibarr', 'FF6B6B', 'Dolibarr module or extension'], - ['generic', '808080', 'Generic project or library'], + private ?GitPlatformAdapter $adapter = null; + /** + * Label definitions — [name, hexColor (no #), description]. + * + * @var list + */ + private const LABELS = [ + // Project Type + ['joomla', '7F52FF', 'Joomla extension or component'], + ['dolibarr', 'FF6B6B', 'Dolibarr module or extension'], + ['generic', '808080', 'Generic project or library'], - // Language - ['php', '4F5D95', 'PHP code changes'], - ['javascript', 'F7DF1E', 'JavaScript code changes'], - ['typescript', '3178C6', 'TypeScript code changes'], - ['python', '3776AB', 'Python code changes'], - ['css', '1572B6', 'CSS/styling changes'], - ['html', 'E34F26', 'HTML template changes'], + // Language + ['php', '4F5D95', 'PHP code changes'], + ['javascript', 'F7DF1E', 'JavaScript code changes'], + ['typescript', '3178C6', 'TypeScript code changes'], + ['python', '3776AB', 'Python code changes'], + ['css', '1572B6', 'CSS/styling changes'], + ['html', 'E34F26', 'HTML template changes'], - // Component - ['documentation', '0075CA', 'Documentation changes'], - ['ci-cd', '000000', 'CI/CD pipeline changes'], - ['docker', '2496ED', 'Docker configuration changes'], - ['tests', '00FF00', 'Test suite changes'], - ['security', 'FF0000', 'Security-related changes'], - ['dependencies', '0366D6', 'Dependency updates'], - ['config', 'F9D0C4', 'Configuration file changes'], - ['build', 'FFA500', 'Build system changes'], + // Component + ['documentation', '0075CA', 'Documentation changes'], + ['ci-cd', '000000', 'CI/CD pipeline changes'], + ['docker', '2496ED', 'Docker configuration changes'], + ['tests', '00FF00', 'Test suite changes'], + ['security', 'FF0000', 'Security-related changes'], + ['dependencies', '0366D6', 'Dependency updates'], + ['config', 'F9D0C4', 'Configuration file changes'], + ['build', 'FFA500', 'Build system changes'], - // Workflow / Process - ['automation', '8B4513', 'Automated processes or scripts'], - ['moko-platform', 'B60205', 'moko-platform compliance'], - ['needs-review', 'FBCA04', 'Awaiting code review'], - ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], - ['breaking-change', 'D73A4A', 'Breaking API or functionality change'], + // Workflow / Process + ['automation', '8B4513', 'Automated processes or scripts'], + ['moko-platform', 'B60205', 'moko-platform compliance'], + ['needs-review', 'FBCA04', 'Awaiting code review'], + ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], + ['breaking-change', 'D73A4A', 'Breaking API or functionality change'], - // Priority - ['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'], - ['priority: high', 'D93F0B', 'High priority'], - ['priority: medium', 'FBCA04', 'Medium priority'], - ['priority: low', '0E8A16', 'Low priority'], + // Priority + ['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'], + ['priority: high', 'D93F0B', 'High priority'], + ['priority: medium', 'FBCA04', 'Medium priority'], + ['priority: low', '0E8A16', 'Low priority'], - // Type - ['type: bug', 'D73A4A', "Something isn't working"], - ['type: feature', 'A2EEEF', 'New feature or request'], - ['type: enhancement', '84B6EB', 'Enhancement to existing feature'], - ['type: refactor', 'F9D0C4', 'Code refactoring'], - ['type: chore', 'FEF2C0', 'Maintenance tasks'], + // Type + ['type: bug', 'D73A4A', "Something isn't working"], + ['type: feature', 'A2EEEF', 'New feature or request'], + ['type: enhancement', '84B6EB', 'Enhancement to existing feature'], + ['type: refactor', 'F9D0C4', 'Code refactoring'], + ['type: chore', 'FEF2C0', 'Maintenance tasks'], - // Status - ['status: pending', 'FBCA04', 'Pending action or decision'], - ['status: in-progress', '0E8A16', 'Currently being worked on'], - ['status: blocked', 'B60205', 'Blocked by another issue or dependency'], - ['status: on-hold', 'D4C5F9', 'Temporarily on hold'], - ['status: wontfix', 'FFFFFF', 'This will not be worked on'], + // Status + ['status: pending', 'FBCA04', 'Pending action or decision'], + ['status: in-progress', '0E8A16', 'Currently being worked on'], + ['status: blocked', 'B60205', 'Blocked by another issue or dependency'], + ['status: on-hold', 'D4C5F9', 'Temporarily on hold'], + ['status: wontfix', 'FFFFFF', 'This will not be worked on'], - // Size - ['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'], - ['size/s', '6FD1E2', 'Small change (11-30 lines)'], - ['size/m', 'F9DD72', 'Medium change (31-100 lines)'], - ['size/l', 'FFA07A', 'Large change (101-300 lines)'], - ['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'], - ['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'], + // Size + ['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'], + ['size/s', '6FD1E2', 'Small change (11-30 lines)'], + ['size/m', 'F9DD72', 'Medium change (31-100 lines)'], + ['size/l', 'FFA07A', 'Large change (101-300 lines)'], + ['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'], + ['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'], - // Health - ['health: excellent', '0E8A16', 'Health score 90-100'], - ['health: good', 'FBCA04', 'Health score 70-89'], - ['health: fair', 'FFA500', 'Health score 50-69'], - ['health: poor', 'FF6B6B', 'Health score below 50'], + // Health + ['health: excellent', '0E8A16', 'Health score 90-100'], + ['health: good', 'FBCA04', 'Health score 70-89'], + ['health: fair', 'FFA500', 'Health score 50-69'], + ['health: poor', 'FF6B6B', 'Health score below 50'], - // Sync / Automation - ['standards-update', 'B60205', 'moko-platform sync update'], - ['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'], - ['sync-report', '0075CA', 'Bulk sync run report'], - ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], - ['push-failure', 'D73A4A', 'File push failure requiring attention'], - ['health-check', '0E8A16', 'Repository health check results'], - ['version-drift', 'FFA500', 'Version mismatch detected'], - ['deploy-failure', 'CC0000', 'Automated deploy failure tracking'], - ['template-validation-failure', 'D73A4A', 'Template workflow validation failure'], - ['version', '0E8A16', 'Version bump or release'], - ['type: version', '0E8A16', 'Version-related change'], + // Sync / Automation + ['standards-update', 'B60205', 'moko-platform sync update'], + ['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'], + ['sync-report', '0075CA', 'Bulk sync run report'], + ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], + ['push-failure', 'D73A4A', 'File push failure requiring attention'], + ['health-check', '0E8A16', 'Repository health check results'], + ['version-drift', 'FFA500', 'Version mismatch detected'], + ['deploy-failure', 'CC0000', 'Automated deploy failure tracking'], + ['template-validation-failure', 'D73A4A', 'Template workflow validation failure'], + ['version', '0E8A16', 'Version bump or release'], + ['type: version', '0E8A16', 'Version-related change'], - // Testing - ['type: test', '00FF00', 'Test suite additions or changes'], - ['needs-testing', 'FBCA04', 'Requires manual or automated testing'], - ['test-failure', 'D73A4A', 'Automated test failure'], - ['regression', 'B60205', 'Regression from a previous working state'], + // Testing + ['type: test', '00FF00', 'Test suite additions or changes'], + ['needs-testing', 'FBCA04', 'Requires manual or automated testing'], + ['test-failure', 'D73A4A', 'Automated test failure'], + ['regression', 'B60205', 'Regression from a previous working state'], - // Version & Release - ['type: release', '0E8A16', 'Release preparation or tracking'], - ['release-candidate', 'BFD4F2', 'Release candidate build'], - ['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'], - ['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'], - ['major-release', 'B60205', 'Major version release (breaking changes)'], - ['version-branch', '1D76DB', 'Version branch related'], - ]; + // Version & Release + ['type: release', '0E8A16', 'Release preparation or tracking'], + ['release-candidate', 'BFD4F2', 'Release candidate build'], + ['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'], + ['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'], + ['major-release', 'B60205', 'Major version release (breaking changes)'], + ['version-branch', '1D76DB', 'Version branch related'], + ]; - /** - * Configure available arguments. - */ - protected function configure(): void - { - $this->setDescription('REQUIRED: Deploy standard labels to repository'); - $this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false); - $this->addArgument('--org', 'Organization name', 'mokoconsulting-tech'); - $this->addArgument('--repo', 'Repository name (defaults to current repo)', ''); - } + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('REQUIRED: Deploy standard labels to repository'); + $this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false); + $this->addArgument('--org', 'Organization name', 'mokoconsulting-tech'); + $this->addArgument('--repo', 'Repository name (defaults to current repo)', ''); + } - /** - * Run the label deployment. - * - * @return int Exit code: 0 on success, 1 on error. - */ - protected function run(): int - { - $dryRun = (bool) $this->getArgument('--dry-run'); + /** + * Run the label deployment. + * + * @return int Exit code: 0 on success, 1 on error. + */ + protected function run(): int + { + $dryRun = (bool) $this->getArgument('--dry-run'); - $config = Config::load(); - try { - $this->adapter = PlatformAdapterFactory::create($config); - } catch (\RuntimeException $e) { - $this->log('ERROR', $e->getMessage()); - return 1; - } + $config = Config::load(); + try { + $this->adapter = PlatformAdapterFactory::create($config); + } catch (\RuntimeException $e) { + $this->log('ERROR', $e->getMessage()); + return 1; + } - $orgArg = (string) $this->getArgument('--org'); - $repoArg = (string) $this->getArgument('--repo'); - $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); - $repo = $repoArg ?: basename(getcwd() ?: '.'); + $orgArg = (string) $this->getArgument('--org'); + $repoArg = (string) $this->getArgument('--repo'); + $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); + $repo = $repoArg ?: basename(getcwd() ?: '.'); - $this->log('INFO', "Setting up labels for repository: {$org}/{$repo} ({$this->adapter->getPlatformName()})"); + $this->log('INFO', "Setting up labels for repository: {$org}/{$repo} ({$this->adapter->getPlatformName()})"); - echo "\n"; + echo "\n"; - $this->deployGroup('Creating REQUIRED project type labels...', 0, 2, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED language labels...', 3, 8, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED component labels...', 9, 16, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED workflow labels...', 17, 21, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED priority labels...', 22, 25, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED type labels...', 26, 30, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED status labels...', 31, 35, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED size labels...', 36, 41, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED health labels...', 42, 45, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED sync/automation labels...', 46, 56, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED testing labels...', 57, 60, $org, $repo, $dryRun); - $this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED project type labels...', 0, 2, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED language labels...', 3, 8, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED component labels...', 9, 16, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED workflow labels...', 17, 21, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED priority labels...', 22, 25, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED type labels...', 26, 30, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED status labels...', 31, 35, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED size labels...', 36, 41, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED health labels...', 42, 45, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED sync/automation labels...', 46, 56, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED testing labels...', 57, 60, $org, $repo, $dryRun); + $this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun); - echo "\n============================================================\n"; - if ($dryRun) { - $this->log('INFO', '[DRY-RUN] Label deployment simulation completed'); - } else { - $this->log('INFO', 'Label deployment completed successfully!'); - echo "\n - TOTAL: " . count(self::LABELS) . " labels\n"; - } - echo "============================================================\n\n"; + echo "\n============================================================\n"; + if ($dryRun) { + $this->log('INFO', '[DRY-RUN] Label deployment simulation completed'); + } else { + $this->log('INFO', 'Label deployment completed successfully!'); + echo "\n - TOTAL: " . count(self::LABELS) . " labels\n"; + } + echo "============================================================\n\n"; - return 0; - } + return 0; + } - // ── Private helpers ─────────────────────────────────────────────────────── + // ── Private helpers ─────────────────────────────────────────────────────── - /** - * Deploy a named group of labels by index range in self::LABELS. - * - * @param string $heading Informational banner printed before the group. - * @param int $fromIndex First label index (inclusive). - * @param int $toIndex Last label index (inclusive). - * @param string $org Organization name. - * @param string $repo Repository name. - * @param bool $dryRun When true, preview only. - */ - private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void - { - $this->log('INFO', $heading); - for ($i = $fromIndex; $i <= $toIndex; $i++) { - [$name, $color, $desc] = self::LABELS[$i]; - $this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun); - } - echo "\n"; - } + /** + * Deploy a named group of labels by index range in self::LABELS. + * + * @param string $heading Informational banner printed before the group. + * @param int $fromIndex First label index (inclusive). + * @param int $toIndex Last label index (inclusive). + * @param string $org Organization name. + * @param string $repo Repository name. + * @param bool $dryRun When true, preview only. + */ + private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void + { + $this->log('INFO', $heading); + for ($i = $fromIndex; $i <= $toIndex; $i++) { + [$name, $color, $desc] = self::LABELS[$i]; + $this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun); + } + echo "\n"; + } - /** - * Create or update a single label via the platform adapter. - * - * @param string $name Label name. - * @param string $color Hex colour without the leading '#'. - * @param string $desc Short description text. - * @param string $org Organization name. - * @param string $repo Repository name. - * @param bool $dryRun When true, preview only. - */ - private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void - { - if ($dryRun) { - echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n"; - return; - } + /** + * Create or update a single label via the platform adapter. + * + * @param string $name Label name. + * @param string $color Hex colour without the leading '#'. + * @param string $desc Short description text. + * @param string $org Organization name. + * @param string $repo Repository name. + * @param bool $dryRun When true, preview only. + */ + private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void + { + if ($dryRun) { + echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n"; + return; + } - try { - $this->adapter->createLabel($org, $repo, $name, $color, $desc); - $this->log('INFO', "Created/updated label: {$name}"); - } catch (\Exception $e) { - // Label may already exist — that's fine - if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { - $this->log('INFO', "Label already exists: {$name}"); - } else { - $this->log('WARNING', "Failed to create label: {$name} — " . $e->getMessage()); - } - } - } + try { + $this->adapter->createLabel($org, $repo, $name, $color, $desc); + $this->log('INFO', "Created/updated label: {$name}"); + } catch (\Exception $e) { + // Label may already exist — that's fine + if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { + $this->log('INFO', "Label already exists: {$name}"); + } else { + $this->log('WARNING', "Failed to create label: {$name} — " . $e->getMessage()); + } + } + } } $script = new SetupLabels('setup_labels', 'REQUIRED: Deploy standard labels to repository'); diff --git a/maintenance/sync_dolibarr_readmes.php b/maintenance/sync_dolibarr_readmes.php index 2c1a69b..12565dc 100644 --- a/maintenance/sync_dolibarr_readmes.php +++ b/maintenance/sync_dolibarr_readmes.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -34,215 +35,224 @@ use MokoEnterprise\CliFramework; */ class SyncDolibarrReadmes extends CliFramework { - /** - * Configure available arguments. - */ - protected function configure(): void - { - $this->setDescription('Keeps root README.md and src/README.md in sync for Dolibarr module repos'); - $this->addArgument('--path', 'Dolibarr module repo root', '.'); - $this->addArgument('--dry-run', 'Preview changes without writing', false); - } + /** + * Configure available arguments. + */ + protected function configure(): void + { + $this->setDescription('Keeps root README.md and src/README.md in sync for Dolibarr module repos'); + $this->addArgument('--path', 'Dolibarr module repo root', '.'); + $this->addArgument('--dry-run', 'Preview changes without writing', false); + } - /** - * Run the sync. - * - * @return int Exit code: 0 on success, 1 on error. - */ - protected function run(): int - { - $repoRoot = rtrim((string) $this->getArgument('--path'), '/'); - $dryRun = (bool) $this->getArgument('--dry-run'); - $rootReadme = $repoRoot . '/README.md'; - $srcReadme = $repoRoot . '/src/README.md'; + /** + * Run the sync. + * + * @return int Exit code: 0 on success, 1 on error. + */ + protected function run(): int + { + $repoRoot = rtrim((string) $this->getArgument('--path'), '/'); + $dryRun = (bool) $this->getArgument('--dry-run'); + $rootReadme = $repoRoot . '/README.md'; + $srcReadme = $repoRoot . '/src/README.md'; - if (!is_file($rootReadme)) { - $this->log('ERROR', "Root README.md not found at {$rootReadme}"); - return 1; - } + if (!is_file($rootReadme)) { + $this->log('ERROR', "Root README.md not found at {$rootReadme}"); + return 1; + } - if (!is_dir($repoRoot . '/src')) { - $this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?'); - return 1; - } + if (!is_dir($repoRoot . '/src')) { + $this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?'); + return 1; + } - $rootContent = (string) file_get_contents($rootReadme); + $rootContent = (string) file_get_contents($rootReadme); - if (!preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $rootContent, $m)) { - $this->log('ERROR', 'Could not find VERSION in root README.md FILE INFORMATION block'); - return 1; - } - $version = $m[1]; + if (!preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $rootContent, $m)) { + $this->log('ERROR', 'Could not find VERSION in root README.md FILE INFORMATION block'); + return 1; + } + $version = $m[1]; - $moduleName = $this->extractModuleName($rootContent, $repoRoot); - $repoUrl = $this->extractField($rootContent, 'REPO', 'https://git.mokoconsulting.tech/MokoConsulting'); - $defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoPlatform.Module'); - $ingroup = $this->extractField($rootContent, 'INGROUP', 'moko-platform'); - $brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation"); + $moduleName = $this->extractModuleName($rootContent, $repoRoot); + $repoUrl = $this->extractField($rootContent, 'REPO', 'https://git.mokoconsulting.tech/MokoConsulting'); + $defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoPlatform.Module'); + $ingroup = $this->extractField($rootContent, 'INGROUP', 'moko-platform'); + $brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation"); - $installSection = $this->extractSection($rootContent, 'Installation'); - $configSection = $this->extractSection($rootContent, 'Configuration'); - $usageSection = $this->extractSection($rootContent, 'Usage'); - $supportSection = $this->extractSection($rootContent, 'Support'); + $installSection = $this->extractSection($rootContent, 'Installation'); + $configSection = $this->extractSection($rootContent, 'Configuration'); + $usageSection = $this->extractSection($rootContent, 'Usage'); + $supportSection = $this->extractSection($rootContent, 'Support'); - echo "═══════════════════════════════════════════════════════════\n"; - echo " Dolibarr README Sync\n"; - echo "═══════════════════════════════════════════════════════════\n\n"; - echo "Module: {$moduleName}\n"; - echo "Version: {$version}\n"; - echo "Root: {$rootReadme}\n"; - echo "Src: {$srcReadme}\n"; - if ($dryRun) { - echo " DRY RUN — no files will be written\n"; - } - echo "\n"; + echo "═══════════════════════════════════════════════════════════\n"; + echo " Dolibarr README Sync\n"; + echo "═══════════════════════════════════════════════════════════\n\n"; + echo "Module: {$moduleName}\n"; + echo "Version: {$version}\n"; + echo "Root: {$rootReadme}\n"; + echo "Src: {$srcReadme}\n"; + if ($dryRun) { + echo " DRY RUN — no files will be written\n"; + } + echo "\n"; - echo "Step 1: Update root README.md badges and VERSION field...\n"; - $this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun); + echo "Step 1: Update root README.md badges and VERSION field...\n"; + $this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun); - echo "Step 2: Sync src/README.md...\n"; - $today = gmdate('Y-m-d'); - $newSrcContent = $this->buildSrcReadme( - $version, $moduleName, $repoUrl, $defgroup, $ingroup, $brief, $today, - $installSection, $configSection, $usageSection, $supportSection - ); - $this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun); + echo "Step 2: Sync src/README.md...\n"; + $today = gmdate('Y-m-d'); + $newSrcContent = $this->buildSrcReadme( + $version, + $moduleName, + $repoUrl, + $defgroup, + $ingroup, + $brief, + $today, + $installSection, + $configSection, + $usageSection, + $supportSection + ); + $this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun); - echo "\n═══════════════════════════════════════════════════════════\n"; - if ($dryRun) { - echo " Dry Run Complete\n"; - echo "═══════════════════════════════════════════════════════════\n"; - echo "Run without --dry-run to apply changes.\n"; - } else { - echo " Dolibarr README Sync Complete\n"; - echo "═══════════════════════════════════════════════════════════\n"; - echo "Module version: {$version}\n\n"; - echo "Next steps:\n"; - echo " git diff && git add README.md src/README.md\n"; - echo " git commit -m \"docs(readme): sync src/README.md from root for version {$version}\"\n"; - } - echo "\n"; + echo "\n═══════════════════════════════════════════════════════════\n"; + if ($dryRun) { + echo " Dry Run Complete\n"; + echo "═══════════════════════════════════════════════════════════\n"; + echo "Run without --dry-run to apply changes.\n"; + } else { + echo " Dolibarr README Sync Complete\n"; + echo "═══════════════════════════════════════════════════════════\n"; + echo "Module version: {$version}\n\n"; + echo "Next steps:\n"; + echo " git diff && git add README.md src/README.md\n"; + echo " git commit -m \"docs(readme): sync src/README.md from root for version {$version}\"\n"; + } + echo "\n"; - return 0; - } + return 0; + } - // ── Private helpers ─────────────────────────────────────────────────────── + // ── Private helpers ─────────────────────────────────────────────────────── - /** - * Extract a named field from the FILE INFORMATION block. - * - * @param string $content Full file content. - * @param string $field Field name (e.g. 'REPO'). - * @param string $fallback Value to use when the field is absent. - * @return string Field value or fallback. - */ - private function extractField(string $content, string $field, string $fallback): string - { - if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) { - return trim($m[1]); - } - return $fallback; - } + /** + * Extract a named field from the FILE INFORMATION block. + * + * @param string $content Full file content. + * @param string $field Field name (e.g. 'REPO'). + * @param string $fallback Value to use when the field is absent. + * @return string Field value or fallback. + */ + private function extractField(string $content, string $field, string $fallback): string + { + if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) { + return trim($m[1]); + } + return $fallback; + } - /** - * Extract the module name from the first H1 heading after the closing '-->' of the header. - * - * @param string $content Full root README.md content. - * @param string $repoRoot Repository root path (used as fallback). - * @return string Module name. - */ - private function extractModuleName(string $content, string $repoRoot): string - { - if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) { - return trim($m[1]); - } - return basename($repoRoot); - } + /** + * Extract the module name from the first H1 heading after the closing '-->' of the header. + * + * @param string $content Full root README.md content. + * @param string $repoRoot Repository root path (used as fallback). + * @return string Module name. + */ + private function extractModuleName(string $content, string $repoRoot): string + { + if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) { + return trim($m[1]); + } + return basename($repoRoot); + } - /** - * Extract a Markdown H2 section (from '## Heading' to the next '## '). - * - * @param string $content Full file content. - * @param string $heading Section heading (without '## ' prefix). - * @return string The extracted section text, or '' if not found. - */ - private function extractSection(string $content, string $heading): string - { - $quoted = preg_quote($heading, '/'); - if (!preg_match('/^## ' . $quoted . '$/m', $content)) { - return ''; - } - if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) { - return '## ' . $heading . $m[1]; - } - return ''; - } + /** + * Extract a Markdown H2 section (from '## Heading' to the next '## '). + * + * @param string $content Full file content. + * @param string $heading Section heading (without '## ' prefix). + * @return string The extracted section text, or '' if not found. + */ + private function extractSection(string $content, string $heading): string + { + $quoted = preg_quote($heading, '/'); + if (!preg_match('/^## ' . $quoted . '$/m', $content)) { + return ''; + } + if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) { + return '## ' . $heading . $m[1]; + } + return ''; + } - /** - * Update the version badge and VERSION field in root README.md. - * - * @param string $path Path to root README.md. - * @param string $content Current file content. - * @param string $version New version string. - * @param bool $dryRun When true, preview only. - */ - private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void - { - $updated = preg_replace( - '/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i', - '${1}' . $version, - $content - ); - $updated = preg_replace( - '/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', - '${1}' . $version, - (string) $updated - ); + /** + * Update the version badge and VERSION field in root README.md. + * + * @param string $path Path to root README.md. + * @param string $content Current file content. + * @param string $version New version string. + * @param bool $dryRun When true, preview only. + */ + private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void + { + $updated = preg_replace( + '/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i', + '${1}' . $version, + $content + ); + $updated = preg_replace( + '/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', + '${1}' . $version, + (string) $updated + ); - if ($updated === $content) { - echo " ✓ root README.md already current\n"; - return; - } + if ($updated === $content) { + echo " ✓ root README.md already current\n"; + return; + } - if ($dryRun) { - echo " ~ root README.md (would update version fields)\n"; - return; - } + if ($dryRun) { + echo " ~ root README.md (would update version fields)\n"; + return; + } - file_put_contents($path, (string) $updated); - echo " ✓ root README.md updated\n"; - } + file_put_contents($path, (string) $updated); + echo " ✓ root README.md updated\n"; + } - /** - * Build the full content for src/README.md. - * - * @param string $version Version string. - * @param string $moduleName Module display name. - * @param string $repoUrl Repository URL. - * @param string $defgroup DEFGROUP value. - * @param string $ingroup INGROUP value. - * @param string $brief BRIEF value. - * @param string $today ISO date string (YYYY-MM-DD). - * @param string $installSection Extracted Installation section (may be ''). - * @param string $configSection Extracted Configuration section (may be ''). - * @param string $usageSection Extracted Usage section (may be ''). - * @param string $supportSection Extracted Support section (may be ''). - * @return string Complete file content. - */ - private function buildSrcReadme( - string $version, - string $moduleName, - string $repoUrl, - string $defgroup, - string $ingroup, - string $brief, - string $today, - string $installSection, - string $configSection, - string $usageSection, - string $supportSection - ): string { - $content = << @@ -270,54 +280,54 @@ NOTE: This file is auto-generated by sync_dolibarr_readmes.php from root README. SRCREADME; - foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) { - if ($section !== '') { - $content .= "\n" . $section; - } - } + foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) { + if ($section !== '') { + $content .= "\n" . $section; + } + } - $content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n"; - return $content; - } + $content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n"; + return $content; + } - /** - * Compare and write (or preview) src/README.md. - * - * @param string $path Path to src/README.md. - * @param string $content Desired file content. - * @param bool $dryRun When true, preview only. - */ - private function syncSrcReadme(string $path, string $content, bool $dryRun): void - { - if (is_file($path)) { - $existing = (string) file_get_contents($path); - if ($existing === $content) { - echo " ✓ src/README.md already current\n"; - return; - } - if ($dryRun) { - echo " ~ src/README.md (would regenerate)\n"; - return; - } - if (!is_dir(dirname($path))) { - mkdir(dirname($path), 0755, true); - } - file_put_contents($path, $content); - echo " ✓ src/README.md regenerated\n"; - return; - } + /** + * Compare and write (or preview) src/README.md. + * + * @param string $path Path to src/README.md. + * @param string $content Desired file content. + * @param bool $dryRun When true, preview only. + */ + private function syncSrcReadme(string $path, string $content, bool $dryRun): void + { + if (is_file($path)) { + $existing = (string) file_get_contents($path); + if ($existing === $content) { + echo " ✓ src/README.md already current\n"; + return; + } + if ($dryRun) { + echo " ~ src/README.md (would regenerate)\n"; + return; + } + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + file_put_contents($path, $content); + echo " ✓ src/README.md regenerated\n"; + return; + } - if ($dryRun) { - echo " ~ src/README.md (would create — file does not exist)\n"; - return; - } + if ($dryRun) { + echo " ~ src/README.md (would create — file does not exist)\n"; + return; + } - if (!is_dir(dirname($path))) { - mkdir(dirname($path), 0755, true); - } - file_put_contents($path, $content); - echo " ✓ src/README.md created\n"; - } + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + file_put_contents($path, $content); + echo " ✓ src/README.md created\n"; + } } $script = new SyncDolibarrReadmes('sync_dolibarr_readmes', 'Keeps root README.md and src/README.md in sync for Dolibarr module repos'); diff --git a/maintenance/update_repo_inventory.php b/maintenance/update_repo_inventory.php index 3b861c1..ae53df6 100644 --- a/maintenance/update_repo_inventory.php +++ b/maintenance/update_repo_inventory.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * @@ -36,274 +37,274 @@ use MokoEnterprise\PlatformAdapterFactory; */ class UpdateRepoInventory extends CliFramework { - private ?GitPlatformAdapter $adapter = null; + private ?GitPlatformAdapter $adapter = null; - /** Marker that begins the auto-generated block. */ - private const MARKER_START = ''; + /** Marker that begins the auto-generated block. */ + private const MARKER_START = ''; - /** Marker that ends the auto-generated block. */ - private const MARKER_END = ''; + /** Marker that ends the auto-generated block. */ + private const MARKER_END = ''; - /** Path to the Dolibarr module registry relative to repo root. */ - private const REGISTRY_PATH = 'docs/development/crm/module-registry.md'; + /** Path to the Dolibarr module registry relative to repo root. */ + private const REGISTRY_PATH = 'docs/development/crm/module-registry.md'; - /** Path to the inventory file relative to repo root. */ - private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md'; + /** Path to the inventory file relative to repo root. */ + private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md'; - protected function configure(): void - { - $this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list'); - $this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech'); - $this->addArgument('--path', 'Repository root path', '.'); - } + protected function configure(): void + { + $this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list'); + $this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech'); + $this->addArgument('--path', 'Repository root path', '.'); + } - protected function run(): int - { - $root = rtrim((string) $this->getArgument('--path'), '/\\'); + protected function run(): int + { + $root = rtrim((string) $this->getArgument('--path'), '/\\'); - $config = Config::load(); - try { - $this->adapter = PlatformAdapterFactory::create($config); - } catch (\RuntimeException $e) { - $this->status(false, 'auth', $e->getMessage()); - return 2; - } + $config = Config::load(); + try { + $this->adapter = PlatformAdapterFactory::create($config); + } catch (\RuntimeException $e) { + $this->status(false, 'auth', $e->getMessage()); + return 2; + } - $orgArg = (string) $this->getArgument('--org'); - $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); + $orgArg = (string) $this->getArgument('--org'); + $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); - // ── 1. Fetch repositories ───────────────────────────────────────────── - $this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})"); + // ── 1. Fetch repositories ───────────────────────────────────────────── + $this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})"); - $repos = $this->fetchAllRepos($org); - if ($repos === null) { - return 1; - } + $repos = $this->fetchAllRepos($org); + if ($repos === null) { + return 1; + } - $this->status(true, 'API', sprintf('Fetched %d repositories', count($repos))); + $this->status(true, 'API', sprintf('Fetched %d repositories', count($repos))); - // ── 2. Load Dolibarr module registry ────────────────────────────────── - $this->section('Loading Dolibarr module registry'); + // ── 2. Load Dolibarr module registry ────────────────────────────────── + $this->section('Loading Dolibarr module registry'); - $moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH); - $this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap))); + $moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH); + $this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap))); - // ── 3. Build the Markdown tables ────────────────────────────────────── - $this->section('Building inventory tables'); + // ── 3. Build the Markdown tables ────────────────────────────────────── + $this->section('Building inventory tables'); - $table = $this->buildTables($repos, $moduleMap, $org); + $table = $this->buildTables($repos, $moduleMap, $org); - // ── 4. Rewrite the inventory file ──────────────────────────────────── - $this->section('Updating ' . self::INVENTORY_PATH); + // ── 4. Rewrite the inventory file ──────────────────────────────────── + $this->section('Updating ' . self::INVENTORY_PATH); - $inventoryPath = $root . '/' . self::INVENTORY_PATH; - if (!is_file($inventoryPath)) { - $this->status(false, self::INVENTORY_PATH, 'file not found'); - return 2; - } + $inventoryPath = $root . '/' . self::INVENTORY_PATH; + if (!is_file($inventoryPath)) { + $this->status(false, self::INVENTORY_PATH, 'file not found'); + return 2; + } - $original = (string) file_get_contents($inventoryPath); - $updated = $this->replaceSection($original, $table); + $original = (string) file_get_contents($inventoryPath); + $updated = $this->replaceSection($original, $table); - if ($original === $updated) { - $this->status(true, self::INVENTORY_PATH, 'no changes needed'); - } elseif (!$this->isDryRun()) { - file_put_contents($inventoryPath, $updated); - $this->status(true, self::INVENTORY_PATH, 'updated'); - } else { - $this->status(true, self::INVENTORY_PATH, '[dry-run] would update'); - } + if ($original === $updated) { + $this->status(true, self::INVENTORY_PATH, 'no changes needed'); + } elseif (!$this->isDryRun()) { + file_put_contents($inventoryPath, $updated); + $this->status(true, self::INVENTORY_PATH, 'updated'); + } else { + $this->status(true, self::INVENTORY_PATH, '[dry-run] would update'); + } - $this->printSummary(1, 0, $this->elapsed()); + $this->printSummary(1, 0, $this->elapsed()); - return 0; - } + return 0; + } - // ── Platform API ────────────────────────────────────────────────────────── + // ── Platform API ────────────────────────────────────────────────────────── - /** - * Fetch all repositories for the org via the platform adapter. - * - * @return list>|null Null on API error. - */ - private function fetchAllRepos(string $org): ?array - { - try { - // Use the adapter's paginated listing — returns full repo objects - $repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']); - $this->progress(count($repos), count($repos), '', true); - return $repos; - } catch (\Exception $e) { - $this->status(false, 'API', $e->getMessage()); - return null; - } - } + /** + * Fetch all repositories for the org via the platform adapter. + * + * @return list>|null Null on API error. + */ + private function fetchAllRepos(string $org): ?array + { + try { + // Use the adapter's paginated listing — returns full repo objects + $repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']); + $this->progress(count($repos), count($repos), '', true); + return $repos; + } catch (\Exception $e) { + $this->status(false, 'API', $e->getMessage()); + return null; + } + } - // ── Module registry ─────────────────────────────────────────────────────── + // ── Module registry ─────────────────────────────────────────────────────── - /** - * Parse the Dolibarr module registry Markdown table. - * - * @return array Map of lower-case repo name → module number. - */ - private function parseModuleRegistry(string $path): array - { - if (!is_file($path)) { - $this->warning("Module registry not found: {$path}"); - return []; - } + /** + * Parse the Dolibarr module registry Markdown table. + * + * @return array Map of lower-case repo name → module number. + */ + private function parseModuleRegistry(string $path): array + { + if (!is_file($path)) { + $this->warning("Module registry not found: {$path}"); + return []; + } - $map = []; - $content = (string) file_get_contents($path); + $map = []; + $content = (string) file_get_contents($path); - // Match table rows: | ModuleName | 185051 | Status | … | - preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER); + // Match table rows: | ModuleName | 185051 | Status | … | + preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER); - foreach ($matches as $match) { - $id = (int) $match[2]; - if ($id >= 100000) { - $map[strtolower($match[1])] = $id; - } - } + foreach ($matches as $match) { + $id = (int) $match[2]; + if ($id >= 100000) { + $map[strtolower($match[1])] = $id; + } + } - return $map; - } + return $map; + } - // ── Table builder ───────────────────────────────────────────────────────── + // ── Table builder ───────────────────────────────────────────────────────── - /** - * Build the full Markdown replacement for the inventory tables. - * - * @param list> $repos - * @param array $moduleMap - */ - private function buildTables(array $repos, array $moduleMap, string $org): string - { - // Sort: active first, then archived; within each group alphabetically. - usort($repos, static function (array $a, array $b): int { - $aArch = (bool) ($a['archived'] ?? false); - $bArch = (bool) ($b['archived'] ?? false); - if ($aArch !== $bArch) { - return $aArch ? 1 : -1; - } - return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); - }); + /** + * Build the full Markdown replacement for the inventory tables. + * + * @param list> $repos + * @param array $moduleMap + */ + private function buildTables(array $repos, array $moduleMap, string $org): string + { + // Sort: active first, then archived; within each group alphabetically. + usort($repos, static function (array $a, array $b): int { + $aArch = (bool) ($a['archived'] ?? false); + $bArch = (bool) ($b['archived'] ?? false); + if ($aArch !== $bArch) { + return $aArch ? 1 : -1; + } + return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); + }); - /** @var array>> $groups */ - $groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []]; + /** @var array>> $groups */ + $groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []]; - foreach ($repos as $repo) { - $name = (string) ($repo['name'] ?? ''); - $topics = array_map('strtolower', (array) ($repo['topics'] ?? [])); - $archived = (bool) ($repo['archived'] ?? false); + foreach ($repos as $repo) { + $name = (string) ($repo['name'] ?? ''); + $topics = array_map('strtolower', (array) ($repo['topics'] ?? [])); + $archived = (bool) ($repo['archived'] ?? false); - if ($archived) { - $groups['archived'][] = $repo; - continue; - } + if ($archived) { + $groups['archived'][] = $repo; + continue; + } - $lower = strtolower($name); + $lower = strtolower($name); - if (in_array('mokostandards-core', $topics, true) || $name === 'moko-platform' || $name === '.github-private') { - $groups['core'][] = $repo; - } elseif ( - in_array('dolibarr-module', $topics, true) - || str_starts_with($lower, 'mokodoli') - || (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme') - ) { - $groups['extension'][] = $repo; - } elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) { - $groups['product'][] = $repo; - } elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) { - $groups['template'][] = $repo; - } else { - $groups['internal'][] = $repo; - } - } + if (in_array('mokostandards-core', $topics, true) || $name === 'moko-platform' || $name === '.github-private') { + $groups['core'][] = $repo; + } elseif ( + in_array('dolibarr-module', $topics, true) + || str_starts_with($lower, 'mokodoli') + || (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme') + ) { + $groups['extension'][] = $repo; + } elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) { + $groups['product'][] = $repo; + } elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) { + $groups['template'][] = $repo; + } else { + $groups['internal'][] = $repo; + } + } - $updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T'); - $lines = [ - "> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.", - '> Do not edit this section manually; it is overwritten on every bulk sync.', - '', - ]; + $updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T'); + $lines = [ + "> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.", + '> Do not edit this section manually; it is overwritten on every bulk sync.', + '', + ]; - $groupLabels = [ - 'core' => 'Core Repositories', - 'product' => 'Product Repositories', - 'extension' => 'Extension Repositories (Dolibarr / CRM)', - 'template' => 'Template Repositories', - 'internal' => 'Internal and Testing', - 'archived' => 'Archived Repositories', - ]; + $groupLabels = [ + 'core' => 'Core Repositories', + 'product' => 'Product Repositories', + 'extension' => 'Extension Repositories (Dolibarr / CRM)', + 'template' => 'Template Repositories', + 'internal' => 'Internal and Testing', + 'archived' => 'Archived Repositories', + ]; - foreach ($groupLabels as $key => $label) { - if (empty($groups[$key])) { - continue; - } + foreach ($groupLabels as $key => $label) { + if (empty($groups[$key])) { + continue; + } - $lines[] = "### {$label}"; - $lines[] = ''; - $isExt = ($key === 'extension'); + $lines[] = "### {$label}"; + $lines[] = ''; + $isExt = ($key === 'extension'); - if ($isExt) { - $lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |'; - $lines[] = '|------------|--------|-------------|-----------|----------|------------|'; - } else { - $lines[] = '| Repository | Status | Description | Language | Visibility |'; - $lines[] = '|------------|--------|-------------|----------|------------|'; - } + if ($isExt) { + $lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |'; + $lines[] = '|------------|--------|-------------|-----------|----------|------------|'; + } else { + $lines[] = '| Repository | Status | Description | Language | Visibility |'; + $lines[] = '|------------|--------|-------------|----------|------------|'; + } - foreach ($groups[$key] as $repo) { - $name = (string) ($repo['name'] ?? ''); - $desc = str_replace('|', '\\|', (string) ($repo['description'] ?? '')); - $url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}"); - $lang = (string) ($repo['language'] ?? '—'); - $private = (bool) ($repo['private'] ?? false); - $archived = (bool) ($repo['archived'] ?? false); - $status = $archived ? '🗄 Archived' : '✅ Active'; - $vis = $private ? 'Private' : 'Public'; - $modId = $moduleMap[strtolower($name)] ?? null; - $modCell = $modId !== null ? (string) $modId : '—'; + foreach ($groups[$key] as $repo) { + $name = (string) ($repo['name'] ?? ''); + $desc = str_replace('|', '\\|', (string) ($repo['description'] ?? '')); + $url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}"); + $lang = (string) ($repo['language'] ?? '—'); + $private = (bool) ($repo['private'] ?? false); + $archived = (bool) ($repo['archived'] ?? false); + $status = $archived ? '🗄 Archived' : '✅ Active'; + $vis = $private ? 'Private' : 'Public'; + $modId = $moduleMap[strtolower($name)] ?? null; + $modCell = $modId !== null ? (string) $modId : '—'; - if ($isExt) { - $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |"; - } else { - $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |"; - } - } + if ($isExt) { + $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |"; + } else { + $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |"; + } + } - $lines[] = ''; - } + $lines[] = ''; + } - return implode("\n", $lines); - } + return implode("\n", $lines); + } - // ── File rewriter ───────────────────────────────────────────────────────── + // ── File rewriter ───────────────────────────────────────────────────────── - /** - * Replace content between the start/end markers in the inventory file. - * If markers are absent, appends a new section at the end. - */ - private function replaceSection(string $original, string $newContent): string - { - $startPos = strpos($original, self::MARKER_START); - $endPos = strpos($original, self::MARKER_END); + /** + * Replace content between the start/end markers in the inventory file. + * If markers are absent, appends a new section at the end. + */ + private function replaceSection(string $original, string $newContent): string + { + $startPos = strpos($original, self::MARKER_START); + $endPos = strpos($original, self::MARKER_END); - if ($startPos === false || $endPos === false) { - $this->warning('Inventory markers not found; appending section to end of file.'); - return $original - . "\n\n## Active Repositories\n\n" - . self::MARKER_START . "\n" - . $newContent . "\n" - . self::MARKER_END . "\n"; - } + if ($startPos === false || $endPos === false) { + $this->warning('Inventory markers not found; appending section to end of file.'); + return $original + . "\n\n## Active Repositories\n\n" + . self::MARKER_START . "\n" + . $newContent . "\n" + . self::MARKER_END . "\n"; + } - $before = substr($original, 0, $startPos + strlen(self::MARKER_START)); - $after = substr($original, $endPos); + $before = substr($original, 0, $startPos + strlen(self::MARKER_START)); + $after = substr($original, $endPos); - return $before . "\n" . $newContent . "\n" . $after; - } + return $before . "\n" . $newContent . "\n" . $after; + } } $script = new UpdateRepoInventory('update_repo_inventory', 'Updates the repository inventory documentation after sync'); diff --git a/maintenance/update_sha_hashes.php b/maintenance/update_sha_hashes.php index d8cb17c..fc68a9b 100755 --- a/maintenance/update_sha_hashes.php +++ b/maintenance/update_sha_hashes.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * diff --git a/maintenance/update_version_from_readme.php b/maintenance/update_version_from_readme.php index 2dd71ac..e3f658a 100644 --- a/maintenance/update_version_from_readme.php +++ b/maintenance/update_version_from_readme.php @@ -1,5 +1,6 @@ #!/usr/bin/env php * * This file is part of a Moko Consulting project. @@ -36,449 +37,451 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework}; */ class UpdateVersionFromReadme extends CliFramework { - private AuditLogger $logger; - private ?ApiClient $apiClient = null; + private AuditLogger $logger; + private ?ApiClient $apiClient = null; - /** Files updated during this run */ - private array $updatedFiles = []; + /** Files updated during this run */ + private array $updatedFiles = []; - /** Errors encountered during this run */ - private array $errors = []; + /** Errors encountered during this run */ + private array $errors = []; - protected function configure(): void - { - $this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers'); - $this->addArgument('--path', 'Repository root path', '.'); - $this->addArgument('--dry-run', 'Preview changes without writing', false); - $this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false); - $this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', ''); - } + protected function configure(): void + { + $this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers'); + $this->addArgument('--path', 'Repository root path', '.'); + $this->addArgument('--dry-run', 'Preview changes without writing', false); + $this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false); + $this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', ''); + } - protected function initialize(): void - { - parent::initialize(); - $this->logger = new AuditLogger('update_version_from_readme'); - } + protected function initialize(): void + { + parent::initialize(); + $this->logger = new AuditLogger('update_version_from_readme'); + } - protected function run(): int - { - $repoRoot = rtrim((string) $this->getArgument('--path'), '/'); - $dryRun = (bool) $this->getArgument('--dry-run'); - $createIssue = (bool) $this->getArgument('--create-issue'); - $repo = (string) $this->getArgument('--repo'); + protected function run(): int + { + $repoRoot = rtrim((string) $this->getArgument('--path'), '/'); + $dryRun = (bool) $this->getArgument('--dry-run'); + $createIssue = (bool) $this->getArgument('--create-issue'); + $repo = (string) $this->getArgument('--repo'); - $readmePath = $repoRoot . '/README.md'; - if (!file_exists($readmePath)) { - $this->error("README.md not found at {$readmePath}"); - return 1; - } + $readmePath = $repoRoot . '/README.md'; + if (!file_exists($readmePath)) { + $this->error("README.md not found at {$readmePath}"); + return 1; + } - // ── 1. Extract version from README.md ──────────────────────────── - $version = $this->extractVersionFromReadme($readmePath); - if ($version === null) { - $this->error("Could not find VERSION field in README.md FILE INFORMATION block"); - return 1; - } + // ── 1. Extract version from README.md ──────────────────────────── + $version = $this->extractVersionFromReadme($readmePath); + if ($version === null) { + $this->error("Could not find VERSION field in README.md FILE INFORMATION block"); + return 1; + } - $this->log("✅ README.md version: {$version}"); - if ($dryRun) { - $this->log("🔍 DRY RUN — no files will be written"); - } + $this->log("✅ README.md version: {$version}"); + if ($dryRun) { + $this->log("🔍 DRY RUN — no files will be written"); + } - // ── 2. Scan and update every tracked file ──────────────────────── - $this->processFiles($repoRoot, $version, $dryRun); + // ── 2. Scan and update every tracked file ──────────────────────── + $this->processFiles($repoRoot, $version, $dryRun); - // ── 3. Update composer.json ────────────────────────────────────── - $this->updateComposerJson($repoRoot, $version, $dryRun); + // ── 3. Update composer.json ────────────────────────────────────── + $this->updateComposerJson($repoRoot, $version, $dryRun); - // ── 4. Summary ─────────────────────────────────────────────────── - $count = count($this->updatedFiles); - if ($dryRun) { - $this->log("🔍 DRY RUN complete — {$count} file(s) would be updated"); - } else { - $this->log("✅ Updated {$count} file(s) to version {$version}"); - } + // ── 4. Summary ─────────────────────────────────────────────────── + $count = count($this->updatedFiles); + if ($dryRun) { + $this->log("🔍 DRY RUN complete — {$count} file(s) would be updated"); + } else { + $this->log("✅ Updated {$count} file(s) to version {$version}"); + } - foreach ($this->updatedFiles as $f) { - $this->log(" ✓ {$f}"); - } + foreach ($this->updatedFiles as $f) { + $this->log(" ✓ {$f}"); + } - // ── 5. Create issue if mismatches remain (non-dry-run only) ────── - if (!$dryRun && $createIssue && !empty($repo)) { - $remaining = $this->countRemainingMismatches($repoRoot, $version); - if ($remaining > 0) { - $this->log("⚠ {$remaining} version reference(s) could not be auto-updated"); - $this->createDriftIssue($repo, $version, $remaining); - } - } + // ── 5. Create issue if mismatches remain (non-dry-run only) ────── + if (!$dryRun && $createIssue && !empty($repo)) { + $remaining = $this->countRemainingMismatches($repoRoot, $version); + if ($remaining > 0) { + $this->log("⚠ {$remaining} version reference(s) could not be auto-updated"); + $this->createDriftIssue($repo, $version, $remaining); + } + } - return empty($this->errors) ? 0 : 1; - } + return empty($this->errors) ? 0 : 1; + } - // ──────────────────────────────────────────────────────────────────── - // Version extraction - // ──────────────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────────────── + // Version extraction + // ──────────────────────────────────────────────────────────────────── - /** - * Extract the VERSION value from the FILE INFORMATION block in README.md. - * - * Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms. - * - * @param string $path Full path to README.md - * @return string|null Version string (e.g. "04.00.04"), or null if not found - */ - private function extractVersionFromReadme(string $path): ?string - { - $content = file_get_contents($path); - if ($content === false) { - return null; - } - // Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab - if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) { - return $m[1]; - } - return null; - } + /** + * Extract the VERSION value from the FILE INFORMATION block in README.md. + * + * Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms. + * + * @param string $path Full path to README.md + * @return string|null Version string (e.g. "04.00.04"), or null if not found + */ + private function extractVersionFromReadme(string $path): ?string + { + $content = file_get_contents($path); + if ($content === false) { + return null; + } + // Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab + if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) { + return $m[1]; + } + return null; + } - // ──────────────────────────────────────────────────────────────────── - // File processing - // ──────────────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────────────── + // File processing + // ──────────────────────────────────────────────────────────────────── - /** - * Walk the repository tree and update every eligible file. - * - * @param string $repoRoot Absolute path to repository root - * @param string $version Target version string - * @param bool $dryRun If true, compute but do not write changes - */ - private function processFiles(string $repoRoot, string $version, bool $dryRun): void - { - $extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf']; - $excludeDirs = ['vendor', '.git', 'node_modules', 'logs']; + /** + * Walk the repository tree and update every eligible file. + * + * @param string $repoRoot Absolute path to repository root + * @param string $version Target version string + * @param bool $dryRun If true, compute but do not write changes + */ + private function processFiles(string $repoRoot, string $version, bool $dryRun): void + { + $extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf']; + $excludeDirs = ['vendor', '.git', 'node_modules', 'logs']; - $iterator = new RecursiveIteratorIterator( - new RecursiveCallbackFilterIterator( - new RecursiveDirectoryIterator( - $repoRoot, - RecursiveDirectoryIterator::SKIP_DOTS - ), - function (\SplFileInfo $fi) use ($excludeDirs): bool { - if ($fi->isDir()) { - return !in_array($fi->getFilename(), $excludeDirs, true); - } - return true; - } - ) - ); + $iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( + $repoRoot, + RecursiveDirectoryIterator::SKIP_DOTS + ), + function (\SplFileInfo $fi) use ($excludeDirs): bool { + if ($fi->isDir()) { + return !in_array($fi->getFilename(), $excludeDirs, true); + } + return true; + } + ) + ); - foreach ($iterator as $file) { - /** @var \SplFileInfo $file */ - if (!$file->isFile()) { - continue; - } + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile()) { + continue; + } - $ext = strtolower($file->getExtension()); - // Strip .template suffix for extension matching - if ($ext === 'template') { - $inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION)); - if (in_array($inner, $extensions, true)) { - $ext = $inner; - } else { - continue; - } - } elseif (!in_array($ext, $extensions, true)) { - continue; - } + $ext = strtolower($file->getExtension()); + // Strip .template suffix for extension matching + if ($ext === 'template') { + $inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION)); + if (in_array($inner, $extensions, true)) { + $ext = $inner; + } else { + continue; + } + } elseif (!in_array($ext, $extensions, true)) { + continue; + } - $this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext); - } - } + $this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext); + } + } - /** - * Apply version replacements to a single file. - * - * @param string $path Absolute file path - * @param string $repoRoot Repository root (for display) - * @param string $version Target version - * @param bool $dryRun If true, do not write - * @param string $ext Canonical extension (without .template) - */ - private function processFile( - string $path, - string $repoRoot, - string $version, - bool $dryRun, - string $ext - ): void { - $original = file_get_contents($path); - if ($original === false) { - $this->errors[] = "Cannot read: {$path}"; - return; - } + /** + * Apply version replacements to a single file. + * + * @param string $path Absolute file path + * @param string $repoRoot Repository root (for display) + * @param string $version Target version + * @param bool $dryRun If true, do not write + * @param string $ext Canonical extension (without .template) + */ + private function processFile( + string $path, + string $repoRoot, + string $version, + bool $dryRun, + string $ext + ): void { + $original = file_get_contents($path); + if ($original === false) { + $this->errors[] = "Cannot read: {$path}"; + return; + } - $updated = $original; + $updated = $original; - // ── Badge replacement (all file types) ─────────────────────────── - // shields.io badge: [![moko-platform](...badge/moko--platform-XX.YY.ZZ-color)] - $updated = preg_replace( - '/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/', - '${1}' . $version . '${2}', - $updated - ); - // Plain text version badge: [VERSION: XX.YY.ZZ] - $updated = preg_replace( - '/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/', - '[VERSION: ' . $version . ']', - $updated - ); + // ── Badge replacement (all file types) ─────────────────────────── + // shields.io badge: [![moko-platform](...badge/moko--platform-XX.YY.ZZ-color)] + $updated = preg_replace( + '/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/', + '${1}' . $version . '${2}', + $updated + ); + // Plain text version badge: [VERSION: XX.YY.ZZ] + $updated = preg_replace( + '/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/', + '[VERSION: ' . $version . ']', + $updated + ); - // ── FILE INFORMATION VERSION replacement ────────────────────────── - // Markdown inside : VERSION: OLD or VERSION: OLD - if ($ext === 'md') { - $updated = preg_replace( - '/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', - '${1}' . $version . '${2}', - $updated - ); - } + // ── FILE INFORMATION VERSION replacement ────────────────────────── + // Markdown inside : VERSION: OLD or VERSION: OLD + if ($ext === 'md') { + $updated = preg_replace( + '/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + } - // PHP inside /** */ or /* */: * VERSION: OLD - if ($ext === 'php') { - $updated = preg_replace( - '/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', - '${1}' . $version . '${2}', - $updated - ); + // PHP inside /** */ or /* */: * VERSION: OLD + if ($ext === 'php') { + $updated = preg_replace( + '/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); - // PHP class VERSION constants: - // private const VERSION = '09.22.00'; - // public const VERSION = '09.22.00'; - // private const VERSION = '09.22.00'; - $updated = preg_replace( - '/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/', - '${1}' . $version . '${2}', - $updated - ); + // PHP class VERSION constants: + // private const VERSION = '09.22.00'; + // public const VERSION = '09.22.00'; + // private const VERSION = '09.22.00'; + $updated = preg_replace( + '/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/', + '${1}' . $version . '${2}', + $updated + ); - // composer.json "version" field (handled separately for JSON files) - } + // composer.json "version" field (handled separately for JSON files) + } - // YAML / Shell / PowerShell / Python: # VERSION: OLD - if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) { - $updated = preg_replace( - '/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', - '${1}' . $version . '${2}', - $updated - ); - } + // YAML / Shell / PowerShell / Python: # VERSION: OLD + if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) { + $updated = preg_replace( + '/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + } - // Terraform (.tf / .tf.template) — three locations: - // 1. # VERSION: OLD (hash-comment header, template-style files) - // 2. * Version: OLD (block-comment header, definition files) - // 3. version = "OLD" (HCL metadata field) - if ($ext === 'tf') { - $updated = preg_replace( - '/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', - '${1}' . $version . '${2}', - $updated - ); - $updated = preg_replace( - '/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', - '${1}' . $version . '${2}', - $updated - ); - $updated = preg_replace( - '/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m', - '${1}' . $version . '${2}', - $updated - ); - } + // Terraform (.tf / .tf.template) — three locations: + // 1. # VERSION: OLD (hash-comment header, template-style files) + // 2. * Version: OLD (block-comment header, definition files) + // 3. version = "OLD" (HCL metadata field) + if ($ext === 'tf') { + $updated = preg_replace( + '/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + $updated = preg_replace( + '/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + $updated = preg_replace( + '/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m', + '${1}' . $version . '${2}', + $updated + ); + } - if ($updated === $original) { - return; // Nothing to change - } + if ($updated === $original) { + return; // Nothing to change + } - $rel = ltrim(str_replace($repoRoot, '', $path), '/'); + $rel = ltrim(str_replace($repoRoot, '', $path), '/'); - if (!$dryRun) { - if (file_put_contents($path, $updated) === false) { - $this->errors[] = "Cannot write: {$path}"; - return; - } - } + if (!$dryRun) { + if (file_put_contents($path, $updated) === false) { + $this->errors[] = "Cannot write: {$path}"; + return; + } + } - $this->updatedFiles[] = $rel; - } + $this->updatedFiles[] = $rel; + } - /** - * Update the "version" key in composer.json if it exists. - * - * @param string $repoRoot Repository root - * @param string $version Target version - * @param bool $dryRun If true, do not write - */ - private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void - { - $path = $repoRoot . '/composer.json'; - if (!file_exists($path)) { - return; - } + /** + * Update the "version" key in composer.json if it exists. + * + * @param string $repoRoot Repository root + * @param string $version Target version + * @param bool $dryRun If true, do not write + */ + private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void + { + $path = $repoRoot . '/composer.json'; + if (!file_exists($path)) { + return; + } - $content = file_get_contents($path); - if ($content === false) { - return; - } + $content = file_get_contents($path); + if ($content === false) { + return; + } - $updated = preg_replace( - '/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m', - '${1}' . $version . '${2}', - $content - ); + $updated = preg_replace( + '/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m', + '${1}' . $version . '${2}', + $content + ); - if ($updated === $content) { - return; - } + if ($updated === $content) { + return; + } - if (!$dryRun) { - file_put_contents($path, $updated); - } + if (!$dryRun) { + file_put_contents($path, $updated); + } - $this->updatedFiles[] = 'composer.json'; - } + $this->updatedFiles[] = 'composer.json'; + } - // ──────────────────────────────────────────────────────────────────── - // Drift detection - // ──────────────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────────────── + // Drift detection + // ──────────────────────────────────────────────────────────────────── - /** - * Count FILE INFORMATION VERSION lines that still differ from $version. - * - * @param string $repoRoot Repository root - * @param string $version Expected version - * @return int Number of remaining mismatches - */ - private function countRemainingMismatches(string $repoRoot, string $version): int - { - $escaped = preg_quote($version, '/'); - $count = 0; - $versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/'; + /** + * Count FILE INFORMATION VERSION lines that still differ from $version. + * + * @param string $repoRoot Repository root + * @param string $version Expected version + * @return int Number of remaining mismatches + */ + private function countRemainingMismatches(string $repoRoot, string $version): int + { + $escaped = preg_quote($version, '/'); + $count = 0; + $versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/'; - $extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf']; - $excludeDirs = ['vendor', '.git', 'node_modules', 'logs']; + $extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf']; + $excludeDirs = ['vendor', '.git', 'node_modules', 'logs']; - $iterator = new RecursiveIteratorIterator( - new RecursiveCallbackFilterIterator( - new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS), - function (\SplFileInfo $fi) use ($excludeDirs): bool { - return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true)); - } - ) - ); + $iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS), + function (\SplFileInfo $fi) use ($excludeDirs): bool { + return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true)); + } + ) + ); - foreach ($iterator as $file) { - /** @var \SplFileInfo $file */ - if (!$file->isFile()) { - continue; - } - $ext = strtolower($file->getExtension()); - if ($ext === 'template') { - $ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION)); - } - if (!in_array($ext, $extensions, true)) { - continue; - } - $content = file_get_contents($file->getPathname()); - if ($content !== false && preg_match($versionRe, $content)) { - $count++; - } - } + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (!$file->isFile()) { + continue; + } + $ext = strtolower($file->getExtension()); + if ($ext === 'template') { + $ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION)); + } + if (!in_array($ext, $extensions, true)) { + continue; + } + $content = file_get_contents($file->getPathname()); + if ($content !== false && preg_match($versionRe, $content)) { + $count++; + } + } - return $count; - } + return $count; + } - // ──────────────────────────────────────────────────────────────────── - // GitHub issue creation - // ──────────────────────────────────────────────────────────────────── + // ──────────────────────────────────────────────────────────────────── + // GitHub issue creation + // ──────────────────────────────────────────────────────────────────── - /** - * Create or update a GitHub issue listing files that could not be auto-updated. - * - * @param string $repo owner/repo - * @param string $version Expected version - * @param int $remaining Number of remaining mismatches - */ - private function createDriftIssue(string $repo, string $version, int $remaining): void - { - if (!isset($this->apiClient)) { - $config = \MokoEnterprise\Config::load(); - try { - $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); - $this->apiClient = $adapter->getApiClient(); - } catch (\Exception $e) { - $this->error('Platform initialization failed: ' . $e->getMessage()); - return; - } - } + /** + * Create or update a GitHub issue listing files that could not be auto-updated. + * + * @param string $repo owner/repo + * @param string $version Expected version + * @param int $remaining Number of remaining mismatches + */ + private function createDriftIssue(string $repo, string $version, int $remaining): void + { + if (!isset($this->apiClient)) { + $config = \MokoEnterprise\Config::load(); + try { + $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); + $this->apiClient = $adapter->getApiClient(); + } catch (\Exception $e) { + $this->error('Platform initialization failed: ' . $e->getMessage()); + return; + } + } - $title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}"; - $labels = ['version-drift', 'maintenance', 'type: chore', 'automation']; - $body = implode("\n", [ - "## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated", - "", - "**Target version:** `{$version}` (from README.md)", - "", - "After the automatic version propagation run, **{$remaining}** file(s) still contain", - "a VERSION field that does not match the README.md version.", - "", - "### How to fix", - "", - "1. Run the sync script locally:", - " ```bash", - " php maintenance/update_version_from_readme.php --path . --dry-run", - " php maintenance/update_version_from_readme.php --path .", - " ```", - "2. Inspect any files still flagged — they may use a non-standard VERSION format.", - "3. Update them manually to match `VERSION: {$version}`.", - "4. Commit and push — this issue will be closed automatically on the next successful sync.", - "", - "---", - "*Automatically created by [update_version_from_readme.php](maintenance/update_version_from_readme.php)*", - ]); + $title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}"; + $labels = ['version-drift', 'maintenance', 'type: chore', 'automation']; + $body = implode("\n", [ + "## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated", + "", + "**Target version:** `{$version}` (from README.md)", + "", + "After the automatic version propagation run, **{$remaining}** file(s) still contain", + "a VERSION field that does not match the README.md version.", + "", + "### How to fix", + "", + "1. Run the sync script locally:", + " ```bash", + " php maintenance/update_version_from_readme.php --path . --dry-run", + " php maintenance/update_version_from_readme.php --path .", + " ```", + "2. Inspect any files still flagged — they may use a non-standard VERSION format.", + "3. Update them manually to match `VERSION: {$version}`.", + "4. Commit and push — this issue will be closed automatically on the next successful sync.", + "", + "---", + "*Automatically created by [update_version_from_readme.php](maintenance/update_version_from_readme.php)*", + ]); - try { - // Check for an existing version-drift issue to avoid duplicates - $existing = $this->apiClient->get("/repos/{$repo}/issues", [ - 'labels' => 'version-drift', - 'state' => 'all', - 'per_page' => 1, - 'sort' => 'created', - 'direction' => 'desc', - ]); + try { + // Check for an existing version-drift issue to avoid duplicates + $existing = $this->apiClient->get("/repos/{$repo}/issues", [ + 'labels' => 'version-drift', + 'state' => 'all', + 'per_page' => 1, + 'sort' => 'created', + 'direction' => 'desc', + ]); - if (!empty($existing[0]['number'])) { - $num = (int) $existing[0]['number']; - $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; - if (($existing[0]['state'] ?? 'open') === 'closed') { - $patch['state'] = 'open'; - } - $this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch); - try { - $this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]); - } catch (\Exception $le) { /* non-fatal */ } - $this->log("✅ Updated issue #{$num} in {$repo}"); - } else { - $issue = $this->apiClient->post("/repos/{$repo}/issues", [ - 'title' => $title, - 'body' => $body, - 'labels' => $labels, - 'assignees' => ['jmiller'], - ]); - $this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}"); - } - } catch (\Exception $e) { - $this->error('Failed to create/update issue: ' . $e->getMessage()); - } - } + if (!empty($existing[0]['number'])) { + $num = (int) $existing[0]['number']; + $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; + if (($existing[0]['state'] ?? 'open') === 'closed') { + $patch['state'] = 'open'; + } + $this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch); + try { + $this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]); + } catch (\Exception $le) { +/* non-fatal */ + } + $this->log("✅ Updated issue #{$num} in {$repo}"); + } else { + $issue = $this->apiClient->post("/repos/{$repo}/issues", [ + 'title' => $title, + 'body' => $body, + 'labels' => $labels, + 'assignees' => ['jmiller'], + ]); + $this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}"); + } + } catch (\Exception $e) { + $this->error('Failed to create/update issue: ' . $e->getMessage()); + } + } } $script = new UpdateVersionFromReadme();