style: fix PHPCS violations across migrated CLI scripts
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 1: Code Quality (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Has been cancelled
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Has been cancelled
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Has been cancelled
Platform: moko-platform CI / Gate 4: Governance (push) Has been cancelled
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been cancelled
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-31 13:36:05 -05:00
parent ae2860c3b5
commit 66e728b078
57 changed files with 5590 additions and 4258 deletions
+3 -1
View File
@@ -111,7 +111,9 @@ class EnrichManifestXmlCli extends CliFramework
if (!isset($enrichment['build'])) { if (!isset($enrichment['build'])) {
$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); $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment); $enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
+12 -2
View File
@@ -111,7 +111,9 @@ class EnrichMokostandardsXmlCli extends CliFramework
if (!isset($enrichment['build'])) { if (!isset($enrichment['build'])) {
$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); $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment); $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, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); $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) { if ($cr !== 0) {
echo "SKIP (no diff)\n"; echo "SKIP (no diff)\n";
$stats['skipped']++; $stats['skipped']++;
+8 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -136,7 +137,13 @@ class ArchiveRepoCli extends CliFramework
$org, $org,
'moko-platform', 'moko-platform',
"chore: archived repository {$repoName}", "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'], 'labels' => ['type: chore', 'automation', 'archived'],
'assignees' => ['jmiller'], 'assignees' => ['jmiller'],
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+7 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -127,7 +128,12 @@ class ClientInventoryCli extends CliFramework
$this->log('INFO', ''); $this->log('INFO', '');
$this->log('INFO', sprintf( $this->log('INFO', sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s', '%-20s | %-35s | %-10s | %-11s | %-19s | %s',
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status' 'Org',
'Repo',
'Dev Config',
'Live Config',
'Last Push',
'Status'
)); ));
$this->log('INFO', str_repeat('-', 120)); $this->log('INFO', str_repeat('-', 120));
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+9 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -282,7 +283,10 @@ class CreateProjectCli extends CliFramework
$result['name'] = $m[1]; $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) { foreach ($matches as $match) {
$field = [ $field = [
'name' => $match[1], 'name' => $match[1],
@@ -376,7 +380,10 @@ class CreateProjectCli extends CliFramework
$vars['singleSelectOptions'] = $optionInputs; $vars['singleSelectOptions'] = $optionInputs;
$this->graphql( $this->graphql(
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) { 'mutation($projectId: ID!, $name: String!,'
. ' $dataType: ProjectV2CustomFieldType!,'
. ' $singleSelectOptions:'
. ' [ProjectV2SingleSelectFieldOptionInput!]) {
createProjectV2Field(input: { createProjectV2Field(input: {
projectId: $projectId, projectId: $projectId,
dataType: $dataType, dataType: $dataType,
+187 -61
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -25,76 +26,201 @@ use MokoEnterprise\PlatformAdapterFactory;
class CreateRepoCli extends CliFramework class CreateRepoCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Scaffold a new governed repository with full moko-platform baseline'); $this->setDescription('Scaffold a new governed repository with full moko-platform baseline');
$this->addArgument('--name', 'Repository name', null); $this->addArgument('--name', 'Repository name', null);
$this->addArgument('--type', 'Project type', null); $this->addArgument('--type', 'Project type', null);
$this->addArgument('--description', 'Repository description', ''); $this->addArgument('--description', 'Repository description', '');
$this->addArgument('--private', 'Create as private', false); $this->addArgument('--private', 'Create as private', false);
} }
protected function run(): int protected function run(): int
{ {
$name = $this->getArgument('--name'); $type = $this->getArgument('--type'); $name = $this->getArgument('--name');
$description = $this->getArgument('--description'); $private = (bool) $this->getArgument('--private'); $type = $this->getArgument('--type');
if (!$name || !$type) { $this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]"); return 2; } $description = $this->getArgument('--description');
$config = Config::load(); $adapter = PlatformAdapterFactory::create($config); $private = (bool) $this->getArgument('--private');
$org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); if (!$name || !$type) {
$repoRoot = dirname(__DIR__, 2); $this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]");
$TYPE_TO_PLATFORM = ['dolibarr' => 'crm-module', 'dolibarr-platform' => 'crm-platform', 'joomla' => 'waas-component', 'nodejs' => 'nodejs', 'terraform' => 'terraform', 'python' => 'python', 'wordpress' => 'wordpress', 'generic' => 'generic']; return 2;
$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']; $config = Config::load();
$platformName = $adapter->getPlatformName(); $adapter = PlatformAdapterFactory::create($config);
echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n Type: {$type} (platform: {$platform})\n Visibility: " . ($private ? 'private' : 'public') . "\n"; $org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
if ($description) { echo " Description: {$description}\n"; } echo "\n"; $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"; echo "Step 1: Creating repository...\n";
if (!$this->dryRun) { if (!$this->dryRun) {
try { 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]); $data = $adapter->createOrgRepo($org, $name, [
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n"; 'description' => $description ?: "Managed by moko-platform ({$type})",
} catch (\Exception $e) { 'private' => $private,
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { echo " Repository already exists -- continuing with setup\n"; } 'has_issues' => true,
else { $this->log('ERROR', "Failed to create repo: " . $e->getMessage()); return 1; } 'has_projects' => true,
} 'has_wiki' => false,
} else { echo " (dry-run) would create {$org}/{$name}\n"; } '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"; echo "Step 2: Setting topics...\n";
if (!$this->dryRun) { $adapter->setRepoTopics($org, $name, $topics); echo " Topics: " . implode(', ', $topics) . "\n"; } if (!$this->dryRun) {
else { echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n"; } $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"; echo "Step 3: Creating .github/.mokostandards...\n";
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\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"; } } if (!$this->dryRun) {
else { echo " (dry-run) would create .github/.mokostandards\n"; } 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"; echo "Step 4: Creating README.md...\n";
$baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com'; $baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com';
$repoUrl = "{$baseUrl}/{$org}/{$name}"; $standardsUrl = "{$baseUrl}/{$org}/MokoStandards"; $repoUrl = "{$baseUrl}/{$org}/{$name}";
$readmeContent = "<!--\nCopyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\nSPDX-License-Identifier: GPL-3.0-or-later\nDEFGROUP: {$name}\nINGROUP: moko-platform\nREPO: {$repoUrl}\nPATH: /README.md\nBRIEF: {$description}\n-->\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"; $standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
if (!$this->dryRun) { $readmeContent = "<!--\n"
$sha = null; . "Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\n"
try { $existing = $adapter->getFileContents($org, $name, 'README.md'); $sha = $existing['sha'] ?? null; } catch (\Exception $e) { $adapter->getApiClient()->resetCircuitBreaker(); } . "SPDX-License-Identifier: GPL-3.0-or-later\n"
$adapter->createOrUpdateFile($org, $name, 'README.md', $readmeContent, 'docs: initialize README with moko-platform header [skip ci]', $sha); . "DEFGROUP: {$name}\n"
echo " README.md created\n"; . "INGROUP: moko-platform\n"
} else { echo " (dry-run) would create README.md\n"; } . "REPO: {$repoUrl}\n"
. "PATH: /README.md\n"
. "BRIEF: {$description}\n"
. "-->\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"; 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"; } } if (!$this->dryRun) {
else { echo " (dry-run) would provision standard labels\n"; } $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"; 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"; } } if (!$this->dryRun) {
else { echo " (dry-run) would run initial sync\n"; } $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"; 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"; } } if (!$this->dryRun) {
else { echo " (dry-run) would create Project\n"; } $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"; echo "\n" . str_repeat('-', 50) . "\n"
return 0; . "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(); $app = new CreateRepoCli();
+4 -1
View File
@@ -233,7 +233,10 @@ class DeployJoomla extends CliFramework
/** /**
* Parse extension metadata from the Joomla XML manifest. * 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<array{type:string, id:string, group:string, client:string, path:string}>}|null * @return array{type:string, element:string, client:string,
* group:string, name:string, shortName:string, version:string,
* subExtensions:list<array{type:string, id:string,
* group:string, client:string, path:string}>}|null
*/ */
private function parseExtensionManifest(string $path): ?array private function parseExtensionManifest(string $path): ?array
{ {
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+59 -19
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -50,7 +51,10 @@ class JoomlaBuildCli extends CliFramework
// ── Find source directory ────────────────────────────────────────────── // ── Find source directory ──────────────────────────────────────────────
$srcDir = null; $srcDir = null;
foreach (['src', 'htdocs'] as $d) { 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) { if ($srcDir === null) {
$this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}"); $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") // Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
if (preg_match('/^[A-Z_]+$/', $meta['name'])) { if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
$resolved = $this->resolveLanguageKey($srcDir, $meta['name']); $resolved = $this->resolveLanguageKey($srcDir, $meta['name']);
if ($resolved !== null) { $meta['name'] = $resolved; } if ($resolved !== null) {
$meta['name'] = $resolved;
}
} }
$prefix = $this->typePrefix($meta); $prefix = $this->typePrefix($meta);
@@ -87,7 +93,9 @@ class JoomlaBuildCli extends CliFramework
$this->log('INFO', " Output: {$zipName}"); $this->log('INFO', " Output: {$zipName}");
// ── Build ────────────────────────────────────────────────────────────── // ── Build ──────────────────────────────────────────────────────────────
if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); } if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
if ($meta['type'] === 'package') { if ($meta['type'] === 'package') {
$this->buildPackageZip($srcDir, $zipPath); $this->buildPackageZip($srcDir, $zipPath);
@@ -114,11 +122,15 @@ class JoomlaBuildCli extends CliFramework
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') { if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
$fh = fopen($ghFile, 'a'); $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); fclose($fh);
$this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT"); $this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT");
} else { } else {
foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; } foreach ($vars as $k => $v) {
echo "{$k}={$v}\n";
}
} }
return 0; return 0;
@@ -131,9 +143,13 @@ class JoomlaBuildCli extends CliFramework
private function findManifest(string $dir): ?string private function findManifest(string $dir): ?string
{ {
// Priority: pkg_*.xml (packages), then any *.xml with <extension> // Priority: pkg_*.xml (packages), then any *.xml with <extension>
foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) { return $f; } foreach (glob("{$dir}/pkg_*.xml") ?: [] as $f) {
return $f;
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) { foreach (glob("{$dir}/*.xml") ?: [] as $f) {
if (str_contains((string) file_get_contents($f), '<extension')) { return $f; } if (str_contains((string) file_get_contents($f), '<extension')) {
return $f;
}
} }
// Broader nested search // Broader nested search
$iter = new RecursiveIteratorIterator( $iter = new RecursiveIteratorIterator(
@@ -167,8 +183,12 @@ class JoomlaBuildCli extends CliFramework
} }
// Fallback element detection // Fallback element detection
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); } if ($element === '') {
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); } $element = (string) ($xml->attributes()->plugin ?? '');
}
if ($element === '') {
$element = (string) ($xml->attributes()->module ?? '');
}
if ($element === '') { if ($element === '') {
$element = strtolower(basename($file, '.xml')); $element = strtolower(basename($file, '.xml'));
if (in_array($element, ['templatedetails', 'manifest'], true)) { 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) // Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas)
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element); $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'); return compact('name', 'type', 'element', 'group');
} }
@@ -216,10 +238,18 @@ class JoomlaBuildCli extends CliFramework
private function isExcluded(string $name): bool private function isExcluded(string $name): bool
{ {
if ($name === '.ftpignore') return true; if ($name === '.ftpignore') {
if (str_starts_with($name, 'sftp-config')) return true; return true;
if (str_starts_with($name, '.env')) return true; }
if (str_starts_with($name, '.build-trigger')) 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); $ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true); return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
} }
@@ -237,7 +267,9 @@ class JoomlaBuildCli extends CliFramework
); );
foreach ($iter as $file) { foreach ($iter as $file) {
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1)); $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); $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
} }
$zip->close(); $zip->close();
@@ -268,8 +300,12 @@ class JoomlaBuildCli extends CliFramework
} }
// 2. Copy package-level files (manifest, script, language) // 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); foreach (glob("{$srcDir}/*.xml") ?: [] as $f) {
foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f)); copy($f, "{$staging}/" . basename($f));
}
foreach (glob("{$srcDir}/*.php") ?: [] as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (['language', 'administrator'] as $d) { foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) { if (is_dir("{$srcDir}/{$d}")) {
$this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}"); $this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
@@ -285,7 +321,9 @@ class JoomlaBuildCli extends CliFramework
private function copyTree(string $src, string $dst): void 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( $iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST RecursiveIteratorIterator::SELF_FIRST
@@ -298,7 +336,9 @@ class JoomlaBuildCli extends CliFramework
private function rmTree(string $dir): void private function rmTree(string $dir): void
{ {
if (!is_dir($dir)) return; if (!is_dir($dir)) {
return;
}
$iter = new RecursiveIteratorIterator( $iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST RecursiveIteratorIterator::CHILD_FIRST
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+44 -17
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -55,17 +56,17 @@ class JoomlaRelease extends CliFramework
'stable' => '', 'stable' => '',
]; ];
private ApiClient $api; private ApiClient $api;
private \MokoEnterprise\GitPlatformAdapter $adapter; private \MokoEnterprise\GitPlatformAdapter $adapter;
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml'); $this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', ''); $this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.'); $this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable'); $this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
$this->addArgument('--dry-run', 'Preview without making changes', false); $this->addArgument('--dry-run', 'Preview without making changes', false);
$this->addArgument('--verbose', 'Show detailed output', false); $this->addArgument('--verbose', 'Show detailed output', false);
} }
protected function run(): int protected function run(): int
@@ -86,7 +87,9 @@ class JoomlaRelease extends CliFramework
if ($repo !== '') { if ($repo !== '') {
$path = $this->cloneRepo($repo); $path = $this->cloneRepo($repo);
if ($path === null) { return 1; } if ($path === null) {
return 1;
}
} }
$path = rtrim($path, '/\\'); $path = rtrim($path, '/\\');
@@ -191,7 +194,9 @@ class JoomlaRelease extends CliFramework
private function findManifest(string $path): ?string private function findManifest(string $path): ?string
{ {
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) { foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
if (!is_dir($dir)) { continue; } if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") as $file) { foreach (glob("{$dir}/*.xml") as $file) {
if (str_contains((string) file_get_contents($file), '<extension')) { if (str_contains((string) file_get_contents($file), '<extension')) {
return $file; return $file;
@@ -235,7 +240,9 @@ class JoomlaRelease extends CliFramework
private function readVersion(string $path): ?string private function readVersion(string $path): ?string
{ {
$readme = "{$path}/README.md"; $readme = "{$path}/README.md";
if (!is_file($readme)) { return null; } if (!is_file($readme)) {
return null;
}
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) { if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/', file_get_contents($readme), $m)) {
return $m[1]; return $m[1];
} }
@@ -301,8 +308,12 @@ class JoomlaRelease extends CliFramework
} }
// 2. Copy package-level files (manifest, script, language) // 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); } foreach (glob("{$srcDir}/*.xml") as $f) {
foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); } copy($f, "{$staging}/" . basename($f));
}
foreach (glob("{$srcDir}/*.php") as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (['language', 'administrator'] as $d) { foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) { if (is_dir("{$srcDir}/{$d}")) {
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}"); $this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
@@ -321,7 +332,9 @@ class JoomlaRelease extends CliFramework
*/ */
private function copyDir(string $src, string $dst): void 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( $iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST \RecursiveIteratorIterator::SELF_FIRST
@@ -342,7 +355,9 @@ class JoomlaRelease extends CliFramework
); );
foreach ($iter as $file) { foreach ($iter as $file) {
$local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname())); $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); $file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
} }
$zip->close(); $zip->close();
@@ -359,17 +374,29 @@ class JoomlaRelease extends CliFramework
private function isExcluded(string $name): bool private function isExcluded(string $name): bool
{ {
if ($name === '.ftpignore') { return true; } if ($name === '.ftpignore') {
if (str_starts_with($name, 'sftp-config')) { return true; } return true;
if (str_starts_with($name, '.env')) { return true; } }
if (str_starts_with($name, 'sftp-config')) {
return true;
}
if (str_starts_with($name, '.env')) {
return true;
}
$ext = pathinfo($name, PATHINFO_EXTENSION); $ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key'], true); return in_array($ext, ['ppk', 'pem', 'key'], true);
} }
// ── GitHub Release ─────────────────────────────────────────────── // ── 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 !== '' $releaseName = $extName !== ''
? "{$extName} {$version} ({$packageName})" ? "{$extName} {$version} ({$packageName})"
: (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})"); : (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})");
+5 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -90,11 +91,13 @@ class LicenseManage extends CliFramework
// Determine subcommand from argv // Determine subcommand from argv
global $argv; global $argv;
foreach ($argv as $arg) { foreach ($argv as $arg) {
if (in_array($arg, [ if (
in_array($arg, [
'list', 'create-package', 'update-package', 'delete-package', 'list', 'create-package', 'update-package', 'delete-package',
'issue', 'revoke', 'activate', 'renew', 'validate', 'issue', 'revoke', 'activate', 'renew', 'validate',
'usage', 'master-key', 'keys', 'packages', 'usage', 'master-key', 'keys', 'packages',
], true)) { ], true)
) {
$this->subcommand = $arg; $this->subcommand = $arg;
break; break;
} }
+164 -66
View File
@@ -21,73 +21,171 @@ use MokoEnterprise\CliFramework;
class ManifestElementCli extends CliFramework class ManifestElementCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest'); $this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
$this->addArgument('--path', 'Repository root', '.'); $this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Version string', null); $this->addArgument('--version', 'Version string', null);
$this->addArgument('--stability', 'Stability level', 'stable'); $this->addArgument('--stability', 'Stability level', 'stable');
$this->addArgument('--repo', 'Repository name', ''); $this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
} }
protected function run(): int protected function run(): int
{ {
$path = $this->getArgument('--path'); $version = $this->getArgument('--version'); $path = $this->getArgument('--path');
$stability = $this->getArgument('--stability'); $repoName = $this->getArgument('--repo'); $version = $this->getArgument('--version');
$githubOutput = (bool) $this->getArgument('--github-output'); $stability = $this->getArgument('--stability');
$root = realpath($path) ?: $path; $repoName = $this->getArgument('--repo');
$platform = 'generic'; $githubOutput = (bool) $this->getArgument('--github-output');
$manifestXml = "{$root}/.mokogitea/manifest.xml"; $root = realpath($path) ?: $path;
if (file_exists($manifestXml)) { $content = file_get_contents($manifestXml); if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) { $platform = trim($pm[1]); } } $platform = 'generic';
$extManifest = null; $manifestXml = "{$root}/.mokogitea/manifest.xml";
$manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []); if (file_exists($manifestXml)) {
foreach ($manifestFiles as $file) { $c = file_get_contents($file); if (strpos($c, '<extension') !== false) { $extManifest = $file; break; } } $content = file_get_contents($manifestXml);
$modFile = null; if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$modFiles = array_merge(glob("{$root}/src/core/modules/mod*.class.php") ?: [], glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [], glob("{$root}/core/modules/mod*.class.php") ?: []); $platform = trim($pm[1]);
foreach ($modFiles as $file) { $c = file_get_contents($file); if (strpos($c, 'extends DolibarrModules') !== false) { $modFile = $file; break; } } }
$extElement = ''; $extType = ''; $extFolder = ''; $extName = ''; }
switch (true) { $extManifest = null;
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null: $manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
$xml = file_get_contents($extManifest); foreach ($manifestFiles as $file) {
if (preg_match('/type="([^"]*)"/', $xml, $tm)) { $extType = $tm[1]; } $c = file_get_contents($file);
if (preg_match('/group="([^"]*)"/', $xml, $gm)) { $extFolder = $gm[1]; } if (strpos($c, '<extension') !== false) {
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) { $extElement = $em[1]; } $extManifest = $file;
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) { $extElement = $mm[1]; } break;
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { $extElement = $pm2[1]; } }
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/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))); } } $modFile = null;
if (preg_match('/<name>([^<]+)<\/name>/', $xml, $nm)) { $extName = trim($nm[1]); } $modFiles = array_merge(
break; glob("{$root}/src/core/modules/mod*.class.php") ?: [],
case in_array($platform, ['dolibarr', 'crm-module'], true) && $modFile !== null: glob("{$root}/htdocs/core/modules/mod*.class.php") ?: [],
$extType = 'dolibarr-module'; $modBasename = basename($modFile, '.class.php'); glob("{$root}/core/modules/mod*.class.php") ?: []
$extElement = strtolower(preg_replace('/^mod/', '', $modBasename)); );
$modContent = file_get_contents($modFile); foreach ($modFiles as $file) {
if (preg_match('/\$this->name\s*=\s*[\'"]([^\'"]+)[\'"]/', $modContent, $nm)) { $extName = $nm[1]; } $c = file_get_contents($file);
break; if (strpos($c, 'extends DolibarrModules') !== false) {
default: $modFile = $file;
$extElement = strtolower(str_replace([' ', '-'], '', $repoName ?: basename($root))); $extType = 'generic'; break; break;
} }
$extElement = preg_replace('/^(pkg_|com_|mod_|plg_[a-z]+_|tpl_|lib_)/', '', $extElement); }
$typePrefix = ''; $extElement = '';
switch ($extType) { $extType = '';
case 'plugin': $typePrefix = "plg_{$extFolder}_"; break; case 'module': $typePrefix = 'mod_'; break; $extFolder = '';
case 'component': $typePrefix = 'com_'; break; case 'template': $typePrefix = 'tpl_'; break; $extName = '';
case 'library': $typePrefix = 'lib_'; break; case 'package': $typePrefix = 'pkg_'; break; switch (true) {
} case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$suffixMap = ['development' => '-dev', 'dev' => '-dev', 'alpha' => '-alpha', 'beta' => '-beta', 'rc' => '-rc', 'release-candidate' => '-rc', 'stable' => '']; $xml = file_get_contents($extManifest);
$suffix = $suffixMap[$stability] ?? ''; $zipName = ''; if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
if ($version !== null) { $zipName = "{$typePrefix}{$extElement}-{$version}{$suffix}.zip"; } $extType = $tm[1];
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 (preg_match('/group="([^"]*)"/', $xml, $gm)) {
if ($githubOutput) { $extFolder = $gm[1];
$ghOutput = getenv('GITHUB_OUTPUT'); $lines = []; }
foreach ($outputs as $key => $value) { $lines[] = "{$key}={$value}"; } if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
if ($ghOutput) { file_put_contents($ghOutput, implode("\n", $lines) . "\n", FILE_APPEND); } $extElement = $em[1];
else { foreach ($outputs as $key => $value) { echo "::set-output name={$key}::{$value}\n"; } } }
} else { foreach ($outputs as $key => $value) { echo "{$key}={$value}\n"; } } if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
return 0; $extElement = $mm[1];
} }
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
$extElement = $pm2[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/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>([^<]+)<\/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(); $app = new ManifestElementCli();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+146 -80
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,89 +21,154 @@ use MokoEnterprise\CliFramework;
class ReleaseManageCli extends CliFramework class ReleaseManageCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Create/update Gitea releases, upload assets, update release body'); $this->setDescription('Create/update Gitea releases, upload assets, update release body');
$this->addArgument('--action', 'create | upload | update-body | delete', null); $this->addArgument('--action', 'create | upload | update-body | delete', null);
$this->addArgument('--tag', 'Release tag name', null); $this->addArgument('--tag', 'Release tag name', null);
$this->addArgument('--name', 'Release name/title', null); $this->addArgument('--name', 'Release name/title', null);
$this->addArgument('--body', 'Release body/description', null); $this->addArgument('--body', 'Release body/description', null);
$this->addArgument('--body-file', 'Read body from file', null); $this->addArgument('--body-file', 'Read body from file', null);
$this->addArgument('--target', 'Target branch/commitish', 'main'); $this->addArgument('--target', 'Target branch/commitish', 'main');
$this->addArgument('--files', 'Comma-separated file paths to upload', null); $this->addArgument('--files', 'Comma-separated file paths to upload', null);
$this->addArgument('--token', 'Gitea API token', null); $this->addArgument('--token', 'Gitea API token', null);
$this->addArgument('--api-base', 'Gitea API base URL', null); $this->addArgument('--api-base', 'Gitea API base URL', null);
} }
protected function run(): int protected function run(): int
{ {
$action = $this->getArgument('--action'); $tag = $this->getArgument('--tag'); $action = $this->getArgument('--action');
$name = $this->getArgument('--name'); $body = $this->getArgument('--body'); $tag = $this->getArgument('--tag');
$bodyFile = $this->getArgument('--body-file'); $target = $this->getArgument('--target'); $name = $this->getArgument('--name');
$filesArg = $this->getArgument('--files'); $token = $this->getArgument('--token'); $body = $this->getArgument('--body');
$apiBase = $this->getArgument('--api-base'); $bodyFile = $this->getArgument('--body-file');
$files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : []; $target = $this->getArgument('--target');
if ($token === null) { $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; } $filesArg = $this->getArgument('--files');
if ($bodyFile !== null && file_exists($bodyFile)) { $body = file_get_contents($bodyFile); } $token = $this->getArgument('--token');
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; } $apiBase = $this->getArgument('--api-base');
switch ($action) { $files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : [];
case 'create': if ($token === null) {
$existing = $this->getReleaseByTag($apiBase, $tag, $token); $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
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]); if ($bodyFile !== null && file_exists($bodyFile)) {
$result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload); $body = file_get_contents($bodyFile);
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; } if ($action === null || $tag === null || $token === null || $apiBase === null) {
break; $this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL");
case 'upload': return 1;
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); switch ($action) {
if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; } case 'create':
$releaseId = $release['id']; $existing = $this->getReleaseByTag($apiBase, $tag, $token);
$assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token); if ($existing !== null) {
$existingAssets = $assetsResult['data'] ?? []; $existingId = $existing['id'];
foreach ($files as $filePath) { $this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
$filePath = trim($filePath); if (!file_exists($filePath)) { $this->log('ERROR', "File not found: {$filePath}"); continue; } $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
$fileName = basename($filePath); echo "Deleted previous release: {$tag} (id: {$existingId})\n";
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); $payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]);
$result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath); $result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) { echo "Uploaded: {$fileName}\n"; } else { $this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}"); } if ($result['code'] >= 200 && $result['code'] < 300) {
} $releaseId = $result['data']['id'] ?? 'unknown';
break; echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
case 'update-body': } else {
$release = $this->getReleaseByTag($apiBase, $tag, $token); $this->log('ERROR', "Failed to create release: HTTP {$result['code']}");
if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; } return 1;
$payload = json_encode(['body' => $body ?? '']); }
$result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload); break;
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; } case 'upload':
break; if (empty($files)) {
case 'delete': $this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2");
$existing = $this->getReleaseByTag($apiBase, $tag, $token); return 1;
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"; } $release = $this->getReleaseByTag($apiBase, $tag, $token);
break; if ($release === null) {
default: $this->log('ERROR', "Unknown action: {$action}"); return 1; $this->log('ERROR', "No release found for tag: {$tag}");
} return 1;
return 0; }
} $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 private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{ {
$ch = curl_init($url); $headers = ["Authorization: token {$token}"]; $ch = curl_init($url);
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method]; $headers = ["Authorization: token {$token}"];
if ($jsonBody !== null) { $headers[] = 'Content-Type: application/json'; $opts[CURLOPT_POSTFIELDS] = $jsonBody; } $opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
elseif ($filePath !== null) { $headers[] = 'Content-Type: application/octet-stream'; $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); } if ($jsonBody !== null) {
$opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); $headers[] = 'Content-Type: application/json';
$response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $opts[CURLOPT_POSTFIELDS] = $jsonBody;
return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []]; } 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 private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
{ {
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token); $result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null; return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
} }
} }
$app = new ReleaseManageCli(); $app = new ReleaseManageCli();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+497 -497
View File
File diff suppressed because it is too large Load Diff
+100 -24
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * 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 // Auto-detect org/repo from git remote if not set
if (empty($org) || empty($repo)) { 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 (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
if (empty($org)) $org = $m[1]; if (empty($org)) {
if (empty($repo)) $repo = $m[2]; $org = $m[1];
}
if (empty($repo)) {
$repo = $m[2];
}
} }
} }
@@ -76,7 +85,12 @@ class ReleasePublishCli extends CliFramework
// Auto-detect branch // Auto-detect branch
if (empty($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}"; $apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
@@ -145,12 +159,23 @@ class ReleasePublishCli extends CliFramework
// -- Step 2b: Update badges and changelog -- // -- Step 2b: Update badges and changelog --
if (!$this->dryRun) { 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'; $changelogFile = realpath($path) . '/CHANGELOG.md';
if (file_exists($changelogFile)) { if (file_exists($changelogFile)) {
passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); passthru(
passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null"); "{$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; $root = realpath($path) ?: $path;
if (!$this->dryRun) { if (!$this->dryRun) {
// Configure git // Configure git
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\""); $cdPfx = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\""); $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)) { 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) // 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(
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"); $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 // Re-apply version changes on the checked-out branch
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path) passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion) . " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch) . " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>/dev/null"); . " --stability " . escapeshellarg($stability) . " 2>/dev/null");
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null"); passthru(
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null"); "{$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') { if ($diffCheck === 'dirty') {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A"); @shell_exec($cdR . " && git add -A");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]") $commitMsg = "chore(release): build"
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\""); . " {$releaseVersion} [skip ci]";
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); @shell_exec(
$cdR . " && git commit -m "
. escapeshellarg($commitMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
$pushResult = @shell_exec(
$cdR . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo " Committed release changes\n"; echo " Committed release changes\n";
echo " Push: " . trim($pushResult ?? '') . "\n"; echo " Push: " . trim($pushResult ?? '') . "\n";
} }
@@ -258,12 +320,26 @@ class ReleasePublishCli extends CliFramework
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
if (!$this->dryRun) { 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') { if ($diffCheck === 'dirty') {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml"); @shell_exec($cdRt . " && 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]") $chMsg = "chore: update channels for"
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\""); . " {$releaseVersion} [skip ci]";
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1"); @shell_exec(
$cdRt . " && git commit -m "
. escapeshellarg($chMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
@shell_exec(
$cdRt . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo " Committed updates.xml\n"; echo " Committed updates.xml\n";
} }
+207 -61
View File
@@ -21,70 +21,216 @@ use MokoEnterprise\CliFramework;
class ReleaseValidateCli extends CliFramework class ReleaseValidateCli extends CliFramework
{ {
private int $pass = 0; private int $pass = 0;
private int $fail = 0; private int $fail = 0;
private int $warn = 0; private int $warn = 0;
private array $results = []; private array $results = [];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Pre-release validation -- version consistency, required files, manifest checks'); $this->setDescription('Pre-release validation -- version consistency, required files, manifest checks');
$this->addArgument('--path', 'Repository root', '.'); $this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Expected version string', null); $this->addArgument('--version', 'Expected version string', null);
$this->addArgument('--platform', 'joomla|dolibarr|generic', null); $this->addArgument('--platform', 'joomla|dolibarr|generic', null);
$this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false); $this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false);
$this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false); $this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false);
} }
protected function run(): int protected function run(): int
{ {
$path = $this->getArgument('--path'); $version = $this->getArgument('--version'); $path = $this->getArgument('--path');
$platform = $this->getArgument('--platform'); $version = $this->getArgument('--version');
$outputSummary = (bool) $this->getArgument('--output-summary'); $platform = $this->getArgument('--platform');
$githubOutput = (bool) $this->getArgument('--github-output'); $outputSummary = (bool) $this->getArgument('--output-summary');
if ($version === null) { $this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]"); return 1; } $githubOutput = (bool) $this->getArgument('--github-output');
$root = realpath($path) ?: $path; if ($version === null) {
if ($platform === null) { $this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]");
$manifestXml = "{$root}/.mokogitea/manifest.xml"; return 1;
if (file_exists($manifestXml)) { $mContent = file_get_contents($manifestXml); if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) { $platform = trim($pm[1]); } } }
if (in_array($platform, ['waas-component'], true)) { $platform = 'joomla'; } $root = realpath($path) ?: $path;
if (in_array($platform, ['crm-module'], true)) { $platform = 'dolibarr'; } if ($platform === null) {
if ($platform === null) { $platform = 'generic'; } $manifestXml = "{$root}/.mokogitea/manifest.xml";
} if (file_exists($manifestXml)) {
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs"); $mContent = file_get_contents($manifestXml);
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory'); if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
if (!file_exists("{$root}/README.md")) { $this->addVResult('README.md', 'FAIL', 'Not found'); } $platform = trim($pm[1]);
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"); } if (in_array($platform, ['waas-component'], true)) {
$licenseFound = false; foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) { if (file_exists("{$root}/{$lf}")) { $licenseFound = true; break; } } $platform = 'joomla';
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found'); }
if ($platform === 'joomla') { if (in_array($platform, ['crm-module'], true)) {
$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, '<extension') !== false) { $manifest = $xmlFile; break 2; } } } $platform = 'dolibarr';
if ($manifest === null) { $this->addVResult('XML manifest', 'FAIL', 'No Joomla manifest found'); } }
else { if (preg_match('/<version>([^<]+)<\/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 <version> tag'); } } if ($platform === null) {
if (!file_exists("{$root}/updates.xml")) { $this->addVResult('updates.xml', 'WARN', 'Not found'); } $platform = 'generic';
else { $ux = file_get_contents("{$root}/updates.xml"); $this->addVResult('updates.xml version', preg_match('/<version>' . preg_quote($version, '/') . '<\/version>/', $ux) ? 'PASS' : 'FAIL', preg_match('/<version>' . 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; } } $hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
if ($modFile === null) { $this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found'); } $this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory');
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}/README.md")) {
} $this->addVResult('README.md', 'FAIL', '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}`"); } } } else {
$table = "| Check | Result | Details |\n|-------|--------|--------|\n"; $readme = file_get_contents("{$root}/README.md");
foreach ($this->results as $r) { $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; } $quotedVer = preg_quote($version, '/');
$table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n"; $readmeHasVer = preg_match(
echo $table; '/VERSION:\s*' . $quotedVer . '/',
if ($outputSummary) { $summaryFile = getenv('GITHUB_STEP_SUMMARY'); if ($summaryFile) { file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND); } } $readme
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); } } ) || strpos($readme, $version) !== false;
return $this->fail > 0 ? 1 : 0; $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, '<extension') !== false) {
$manifest = $xmlFile;
break 2;
}
}
}
if ($manifest === null) {
$this->addVResult('XML manifest', 'FAIL', 'No Joomla manifest found');
} else {
$manifestContent = file_get_contents($manifest);
if (preg_match('/<version>([^<]+)<\/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 <version> 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(
'/<version>' . 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 private function addVResult(string $check, string $status, string $details): void
{ {
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details]; $this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') { $this->pass++; } elseif ($status === 'FAIL') { $this->fail++; } elseif ($status === 'WARN') { $this->warn++; } if ($status === 'PASS') {
} $this->pass++;
} elseif ($status === 'FAIL') {
$this->fail++;
} elseif ($status === 'WARN') {
$this->warn++;
}
}
} }
$app = new ReleaseValidateCli(); $app = new ReleaseValidateCli();
+15 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -103,7 +104,13 @@ class ReleaseVerifyCli extends CliFramework
if ($zipSha === $expectedSha) { if ($zipSha === $expectedSha) {
$this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`'); $this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
} else { } 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 { } else {
$this->addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml'); $this->addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
@@ -145,7 +152,13 @@ class ReleaseVerifyCli extends CliFramework
} }
// Clean up // 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) { foreach ($rit as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
} }
+103 -51
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -23,60 +24,111 @@ use MokoEnterprise\CliFramework;
class ScaffoldClientCli extends CliFramework class ScaffoldClientCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS'); $this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
$this->addArgument('--name', 'Client name', ''); $this->addArgument('--name', 'Client name', '');
$this->addArgument('--org', 'Gitea organization', ''); $this->addArgument('--org', 'Gitea organization', '');
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech'); $this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', ''); $this->addArgument('--token', 'Gitea API token', '');
} }
protected function run(): int protected function run(): int
{ {
$name = $this->getArgument('--name'); $org = $this->getArgument('--org'); $name = $this->getArgument('--name');
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $token = $this->getArgument('--token'); $org = $this->getArgument('--org');
if ($name === '' || $org === '' || $token === '') { $this->log('ERROR', '--name, --org, and --token are required.'); return 1; } $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$repoName = 'client-waas-' . $name; $token = $this->getArgument('--token');
$this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}"); if ($name === '' || $org === '' || $token === '') {
$this->log('INFO', "Gitea URL: {$giteaUrl}"); $this->log('ERROR', '--name, --org, and --token are required.');
if ($this->dryRun) { return 1;
$this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS'); }
$this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}"); $repoName = 'client-waas-' . $name;
$this->printPostSetupInstructions($repoName, $giteaUrl, $org); $this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}");
return 0; $this->log('INFO', "Gitea URL: {$giteaUrl}");
} if ($this->dryRun) {
$this->log('INFO', 'Step 1: Creating repo from template...'); $this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
$createPayload = json_encode(['owner' => $org, 'name' => $repoName, 'description' => "{$name} WaaS site", 'private' => true, 'git_content' => true, 'topics' => true, 'labels' => true]); $this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}");
$response = $this->apiRequest('POST', "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", $giteaUrl, $token, $createPayload); $this->printPostSetupInstructions($repoName, $giteaUrl, $org);
if ($response['code'] < 200 || $response['code'] >= 300) { $this->log('ERROR', "Failed to create repo (HTTP {$response['code']})."); return 1; } return 0;
$this->log('INFO', "Repo created: {$org}/{$repoName}"); }
$this->log('INFO', 'Step 2: Updating repo description...'); $this->log('INFO', 'Step 1: Creating repo from template...');
$this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"])); $createPayload = json_encode([
$this->log('INFO', 'Step 3: Creating dev branch from main...'); 'owner' => $org,
$response = $this->apiRequest('POST', "/api/v1/repos/{$org}/{$repoName}/branches", $giteaUrl, $token, json_encode(['new_branch_name' => 'dev', 'old_branch_name' => 'main'])); 'name' => $repoName,
if ($response['code'] >= 200 && $response['code'] < 300) { $this->log('INFO', 'Branch "dev" created from "main".'); } 'description' => "{$name} WaaS site",
else { $this->log('WARN', "Could not create dev branch (HTTP {$response['code']})."); } 'private' => true,
$this->printPostSetupInstructions($repoName, $giteaUrl, $org); 'git_content' => true,
$this->log('INFO', 'Scaffold complete.'); 'topics' => true,
return 0; '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 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"); 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 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); $ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
$responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
if (curl_errno($ch)) { $error = curl_error($ch); curl_close($ch); return ['code' => 0, 'body' => "cURL error: {$error}"]; } if ($body !== null) {
curl_close($ch); return ['code' => $httpCode, 'body' => $responseBody]; 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(); $app = new ScaffoldClientCli();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
+153 -139
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,159 +21,172 @@ use MokoEnterprise\CliFramework;
class ThemeLintCli extends CliFramework class ThemeLintCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs'); $this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
$this->addArgument('--path', 'Repository root', '.'); $this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500'); $this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false); $this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
$this->addArgument('--strict', 'Exit 1 on any warning', false); $this->addArgument('--strict', 'Exit 1 on any warning', false);
} }
protected function run(): int protected function run(): int
{ {
$path = $this->getArgument('--path'); $path = $this->getArgument('--path');
$maxImageKb = (int) $this->getArgument('--max-image-kb'); $maxImageKb = (int) $this->getArgument('--max-image-kb');
$ghOutput = (bool) $this->getArgument('--github-output'); $ghOutput = (bool) $this->getArgument('--github-output');
$strict = (bool) $this->getArgument('--strict'); $strict = (bool) $this->getArgument('--strict');
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
$errors = 0; $errors = 0;
$warnings = 0; $warnings = 0;
$srcDir = null; $srcDir = null;
foreach (['src', 'htdocs'] as $d) { foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; } if (is_dir("{$root}/{$d}")) {
} $srcDir = "{$root}/{$d}";
if ($srcDir === null) { break;
$this->log('ERROR', "No src/ or htdocs/ directory in {$root}"); }
return 1; }
} 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"; echo "--- CSS Syntax ---\n";
$cssFiles = $this->findFiles($srcDir, '*.css'); $cssFiles = $this->findFiles($srcDir, '*.css');
$cssMinFiles = $this->findFiles($srcDir, '*.min.css'); $cssMinFiles = $this->findFiles($srcDir, '*.min.css');
$cssToCheck = array_diff($cssFiles, $cssMinFiles); $cssToCheck = array_diff($cssFiles, $cssMinFiles);
if (empty($cssToCheck)) { if (empty($cssToCheck)) {
echo " No CSS files to check\n"; echo " No CSS files to check\n";
} else { } else {
foreach ($cssToCheck as $file) { foreach ($cssToCheck as $file) {
$content = file_get_contents($file); $content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file); $relPath = str_replace($root . '/', '', $file);
$openBraces = substr_count($content, '{'); $openBraces = substr_count($content, '{');
$closeBraces = substr_count($content, '}'); $closeBraces = substr_count($content, '}');
if ($openBraces !== $closeBraces) { if ($openBraces !== $closeBraces) {
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n"; echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
$errors++; $errors++;
} }
if (preg_match_all('/\{[\s]*\}/', $content, $m)) { if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
$count = count($m[0]); $count = count($m[0]);
echo " WARN: {$relPath}: {$count} empty rule(s)\n"; echo " WARN: {$relPath}: {$count} empty rule(s)\n";
$warnings++; $warnings++;
} }
$importantCount = substr_count($content, '!important'); $importantCount = substr_count($content, '!important');
if ($importantCount > 10) { if ($importantCount > 10) {
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n"; echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
$warnings++; $warnings++;
} }
} }
if ($errors === 0) { if ($errors === 0) {
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n"; echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
} }
} }
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n"; echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp']; $imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
$images = []; $images = [];
foreach ($imageExts as $ext) { foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles($srcDir, $ext)); $images = array_merge($images, $this->findFiles($srcDir, $ext));
} }
if (is_dir("{$root}/images")) { if (is_dir("{$root}/images")) {
foreach ($imageExts as $ext) { foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles("{$root}/images", $ext)); $images = array_merge($images, $this->findFiles("{$root}/images", $ext));
} }
} }
$oversized = 0; $oversized = 0;
$totalSize = 0; $totalSize = 0;
foreach ($images as $file) { foreach ($images as $file) {
$size = filesize($file); $size = filesize($file);
$totalSize += $size; $totalSize += $size;
$relPath = str_replace($root . '/', '', $file); $relPath = str_replace($root . '/', '', $file);
$sizeKb = round($size / 1024); $sizeKb = round($size / 1024);
if ($sizeKb > $maxImageKb) { if ($sizeKb > $maxImageKb) {
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n"; echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
$oversized++; $oversized++;
$warnings++; $warnings++;
} }
} }
$totalMb = round($totalSize / 1024 / 1024, 1); $totalMb = round($totalSize / 1024 / 1024, 1);
echo " " . count($images) . " image(s), {$totalMb}MB total"; echo " " . count($images) . " image(s), {$totalMb}MB total";
if ($oversized > 0) { echo ", {$oversized} oversized"; } if ($oversized > 0) {
echo "\n"; echo ", {$oversized} oversized";
}
echo "\n";
echo "\n--- Hardcoded URLs ---\n"; echo "\n--- Hardcoded URLs ---\n";
$codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js')); $codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
$codeFiles = array_filter($codeFiles, function ($f) { $codeFiles = array_filter($codeFiles, function ($f) {
return !preg_match('/\.min\.(css|js)$/', $f); return !preg_match('/\.min\.(css|js)$/', $f);
}); });
$urlPatterns = [ $urlPatterns = [
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL', '/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL', '/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
'/https?:\/\/localhost/' => 'localhost reference', '/https?:\/\/localhost/' => 'localhost reference',
]; ];
$urlIssues = 0; $urlIssues = 0;
foreach ($codeFiles as $file) { foreach ($codeFiles as $file) {
$content = file_get_contents($file); $content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file); $relPath = str_replace($root . '/', '', $file);
foreach ($urlPatterns as $pattern => $desc) { foreach ($urlPatterns as $pattern => $desc) {
if (preg_match_all($pattern, $content, $matches)) { if (preg_match_all($pattern, $content, $matches)) {
$count = count($matches[0]); $count = count($matches[0]);
echo " WARN: {$relPath}: {$count} {$desc}\n"; echo " WARN: {$relPath}: {$count} {$desc}\n";
$urlIssues++; $urlIssues++;
$warnings++; $warnings++;
} }
} }
} }
if ($urlIssues === 0) { echo " OK: No hardcoded URLs found\n"; } if ($urlIssues === 0) {
echo " OK: No hardcoded URLs found\n";
}
echo "\n=== Summary ===\n"; echo "\n=== Summary ===\n";
echo "Errors: {$errors}\n"; echo "Errors: {$errors}\n";
echo "Warnings: {$warnings}\n"; echo "Warnings: {$warnings}\n";
if ($ghOutput) { if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT'); $ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) { if ($ghFile) {
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND); 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_warnings={$warnings}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_images=" . count($images) . "\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); file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
} }
} }
if ($errors > 0) { return 1; } if ($errors > 0) {
if ($strict && $warnings > 0) { return 1; } return 1;
return 0; }
} if ($strict && $warnings > 0) {
return 1;
}
return 0;
}
private function findFiles(string $dir, string $pattern): array private function findFiles(string $dir, string $pattern): array
{ {
$results = []; $results = [];
if (!is_dir($dir)) { return $results; } if (!is_dir($dir)) {
$iterator = new RecursiveIteratorIterator( return $results;
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) }
); $iterator = new RecursiveIteratorIterator(
foreach ($iterator as $file) { new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
if (fnmatch($pattern, $file->getFilename())) { );
$results[] = $file->getPathname(); foreach ($iterator as $file) {
} if (fnmatch($pattern, $file->getFilename())) {
} $results[] = $file->getPathname();
return $results; }
} }
return $results;
}
} }
$app = new ThemeLintCli(); $app = new ThemeLintCli();
+24 -8
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -94,7 +95,8 @@ class UpdatesXmlSyncCli extends CliFramework
$discovered = []; $discovered = [];
foreach ($branchList as $b) { foreach ($branchList as $b) {
$name = $b['name'] ?? ''; $name = $b['name'] ?? '';
if ($name !== '' && $name !== $current if (
$name !== '' && $name !== $current
&& !str_starts_with($name, 'version/') && !str_starts_with($name, 'version/')
&& !str_starts_with($name, 'feature/') && !str_starts_with($name, 'feature/')
&& !str_starts_with($name, 'patch/') && !str_starts_with($name, 'patch/')
@@ -144,8 +146,14 @@ class UpdatesXmlSyncCli extends CliFramework
continue; continue;
} }
$ok = $this->putFile($apiBase, $token, $branch, $encoded, $sha, $ok = $this->putFile(
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]"); $apiBase,
$token,
$branch,
$encoded,
$sha,
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]"
);
if ($ok) { if ($ok) {
$this->log('INFO', "Synced to {$branch}"); $this->log('INFO', "Synced to {$branch}");
@@ -166,9 +174,14 @@ class UpdatesXmlSyncCli extends CliFramework
return $resp['sha'] ?? null; return $resp['sha'] ?? null;
} }
private function putFile(string $apiBase, string $token, string $branch, private function putFile(
string $encoded, string $sha, string $msg): bool string $apiBase,
{ string $token,
string $branch,
string $encoded,
string $sha,
string $msg
): bool {
$resp = $this->apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [ $resp = $this->apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [
'content' => $encoded, 'content' => $encoded,
'sha' => $sha, 'sha' => $sha,
@@ -194,8 +207,11 @@ class UpdatesXmlSyncCli extends CliFramework
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
if ($data !== null) { if ($data !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, curl_setopt(
json_encode($data, JSON_UNESCAPED_SLASHES)); $ch,
CURLOPT_POSTFIELDS,
json_encode($data, JSON_UNESCAPED_SLASHES)
);
} }
$body = curl_exec($ch); $body = curl_exec($ch);
+35 -9
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -82,8 +83,11 @@ class VersionAutoBumpCli extends CliFramework
$shouldBump = true; $shouldBump = true;
if (!empty($watchPath)) { if (!empty($watchPath)) {
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$diffOutput = trim((string) @shell_exec( $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)) { if (empty($diffOutput)) {
echo "No changes in {$watchPath} — skipping version bump\n"; echo "No changes in {$watchPath} — skipping version bump\n";
@@ -148,28 +152,50 @@ class VersionAutoBumpCli extends CliFramework
$root = realpath($path) ?: $path; $root = realpath($path) ?: $path;
// Check if anything changed // 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') { if ($diffStatus === 'clean') {
echo "No version changes to commit\n"; echo "No version changes to commit\n";
return 0; return 0;
} }
// Configure git // Configure git
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\""); $cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\""); $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)) { 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 $commitMsg = $shouldBump
? "chore(version): auto-bump patch {$displayVersion} [skip ci]" ? "chore(version): auto-bump patch {$displayVersion} [skip ci]"
: "chore(version): set {$stability} suffix {$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) @shell_exec(
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\""); $cdRoot . " && git commit -m " . escapeshellarg($commitMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
$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 $pushResult ?? '';
echo "Bumped to {$displayVersion}\n"; echo "Bumped to {$displayVersion}\n";
+227 -64
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,71 +21,233 @@ use MokoEnterprise\CliFramework;
class VersionBumpCli extends CliFramework class VersionBumpCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files'); $this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files');
$this->addArgument('--path', 'Repository root', '.'); $this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--minor', 'Bump minor version', false); $this->addArgument('--minor', 'Bump minor version', false);
$this->addArgument('--major', 'Bump major version', false); $this->addArgument('--major', 'Bump major version', false);
} }
protected function run(): int protected function run(): int
{ {
$path = $this->getArgument('--path'); $type = 'patch'; $path = $this->getArgument('--path');
if ($this->getArgument('--minor')) $type = 'minor'; $type = 'patch';
if ($this->getArgument('--major')) $type = 'major'; if ($this->getArgument('--minor')) {
$root = realpath($path) ?: $path; $type = 'minor';
$mokoVersion = null; $mokoManifest = "{$root}/.mokogitea/manifest.xml"; $mokoContent = ''; }
if (file_exists($mokoManifest)) { $mokoContent = file_get_contents($mokoManifest); if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?</version>#', $mokoContent, $m)) { $mokoVersion = $m[1]; } } if ($this->getArgument('--major')) {
$readmeVersion = null; $readme = "{$root}/README.md"; $readmeContent = ''; $type = 'major';
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; $root = realpath($path) ?: $path;
$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") ?: []); $mokoVersion = null;
foreach ($manifestFiles as $xmlFile) { $xmlContent = file_get_contents($xmlFile); if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) { continue; } if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) { $candidate = $xm[1]; if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) { $manifestVersion = $candidate; } } } $mokoManifest = "{$root}/.mokogitea/manifest.xml";
$baseVersion = null; $candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]); $mokoContent = '';
foreach ($candidates as $v) { if ($baseVersion === null || version_compare($v, $baseVersion, '>')) { $baseVersion = $v; } } if (file_exists($mokoManifest)) {
if ($baseVersion === null) { $this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML"); return 1; } $mokoContent = file_get_contents($mokoManifest);
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) { $this->log('ERROR', "Invalid version format: {$baseVersion}"); return 1; } if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?</version>#', $mokoContent, $m)) {
$major = (int)$parts[1]; $minor = (int)$parts[2]; $patch = (int)$parts[3]; $mokoVersion = $m[1];
$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); $readmeVersion = null;
if (file_exists($mokoManifest) && !empty($mokoContent)) { $updated = preg_replace('#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', "<version>{$newFull}</version>", $mokoContent, 1); if ($updated !== null) { file_put_contents($mokoManifest, $updated); } } $readme = "{$root}/README.md";
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); } } $readmeContent = '';
$updatedFiles = []; if (file_exists($readme)) {
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $pattern) { $readmeContent = file_get_contents($readme);
foreach (glob($pattern) ?: [] as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, '<extension') === false) { continue; } $newContent = preg_replace('#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', "<version>{$newFull}</version>", $content); if ($newContent !== null && $newContent !== $content) { file_put_contents($xmlFile, $newContent); $updatedFiles[] = substr($xmlFile, strlen($root) + 1); } } if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
} $readmeVersion = $m[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"); } } $manifestVersion = null;
$pyprojectFile = "{$root}/pyproject.toml"; $manifestFiles = array_merge(
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"); } } glob("{$root}/src/pkg_*.xml") ?: [],
$changelogFile = "{$root}/CHANGELOG.md"; glob("{$root}/src/*.xml") ?: [],
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"); } } glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
$scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js']; glob("{$root}/src/packages/*/*.xml") ?: [],
$excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude']; glob("{$root}/*.xml") ?: []
$versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m'; );
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS); foreach ($manifestFiles as $xmlFile) {
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) { if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) { return false; } return true; }); $xmlContent = file_get_contents($xmlFile);
$iterator = new RecursiveIteratorIterator($filter); if (strpos($xmlContent, '<extension') === false && strpos($xmlContent, '<version>') === false) {
$genericUpdated = []; continue;
foreach ($iterator as $fileInfo) { } if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})((?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) {
if ($fileInfo->isDir()) { continue; } $candidate = $xm[1];
$ext = strtolower($fileInfo->getExtension()); if (!in_array($ext, $scanExtensions, true)) { continue; } if ($manifestVersion === null || version_compare($candidate, $manifestVersion, '>')) {
$filePath = $fileInfo->getPathname(); $relPath = str_replace([$root . '/', $root . '\\'], '', $filePath); $manifestVersion = $candidate;
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; } $baseVersion = null;
if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) { continue; } $candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
$updated = preg_replace($versionPattern, '${1}' . $newFull, $content); foreach ($candidates as $v) {
if ($updated !== null && $updated !== $content) { file_put_contents($filePath, $updated); $genericUpdated[] = $relPath; } if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
} $baseVersion = $v;
if (!empty($genericUpdated)) { fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n"); } }
echo "{$old} -> {$newFull}\n"; }
return 0; 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 = '#<version>\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
$updated = preg_replace(
$pattern,
"<version>{$newFull}</version>",
$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, '<extension') === false) {
continue;
}
$xmlPattern = '#<version>\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
$newContent = preg_replace(
$xmlPattern,
"<version>{$newFull}</version>",
$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(); $app = new VersionBumpCli();
+171 -85
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,95 +21,180 @@ use MokoEnterprise\CliFramework;
class VersionBumpRemoteCli extends CliFramework class VersionBumpRemoteCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API'); $this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API');
$this->addArgument('--path', 'Repository root', '.'); $this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--branch', 'Target branch to bump (required)', null); $this->addArgument('--branch', 'Target branch to bump (required)', null);
$this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor'); $this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor');
$this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null); $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('--api-base', 'Gitea API base URL for the repo', null);
$this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false); $this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false);
$this->addArgument('--repo', 'Repository path (owner/repo)', null); $this->addArgument('--repo', 'Repository path (owner/repo)', null);
$this->addArgument('--gitea-url', 'Gitea instance URL', null); $this->addArgument('--gitea-url', 'Gitea instance URL', null);
} }
protected function run(): int protected function run(): int
{ {
$path = $this->getArgument('--path'); $branch = $this->getArgument('--branch'); $path = $this->getArgument('--path');
$bumpType = $this->getArgument('--bump'); $token = $this->getArgument('--token'); $branch = $this->getArgument('--branch');
$apiBase = $this->getArgument('--api-base'); $noChangelog = (bool) $this->getArgument('--no-changelog'); $bumpType = $this->getArgument('--bump');
$repo = $this->getArgument('--repo'); $giteaUrl = $this->getArgument('--gitea-url'); $token = $this->getArgument('--token');
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; $apiBase = $this->getArgument('--api-base');
if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech'; $noChangelog = (bool) $this->getArgument('--no-changelog');
if ($apiBase === null && $repo !== null) { $apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo; } $repo = $this->getArgument('--repo');
if ($branch === null || $token === null || $apiBase === null) { $giteaUrl = $this->getArgument('--gitea-url');
$this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]"); if ($token === null) {
return 1; $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
} }
$root = realpath($path) ?: $path; if ($giteaUrl === null) {
$version = null; $manifestFile = null; $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
foreach (["{$root}/src", $root] as $dir) { }
if (!is_dir($dir)) continue; if ($apiBase === null && $repo !== null) {
foreach (glob("{$dir}/*.xml") ?: [] as $f) { $apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
$xml = file_get_contents($f); }
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) { if ($branch === null || $token === null || $apiBase === null) {
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $xml, $m)) { $this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]");
if ($version === null || version_compare($m[1], $version, '>')) { $version = $m[1]; $manifestFile = basename($f); } return 1;
} }
} $root = realpath($path) ?: $path;
} $version = null;
} $manifestFile = null;
if ($version === null) { $this->log('ERROR', "No version found in manifest XML"); return 1; } foreach (["{$root}/src", $root] as $dir) {
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $version, $parts)) { $this->log('ERROR', "Invalid version format: {$version}"); return 1; } if (!is_dir($dir)) {
$major = (int)$parts[1]; $minor = (int)$parts[2]; $patch = (int)$parts[3]; continue;
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); foreach (glob("{$dir}/*.xml") ?: [] as $f) {
echo "{$version} -> {$nextVersion} ({$branch})\n"; $xml = file_get_contents($f);
if (strpos($xml, '<extension') !== false || strpos($xml, '<version>') !== false) {
if (preg_match('|<version>(\d{2}\.\d{2}\.\d{2})</version>|', $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 = []; $manifestPaths = [];
if ($manifestFile !== null) { $manifestPaths[] = "src/{$manifestFile}"; } if ($manifestFile !== null) {
$manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']); $manifestPaths[] = "src/{$manifestFile}";
$manifestUpdated = false; }
foreach ($manifestPaths as $mPath) { $manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']);
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string { return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content); }, "chore(version): bump {$version} -> {$nextVersion} [skip ci]"); $manifestUpdated = false;
if ($result) { $manifestUpdated = true; break; } foreach ($manifestPaths as $mPath) {
} $result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string {
if (!$manifestUpdated) { $this->log('WARN', "could not update manifest on {$branch}"); } return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content);
if (!$noChangelog) { }, "chore(version): bump {$version} -> {$nextVersion} [skip ci]");
$this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string { if ($result) {
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content); $manifestUpdated = true;
if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) { break;
$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); } }
} if (!$manifestUpdated) {
return $content; $this->log('WARN', "could not update manifest on {$branch}");
}, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]"); }
} if (!$noChangelog) {
return 0; $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 private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
{ {
$ch = curl_init($url); $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]); curl_setopt_array($ch, [
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } CURLOPT_RETURNTRANSFER => true,
$response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); CURLOPT_HTTPHEADER => [
if ($httpCode >= 400 || $response === false) { return null; } "Authorization: token {$token}",
return json_decode($response, true) ?: []; '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 private function updateRemoteFile(
{ string $apiBase,
$file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token); string $token,
if ($file === null || !isset($file['sha']) || !isset($file['content'])) { return false; } string $filePath,
$content = base64_decode($file['content']); $newContent = $transform($content); string $branch,
if ($newContent === $content) { $this->log('INFO', "{$filePath}: no changes needed"); return true; } callable $transform,
$payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]); string $commitMessage
$result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload); ): bool {
if ($result === null) { $this->log('ERROR', "{$filePath}: failed to update"); return false; } $file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token);
echo " {$filePath}: updated on {$branch}\n"; return true; 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(); $app = new VersionBumpRemoteCli();
+175 -50
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -21,57 +22,181 @@ use MokoEnterprise\CliFramework;
class VersionCheckCli extends CliFramework class VersionCheckCli extends CliFramework
{ {
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Validate version consistency across README, manifests, and sub-packages'); $this->setDescription('Validate version consistency across README, manifests, and sub-packages');
$this->addArgument('--path', 'Repository root', '.'); $this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--strict', 'Exit 1 on mismatch', false); $this->addArgument('--strict', 'Exit 1 on mismatch', false);
$this->addArgument('--fix', 'Fix mismatches to highest version', false); $this->addArgument('--fix', 'Fix mismatches to highest version', false);
} }
protected function run(): int protected function run(): int
{ {
$path = $this->getArgument('--path'); $strict = (bool) $this->getArgument('--strict'); $fix = (bool) $this->getArgument('--fix'); $path = $this->getArgument('--path');
$root = realpath($path) ?: $path; $errors = 0; $versions = []; $strict = (bool) $this->getArgument('--strict');
$mokoManifest = "{$root}/.mokogitea/manifest.xml"; $fix = (bool) $this->getArgument('--fix');
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; } } } $root = realpath($path) ?: $path;
$readme = "{$root}/README.md"; $errors = 0;
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]; } } $versions = [];
$changelog = "{$root}/CHANGELOG.md"; $mokoManifest = "{$root}/.mokogitea/manifest.xml";
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]; } } if (file_exists($mokoManifest)) {
$packageJson = "{$root}/package.json"; $xml = @simplexml_load_file($mokoManifest);
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']; } } if ($xml !== false) {
$pyproject = "{$root}/pyproject.toml"; $v = (string)($xml->identity->version ?? '');
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]; } } $base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v);
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) { if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) {
foreach (glob($glob) ?: [] as $file) { $versions['.mokogitea/manifest.xml'] = $base;
if (basename($file) === 'updates.xml') continue; }
$xmlContent = file_get_contents($file); if (strpos($xmlContent, '<extension') === false) continue; }
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $xmlContent, $xm)) { $relPath = str_replace([$root . '/', $root . '\\'], '', $file); $versions[$relPath] = $xm[1]; } }
} $readme = "{$root}/README.md";
} if (file_exists($readme)) {
if (empty($versions)) { $this->log('ERROR', "No version sources found"); return 1; } $content = file_get_contents($readme);
$uniqueVersions = array_unique(array_values($versions)); $highestVersion = '00.00.00'; if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
foreach ($versions as $v) { if (version_compare($v, $highestVersion, '>')) { $highestVersion = $v; } } $versions['README.md'] = $m[1];
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"; } $changelog = "{$root}/CHANGELOG.md";
else { if (file_exists($changelog)) {
echo "\n** {$errors} mismatch(es) found. Highest version: {$highestVersion}\n"; $content = file_get_contents($changelog);
if ($fix) { if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
echo "\n=== Fixing mismatches to {$highestVersion} ===\n"; $versions['CHANGELOG.md'] = $m[1];
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('#<version>\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', "<version>{$highestVersion}</version>", $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"; } $packageJson = "{$root}/package.json";
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 (file_exists($packageJson)) {
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"; } $pkg = json_decode(file_get_contents($packageJson), true);
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('#<version>[^<]*</version>#', "<version>{$highestVersion}</version>", $content); if ($updated !== null) { file_put_contents($file, $updated); } echo " Fixed: {$source} -> {$highestVersion}\n"; } if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) {
echo "Done.\n"; $versions['package.json'] = $pkg['version'];
} }
} }
if ($strict && $errors > 0) { return 1; } $pyproject = "{$root}/pyproject.toml";
return 0; 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, '<extension') === false) {
continue;
}
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#', $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 = '#<version>\d{2}\.\d{2}\.\d{2}'
. '(?:(?:-(?:dev|alpha|beta|rc))+)?</version>#';
$updated = preg_replace(
$vPat,
"<version>{$highestVersion}</version>",
$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('#<version>[^<]*</version>#', "<version>{$highestVersion}</version>", $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(); $app = new VersionCheckCli();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
+7 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
@@ -204,7 +205,9 @@ class WikiSyncCli extends CliFramework
]; ];
$ctx = stream_context_create($opts); $ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx); $result = @file_get_contents($url, false, $ctx);
if ($result === false) return null; if ($result === false) {
return null;
}
$data = json_decode($result, true); $data = json_decode($result, true);
return is_array($data) ? $data : null; return is_array($data) ? $data : null;
} }
@@ -232,7 +235,9 @@ class WikiSyncCli extends CliFramework
]; ];
$ctx = stream_context_create($opts); $ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx); $result = @file_get_contents($url, false, $ctx);
if ($result === false) return null; if ($result === false) {
return null;
}
$data = json_decode($result, true); $data = json_decode($result, true);
return is_array($data) ? $data : null; return is_array($data) ? $data : null;
} }
+137 -136
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -23,171 +24,171 @@ use MokoEnterprise\CliFramework;
class BackupBeforeDeployCli extends CliFramework class BackupBeforeDeployCli extends CliFramework
{ {
private const JOOMLA_DIRS = [ private const JOOMLA_DIRS = [
'administrator/components', 'administrator/components',
'administrator/language', 'administrator/language',
'administrator/modules', 'administrator/modules',
'administrator/templates', 'administrator/templates',
'components', 'components',
'language', 'language',
'layouts', 'layouts',
'libraries', 'libraries',
'media', 'media',
'modules', 'modules',
'plugins', 'plugins',
'templates', 'templates',
]; ];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Snapshot Joomla directories before deployment for rollback capability'); $this->setDescription('Snapshot Joomla directories before deployment for rollback capability');
$this->addArgument('--config', 'Path to sftp-config.json', ''); $this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--output', 'Local output directory for snapshot', ''); $this->addArgument('--output', 'Local output directory for snapshot', '');
} }
protected function run(): int protected function run(): int
{ {
$configPath = $this->getArgument('--config'); $configPath = $this->getArgument('--config');
$outputDir = $this->getArgument('--output'); $outputDir = $this->getArgument('--output');
if ($configPath === '') { if ($configPath === '') {
$this->log('ERROR', 'Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]'); $this->log('ERROR', 'Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
return 1; return 1;
} }
if ($outputDir === '') { if ($outputDir === '') {
$outputDir = '/tmp/moko-snapshot-' . date('Ymd-His'); $outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
} }
$config = $this->loadConfig($configPath); $config = $this->loadConfig($configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
$host = $config['host'] ?? ''; $host = $config['host'] ?? '';
$user = $config['user'] ?? ''; $user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22); $port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/'); $remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.'); $this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1; return 1;
} }
// Create output directory // Create output directory
if (!is_dir($outputDir)) { if (!is_dir($outputDir)) {
if (!mkdir($outputDir, 0755, true)) { if (!mkdir($outputDir, 0755, true)) {
$this->log('ERROR', "Could not create output directory: {$outputDir}"); $this->log('ERROR', "Could not create output directory: {$outputDir}");
return 1; return 1;
} }
} }
$this->log('INFO', 'Starting pre-deploy snapshot...'); $this->log('INFO', 'Starting pre-deploy snapshot...');
$this->log('INFO', "Source: {$user}@{$host}:{$remotePath}"); $this->log('INFO', "Source: {$user}@{$host}:{$remotePath}");
$this->log('INFO', "Output: {$outputDir}"); $this->log('INFO', "Output: {$outputDir}");
$failed = 0; $failed = 0;
foreach (self::JOOMLA_DIRS as $dir) { foreach (self::JOOMLA_DIRS as $dir) {
$remoteSource = "{$remotePath}/{$dir}/"; $remoteSource = "{$remotePath}/{$dir}/";
$localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/'; $localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/';
// Ensure local subdirectory exists // Ensure local subdirectory exists
if (!is_dir($localTarget)) { if (!is_dir($localTarget)) {
mkdir($localTarget, 0755, true); mkdir($localTarget, 0755, true);
} }
$sshCmd = "ssh -p {$port}"; $sshCmd = "ssh -p {$port}";
if ($sshKey !== '') { if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$cmd = $this->buildRsyncCommand( $cmd = $this->buildRsyncCommand(
$sshCmd, $sshCmd,
"{$user}@{$host}:{$remoteSource}", "{$user}@{$host}:{$remoteSource}",
$localTarget $localTarget
); );
$this->log('INFO', "Downloading: {$dir}"); $this->log('INFO', "Downloading: {$dir}");
if ($this->verbose) { if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}"); $this->log('INFO', "CMD: {$cmd}");
} }
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})"); $this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log('ERROR', " {$line}"); $this->log('ERROR', " {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log('INFO', " {$line}"); $this->log('INFO', " {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log('ERROR', "Snapshot completed with {$failed} error(s)."); $this->log('ERROR', "Snapshot completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log('INFO', ''); $this->log('INFO', '');
$this->log('INFO', 'Snapshot completed successfully.'); $this->log('INFO', 'Snapshot completed successfully.');
$this->log('INFO', "SNAPSHOT_PATH={$outputDir}"); $this->log('INFO', "SNAPSHOT_PATH={$outputDir}");
$this->log('INFO', ''); $this->log('INFO', '');
$this->log('INFO', 'To rollback, run:'); $this->log('INFO', 'To rollback, run:');
$this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}"); $this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}");
return 0; return 0;
} }
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}"); $this->log('ERROR', "Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}"); $this->log('ERROR', "Could not read config file: {$path}");
return null; return null;
} }
// Strip // comments (sftp-config.json style) // Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw); $cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.'); $this->log('ERROR', 'Invalid JSON in config file.');
return null; return null;
} }
return $config; return $config;
} }
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
{ {
$parts = ['rsync', '-rlptz', '--exclude=configuration.php']; $parts = ['rsync', '-rlptz', '--exclude=configuration.php'];
if ($this->verbose) { if ($this->verbose) {
$parts[] = '-v'; $parts[] = '-v';
} }
$parts[] = '-e'; $parts[] = '-e';
$parts[] = escapeshellarg($sshCmd); $parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source); $parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest); $parts[] = escapeshellarg($dest);
return implode(' ', $parts); return implode(' ', $parts);
} }
} }
$app = new BackupBeforeDeployCli(); $app = new BackupBeforeDeployCli();
+204 -203
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -23,258 +24,258 @@ use MokoEnterprise\CliFramework;
class DeployDolibarrCli extends CliFramework class DeployDolibarrCli extends CliFramework
{ {
private string $source = ''; private string $source = '';
private const MODULE_DIRS = [ private const MODULE_DIRS = [
'core/modules', 'core/modules',
'class', 'class',
'lib', 'lib',
'sql', 'sql',
'langs', 'langs',
'css', 'css',
'js', 'js',
'img', 'img',
]; ];
private const EXCLUDES = [ private const EXCLUDES = [
'.git/', '.git/',
'vendor/', 'vendor/',
'tests/', 'tests/',
'node_modules/', 'node_modules/',
]; ];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync'); $this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync');
$this->addArgument('--source', 'Local source directory', ''); $this->addArgument('--source', 'Local source directory', '');
$this->addArgument('--config', 'Path to sftp-config.json', ''); $this->addArgument('--config', 'Path to sftp-config.json', '');
} }
protected function run(): int protected function run(): int
{ {
$configPath = $this->getArgument('--config'); $configPath = $this->getArgument('--config');
$this->source = $this->getArgument('--source'); $this->source = $this->getArgument('--source');
if ($configPath === '' || $this->source === '') { if ($configPath === '' || $this->source === '') {
$this->log('ERROR', 'Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]'); $this->log('ERROR', 'Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
return 1; return 1;
} }
if (!is_dir($this->source)) { if (!is_dir($this->source)) {
$this->log('ERROR', "Source directory does not exist: {$this->source}"); $this->log('ERROR', "Source directory does not exist: {$this->source}");
return 1; return 1;
} }
$moduleName = $this->detectModuleName(); $moduleName = $this->detectModuleName();
if ($moduleName === null) { if ($moduleName === null) {
$this->log('ERROR', 'Could not auto-detect module name. Expected core/modules/mod*.class.php'); $this->log('ERROR', 'Could not auto-detect module name. Expected core/modules/mod*.class.php');
return 1; return 1;
} }
$config = $this->loadConfig($configPath); $config = $this->loadConfig($configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
$host = $config['host'] ?? ''; $host = $config['host'] ?? '';
$user = $config['user'] ?? ''; $user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22); $port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/'); $remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.'); $this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1; return 1;
} }
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}"; $remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
$this->log('INFO', "Deploying Dolibarr module: {$moduleName}"); $this->log('INFO', "Deploying Dolibarr module: {$moduleName}");
$this->log('INFO', "Source: {$this->source}"); $this->log('INFO', "Source: {$this->source}");
$this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}"); $this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('INFO', '*** DRY RUN — no changes will be made ***'); $this->log('INFO', '*** DRY RUN — no changes will be made ***');
} }
$failed = 0; $failed = 0;
// Deploy subdirectories // Deploy subdirectories
foreach (self::MODULE_DIRS as $dir) { foreach (self::MODULE_DIRS as $dir) {
$localDir = rtrim($this->source, '/\\') . '/' . $dir . '/'; $localDir = rtrim($this->source, '/\\') . '/' . $dir . '/';
if (!is_dir($localDir)) { if (!is_dir($localDir)) {
if ($this->verbose) { if ($this->verbose) {
$this->log('INFO', "SKIP: {$dir} (not present in source)"); $this->log('INFO', "SKIP: {$dir} (not present in source)");
} }
continue; continue;
} }
$remoteTarget = "{$remoteBase}/{$dir}/"; $remoteTarget = "{$remoteBase}/{$dir}/";
$result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey); $result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey);
if (!$result) { if (!$result) {
$failed++; $failed++;
} }
} }
// Deploy root PHP files // Deploy root PHP files
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php'); $rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
if (!empty($rootPhpFiles)) { if (!empty($rootPhpFiles)) {
$this->log('INFO', 'Syncing root PHP files...'); $this->log('INFO', 'Syncing root PHP files...');
$sourceRoot = rtrim($this->source, '/\\') . '/'; $sourceRoot = rtrim($this->source, '/\\') . '/';
$remoteTarget = "{$remoteBase}/"; $remoteTarget = "{$remoteBase}/";
$sshCmd = "ssh -p {$port}"; $sshCmd = "ssh -p {$port}";
if ($sshKey !== '') { if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$cmd = $this->buildRsyncCommand( $cmd = $this->buildRsyncCommand(
$sshCmd, $sshCmd,
$sourceRoot, $sourceRoot,
"{$user}@{$host}:{$remoteTarget}", "{$user}@{$host}:{$remoteTarget}",
['--include=*.php', '--exclude=*/', '--exclude=.*'] ['--include=*.php', '--exclude=*/', '--exclude=.*']
); );
if ($this->verbose) { if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}"); $this->log('INFO', "CMD: {$cmd}");
} }
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})"); $this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log('ERROR', " {$line}"); $this->log('ERROR', " {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log('INFO', " {$line}"); $this->log('INFO', " {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log('ERROR', "Deployment completed with {$failed} error(s)."); $this->log('ERROR', "Deployment completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log('INFO', 'Deployment completed successfully.'); $this->log('INFO', 'Deployment completed successfully.');
return 0; return 0;
} }
private function detectModuleName(): ?string private function detectModuleName(): ?string
{ {
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php'; $pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
$matches = glob($pattern); $matches = glob($pattern);
if (empty($matches)) { if (empty($matches)) {
return null; return null;
} }
$filename = basename($matches[0]); $filename = basename($matches[0]);
// mod{ModuleName}.class.php -> extract ModuleName, lowercase it // mod{ModuleName}.class.php -> extract ModuleName, lowercase it
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) { if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
return strtolower($m[1]); return strtolower($m[1]);
} }
return null; return null;
} }
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}"); $this->log('ERROR', "Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}"); $this->log('ERROR', "Could not read config file: {$path}");
return null; return null;
} }
// Strip // comments (sftp-config.json style) // Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw); $cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.'); $this->log('ERROR', 'Invalid JSON in config file.');
return null; return null;
} }
return $config; return $config;
} }
private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool
{ {
$dirName = basename(rtrim($localDir, '/')); $dirName = basename(rtrim($localDir, '/'));
$sshCmd = "ssh -p {$port}"; $sshCmd = "ssh -p {$port}";
if ($sshKey !== '') { if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$this->log('INFO', "Syncing: {$dirName}"); $this->log('INFO', "Syncing: {$dirName}");
if ($this->verbose) { if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}"); $this->log('INFO', "CMD: {$cmd}");
} }
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})"); $this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log('ERROR', " {$line}"); $this->log('ERROR', " {$line}");
} }
return false; return false;
} }
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log('INFO', " {$line}"); $this->log('INFO', " {$line}");
} }
} }
return true; return true;
} }
private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string
{ {
$parts = ['rsync', '-rlptz', '--delete']; $parts = ['rsync', '-rlptz', '--delete'];
foreach (self::EXCLUDES as $exclude) { foreach (self::EXCLUDES as $exclude) {
$parts[] = '--exclude=' . $exclude; $parts[] = '--exclude=' . $exclude;
} }
foreach ($extraArgs as $arg) { foreach ($extraArgs as $arg) {
$parts[] = $arg; $parts[] = $arg;
} }
if ($this->dryRun) { if ($this->dryRun) {
$parts[] = '--dry-run'; $parts[] = '--dry-run';
} }
if ($this->verbose) { if ($this->verbose) {
$parts[] = '-v'; $parts[] = '-v';
} }
$parts[] = '-e'; $parts[] = '-e';
$parts[] = escapeshellarg($sshCmd); $parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source); $parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest); $parts[] = escapeshellarg($dest);
return implode(' ', $parts); return implode(' ', $parts);
} }
} }
$app = new DeployDolibarrCli(); $app = new DeployDolibarrCli();
+440 -439
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -68,499 +69,499 @@ use phpseclib3\Crypt\PublicKeyLoader;
*/ */
class DeployJoomlaCli extends CliFramework class DeployJoomlaCli extends CliFramework
{ {
private string $repoPath = '.'; private string $repoPath = '.';
private string $srcDir = 'src'; private string $srcDir = 'src';
private array $config = []; private array $config = [];
private int $uploaded = 0; private int $uploaded = 0;
private int $unchanged = 0; private int $unchanged = 0;
private int $skipped = 0; private int $skipped = 0;
private int $deleted = 0; private int $deleted = 0;
private array $ignorePatterns = []; private array $ignorePatterns = [];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest'); $this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest');
$this->addArgument('--path', 'Repository root path', '.'); $this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--src-dir', 'Source directory relative to path', 'src'); $this->addArgument('--src-dir', 'Source directory relative to path', 'src');
$this->addArgument('--config', 'Path to sftp-config.json', ''); $this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--key-passphrase', 'SSH key passphrase', ''); $this->addArgument('--key-passphrase', 'SSH key passphrase', '');
} }
protected function run(): int protected function run(): int
{ {
$this->repoPath = $this->getArgument('--path'); $this->repoPath = $this->getArgument('--path');
$this->srcDir = $this->getArgument('--src-dir'); $this->srcDir = $this->getArgument('--src-dir');
$configPath = $this->getArgument('--config'); $configPath = $this->getArgument('--config');
$keyPassphrase = $this->getArgument('--key-passphrase'); $keyPassphrase = $this->getArgument('--key-passphrase');
if ($keyPassphrase !== '') { if ($keyPassphrase !== '') {
$this->config['key_passphrase'] = $keyPassphrase; $this->config['key_passphrase'] = $keyPassphrase;
} }
$this->repoPath = realpath($this->repoPath) ?: $this->repoPath; $this->repoPath = realpath($this->repoPath) ?: $this->repoPath;
// Resolve src dir // Resolve src dir
if (!str_starts_with($this->srcDir, '/')) { if (!str_starts_with($this->srcDir, '/')) {
$this->srcDir = "{$this->repoPath}/{$this->srcDir}"; $this->srcDir = "{$this->repoPath}/{$this->srcDir}";
} }
// Try htdocs/ as fallback // Try htdocs/ as fallback
if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) { if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) {
$this->srcDir = "{$this->repoPath}/htdocs"; $this->srcDir = "{$this->repoPath}/htdocs";
} }
// Load config // Load config
if ($configPath !== '' && file_exists($configPath)) { if ($configPath !== '' && file_exists($configPath)) {
$json = file_get_contents($configPath); $json = file_get_contents($configPath);
$json = preg_replace('#^\s*//.*$#m', '', $json); $json = preg_replace('#^\s*//.*$#m', '', $json);
$json = preg_replace('#,\s*([\]}])#', '$1', $json); $json = preg_replace('#,\s*([\]}])#', '$1', $json);
$parsed = json_decode($json, true); $parsed = json_decode($json, true);
if (is_array($parsed)) { if (is_array($parsed)) {
$this->config = array_merge($this->config, $parsed); $this->config = array_merge($this->config, $parsed);
} }
} }
$manifest = $this->findManifest(); $manifest = $this->findManifest();
if ($manifest === null) { if ($manifest === null) {
$this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}"); $this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}");
return 1; return 1;
} }
$ext = $this->parseManifest($manifest); $ext = $this->parseManifest($manifest);
if ($ext === null) { if ($ext === null) {
$this->log('ERROR', "Failed to parse manifest: {$manifest}"); $this->log('ERROR', "Failed to parse manifest: {$manifest}");
return 1; 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); $deployMap = $this->buildDeployMap($ext);
if (empty($deployMap)) { if (empty($deployMap)) {
$this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}"); $this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}");
return 1; return 1;
} }
$this->log('INFO', "Deploy mappings:"); $this->log('INFO', "Deploy mappings:");
foreach ($deployMap as $map) { foreach ($deployMap as $map) {
$this->log('INFO', " {$map['local']} -> {$map['remote']}"); $this->log('INFO', " {$map['local']} -> {$map['remote']}");
} }
// Load ignore patterns // Load ignore patterns
$this->ignorePatterns = $this->loadFtpIgnore(); $this->ignorePatterns = $this->loadFtpIgnore();
// Check if manifest changed (warn user about reinstall) // Check if manifest changed (warn user about reinstall)
$this->checkManifestChange($ext, $manifest); $this->checkManifestChange($ext, $manifest);
if ($this->dryRun) { if ($this->dryRun) {
$this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings"); $this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings");
foreach ($deployMap as $map) { foreach ($deployMap as $map) {
if (is_dir($map['local'])) { if (is_dir($map['local'])) {
$count = iterator_count( $count = iterator_count(
new \RecursiveIteratorIterator( new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS) new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS)
) )
); );
$this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}"); $this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}");
} }
} }
return 0; return 0;
} }
// Connect // Connect
$sftp = $this->connectSftp(); $sftp = $this->connectSftp();
if ($sftp === null) { if ($sftp === null) {
return 1; return 1;
} }
// Deploy each mapping // Deploy each mapping
$errors = 0; $errors = 0;
foreach ($deployMap as $map) { foreach ($deployMap as $map) {
if (!is_dir($map['local'])) { if (!is_dir($map['local'])) {
if ($this->verbose) { if ($this->verbose) {
$this->log('INFO', " SKIP: {$map['local']} (directory not found)"); $this->log('INFO', " SKIP: {$map['local']} (directory not found)");
} }
continue; continue;
} }
// Ensure remote directory exists // Ensure remote directory exists
$stat = @$sftp->stat($map['remote']); $stat = @$sftp->stat($map['remote']);
if ($stat === false) { if ($stat === false) {
$this->log('INFO', " MKDIR: {$map['remote']}"); $this->log('INFO', " MKDIR: {$map['remote']}");
$sftp->mkdir($map['remote'], -1, true); $sftp->mkdir($map['remote'], -1, true);
} }
$result = $this->uploadDirectory($sftp, $map['local'], $map['remote']); $result = $this->uploadDirectory($sftp, $map['local'], $map['remote']);
if ($result !== 0) { if ($result !== 0) {
$errors++; $errors++;
} }
} }
// Also deploy the manifest file itself to the admin component directory // Also deploy the manifest file itself to the admin component directory
if ($ext['type'] === 'component' && file_exists($manifest)) { if ($ext['type'] === 'component' && file_exists($manifest)) {
$adminRemote = $this->getRemotePath($ext, 'admin'); $adminRemote = $this->getRemotePath($ext, 'admin');
$manifestName = basename($manifest); $manifestName = basename($manifest);
$remoteDest = "{$adminRemote}/{$manifestName}"; $remoteDest = "{$adminRemote}/{$manifestName}";
$this->uploadFile($sftp, $manifest, $remoteDest); $this->uploadFile($sftp, $manifest, $remoteDest);
$this->log('INFO', " Manifest: {$manifestName} -> {$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. * Find the Joomla XML manifest file.
*/ */
private function findManifest(): ?string private function findManifest(): ?string
{ {
$searchDirs = [$this->srcDir, $this->repoPath]; $searchDirs = [$this->srcDir, $this->repoPath];
foreach ($searchDirs as $dir) { foreach ($searchDirs as $dir) {
$iterator = new \DirectoryIterator($dir); $iterator = new \DirectoryIterator($dir);
foreach ($iterator as $file) { foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'xml') { if ($file->isFile() && $file->getExtension() === 'xml') {
$content = file_get_contents($file->getPathname()); $content = file_get_contents($file->getPathname());
if ($content !== false && str_contains($content, '<extension')) { if ($content !== false && str_contains($content, '<extension')) {
return $file->getPathname(); return $file->getPathname();
} }
} }
} }
// Also check one level deep // Also check one level deep
foreach (new \DirectoryIterator($dir) as $subdir) { foreach (new \DirectoryIterator($dir) as $subdir) {
if ($subdir->isDir() && !$subdir->isDot()) { if ($subdir->isDir() && !$subdir->isDot()) {
foreach (new \DirectoryIterator($subdir->getPathname()) as $file) { foreach (new \DirectoryIterator($subdir->getPathname()) as $file) {
if ($file->isFile() && $file->getExtension() === 'xml') { if ($file->isFile() && $file->getExtension() === 'xml') {
$content = file_get_contents($file->getPathname()); $content = file_get_contents($file->getPathname());
if ($content !== false && str_contains($content, '<extension')) { if ($content !== false && str_contains($content, '<extension')) {
return $file->getPathname(); return $file->getPathname();
} }
} }
} }
} }
} }
} }
return null; return null;
} }
/** /**
* Parse extension metadata from the XML manifest. * Parse extension metadata from the XML manifest.
* *
* @return array{type: string, element: string, client: string, group: string, name: string}|null * @return array{type: string, element: string, client: string, group: string, name: string}|null
*/ */
private function parseManifest(string $path): ?array private function parseManifest(string $path): ?array
{ {
$xml = @simplexml_load_file($path); $xml = @simplexml_load_file($path);
if ($xml === false || $xml->getName() !== 'extension') { if ($xml === false || $xml->getName() !== 'extension') {
return null; return null;
} }
$type = (string) ($xml['type'] ?? 'component'); $type = (string) ($xml['type'] ?? 'component');
$client = (string) ($xml['client'] ?? 'site'); $client = (string) ($xml['client'] ?? 'site');
$group = (string) ($xml['group'] ?? ''); $group = (string) ($xml['group'] ?? '');
$element = (string) ($xml->element ?? ''); $element = (string) ($xml->element ?? '');
$name = (string) ($xml->name ?? ''); $name = (string) ($xml->name ?? '');
// Derive element from type + name if not explicit // Derive element from type + name if not explicit
if (empty($element)) { if (empty($element)) {
$cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name)); $cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name));
$element = match ($type) { $element = match ($type) {
'component' => "com_{$cleanName}", 'component' => "com_{$cleanName}",
'module' => "mod_{$cleanName}", 'module' => "mod_{$cleanName}",
'plugin' => "plg_{$group}_{$cleanName}", 'plugin' => "plg_{$group}_{$cleanName}",
'template' => "tpl_{$cleanName}", 'template' => "tpl_{$cleanName}",
'library' => "lib_{$cleanName}", 'library' => "lib_{$cleanName}",
default => $cleanName, default => $cleanName,
}; };
} }
// For plugins, derive the short name (without plg_group_ prefix) // For plugins, derive the short name (without plg_group_ prefix)
$shortName = $element; $shortName = $element;
if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) { if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) {
$shortName = $m[1]; $shortName = $m[1];
} elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) { } elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) {
$shortName = $m[1]; $shortName = $m[1];
} }
return [ return [
'type' => $type, 'type' => $type,
'element' => $element, 'element' => $element,
'client' => $client, 'client' => $client,
'group' => $group, 'group' => $group,
'name' => $name, 'name' => $name,
'shortName' => $shortName, 'shortName' => $shortName,
]; ];
} }
/** /**
* Build the local->remote deploy mapping based on extension type. * Build the local->remote deploy mapping based on extension type.
* *
* @return array<int, array{local: string, remote: string}> * @return array<int, array{local: string, remote: string}>
*/ */
private function buildDeployMap(array $ext): array private function buildDeployMap(array $ext): array
{ {
$remotePath = rtrim((string) $this->config['remote_path'], '/'); $remotePath = rtrim((string) $this->config['remote_path'], '/');
$src = $this->srcDir; $src = $this->srcDir;
$map = []; $map = [];
switch ($ext['type']) { switch ($ext['type']) {
case 'component': case 'component':
// Admin files // Admin files
if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) { if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) {
$adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator"; $adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator";
$map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"]; $map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"];
} }
// Site files // Site files
if (is_dir("{$src}/site")) { if (is_dir("{$src}/site")) {
$map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"]; $map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"];
} }
// Media files // Media files
if (is_dir("{$src}/media")) { if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
} }
// API files (Joomla 4+) // API files (Joomla 4+)
if (is_dir("{$src}/api")) { if (is_dir("{$src}/api")) {
$map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"]; $map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"];
} }
// Language files (admin) // Language files (admin)
if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) { if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) {
$langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language"; $langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language";
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"]; $map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"];
} }
// Language files (site) // Language files (site)
if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) { if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) {
$langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language"; $langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language";
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"]; $map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"];
} }
break; break;
case 'module': case 'module':
$base = $ext['client'] === 'administrator' $base = $ext['client'] === 'administrator'
? "{$remotePath}/administrator/modules/{$ext['element']}" ? "{$remotePath}/administrator/modules/{$ext['element']}"
: "{$remotePath}/modules/{$ext['element']}"; : "{$remotePath}/modules/{$ext['element']}";
$map[] = ['local' => $src, 'remote' => $base]; $map[] = ['local' => $src, 'remote' => $base];
if (is_dir("{$src}/media")) { if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
} }
break; break;
case 'plugin': case 'plugin':
$map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"]; $map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"];
if (is_dir("{$src}/media")) { if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
} }
break; break;
case 'template': case 'template':
$clientDir = $ext['client'] === 'administrator' ? 'administrator/' : ''; $clientDir = $ext['client'] === 'administrator' ? 'administrator/' : '';
$map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"]; $map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"];
if (is_dir("{$src}/media")) { if (is_dir("{$src}/media")) {
$mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site'; $mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site';
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"]; $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"];
} }
break; break;
case 'library': case 'library':
$map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"]; $map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"];
if (is_dir("{$src}/media")) { if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"]; $map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
} }
break; break;
case 'package': case 'package':
// Packages deploy their sub-extensions individually // Packages deploy their sub-extensions individually
// For now, deploy to administrator/manifests/packages/ // For now, deploy to administrator/manifests/packages/
$map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"]; $map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"];
break; break;
} }
return $map; return $map;
} }
/** /**
* Get the remote path for a specific section of the extension. * Get the remote path for a specific section of the extension.
*/ */
private function getRemotePath(array $ext, string $section): string private function getRemotePath(array $ext, string $section): string
{ {
$remotePath = rtrim((string) $this->config['remote_path'], '/'); $remotePath = rtrim((string) $this->config['remote_path'], '/');
return match ($section) { return match ($section) {
'admin' => "{$remotePath}/administrator/components/{$ext['element']}", 'admin' => "{$remotePath}/administrator/components/{$ext['element']}",
'site' => "{$remotePath}/components/{$ext['element']}", 'site' => "{$remotePath}/components/{$ext['element']}",
'media' => "{$remotePath}/media/{$ext['element']}", 'media' => "{$remotePath}/media/{$ext['element']}",
default => $remotePath, default => $remotePath,
}; };
} }
/** /**
* Check if the XML manifest has changed and warn about reinstall. * Check if the XML manifest has changed and warn about reinstall.
*/ */
private function checkManifestChange(array $ext, string $manifestPath): void private function checkManifestChange(array $ext, string $manifestPath): void
{ {
$manifestName = basename($manifestPath); $manifestName = basename($manifestPath);
$this->log('INFO', ''); $this->log('INFO', '');
$this->log('INFO', "NOTE: If {$manifestName} has changed (new fields, permissions, menu items,"); $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', ' 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', ' Code changes (PHP, JS, CSS, language) do NOT require reinstall.');
$this->log('INFO', ''); $this->log('INFO', '');
} }
/** /**
* Upload a directory recursively to the remote server. * Upload a directory recursively to the remote server.
*/ */
private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int
{ {
$errors = 0; $errors = 0;
$iterator = new \RecursiveIteratorIterator( $iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS), new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST \RecursiveIteratorIterator::SELF_FIRST
); );
foreach ($iterator as $item) { foreach ($iterator as $item) {
$relativePath = substr($item->getPathname(), strlen($localDir) + 1); $relativePath = substr($item->getPathname(), strlen($localDir) + 1);
$relativePath = str_replace('\\', '/', $relativePath); $relativePath = str_replace('\\', '/', $relativePath);
$remotePath = "{$remoteDir}/{$relativePath}"; $remotePath = "{$remoteDir}/{$relativePath}";
// Check ignore patterns // Check ignore patterns
if ($this->shouldIgnore($relativePath)) { if ($this->shouldIgnore($relativePath)) {
$this->skipped++; $this->skipped++;
continue; continue;
} }
if ($item->isDir()) { if ($item->isDir()) {
$stat = @$sftp->stat($remotePath); $stat = @$sftp->stat($remotePath);
if ($stat === false) { if ($stat === false) {
$sftp->mkdir($remotePath, -1, true); $sftp->mkdir($remotePath, -1, true);
} }
} else { } else {
$result = $this->uploadFile($sftp, $item->getPathname(), $remotePath); $result = $this->uploadFile($sftp, $item->getPathname(), $remotePath);
if (!$result) { if (!$result) {
$errors++; $errors++;
} }
} }
} }
return $errors; return $errors;
} }
/** /**
* Upload a single file with smart diff (skip if unchanged). * Upload a single file with smart diff (skip if unchanged).
*/ */
private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool
{ {
$localSize = filesize($localPath); $localSize = filesize($localPath);
$remoteStat = @$sftp->stat($remotePath); $remoteStat = @$sftp->stat($remotePath);
// Smart diff: skip if same size and hash // Smart diff: skip if same size and hash
if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) { if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) {
$remoteContent = @$sftp->get($remotePath); $remoteContent = @$sftp->get($remotePath);
if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) { if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) {
$this->unchanged++; $this->unchanged++;
return true; return true;
} }
} }
// Ensure parent directory exists // Ensure parent directory exists
$parentDir = dirname($remotePath); $parentDir = dirname($remotePath);
$parentStat = @$sftp->stat($parentDir); $parentStat = @$sftp->stat($parentDir);
if ($parentStat === false) { if ($parentStat === false) {
$sftp->mkdir($parentDir, -1, true); $sftp->mkdir($parentDir, -1, true);
} }
$result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE); $result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE);
if ($result) { if ($result) {
$this->uploaded++; $this->uploaded++;
if ($this->verbose) { if ($this->verbose) {
$this->log('INFO', " UPLOAD: {$remotePath}"); $this->log('INFO', " UPLOAD: {$remotePath}");
} }
} else { } else {
$this->log('ERROR', " FAIL: {$remotePath}"); $this->log('ERROR', " FAIL: {$remotePath}");
} }
return $result; return $result;
} }
/** /**
* Check if a relative path should be ignored. * Check if a relative path should be ignored.
*/ */
private function shouldIgnore(string $relativePath): bool private function shouldIgnore(string $relativePath): bool
{ {
foreach ($this->ignorePatterns as $pattern) { foreach ($this->ignorePatterns as $pattern) {
if (preg_match($pattern, $relativePath)) { if (preg_match($pattern, $relativePath)) {
return true; return true;
} }
} }
// Always skip dotfiles and common non-deploy files // Always skip dotfiles and common non-deploy files
$basename = basename($relativePath); $basename = basename($relativePath);
if (str_starts_with($basename, '.') && $basename !== '.htaccess') { if (str_starts_with($basename, '.') && $basename !== '.htaccess') {
return true; return true;
} }
return false; return false;
} }
/** /**
* Load .ftpignore patterns. * Load .ftpignore patterns.
* *
* @return string[] Regex patterns * @return string[] Regex patterns
*/ */
private function loadFtpIgnore(): array private function loadFtpIgnore(): array
{ {
$patterns = []; $patterns = [];
foreach ([$this->srcDir, $this->repoPath] as $dir) { foreach ([$this->srcDir, $this->repoPath] as $dir) {
$file = "{$dir}/.ftpignore"; $file = "{$dir}/.ftpignore";
if (!file_exists($file)) { if (!file_exists($file)) {
continue; continue;
} }
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line); $line = trim($line);
if ($line === '' || str_starts_with($line, '#')) { if ($line === '' || str_starts_with($line, '#')) {
continue; continue;
} }
// Convert glob to regex // Convert glob to regex
$regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line); $regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line);
$patterns[] = "#^{$regex}(/|$)#i"; $patterns[] = "#^{$regex}(/|$)#i";
} }
} }
return $patterns; return $patterns;
} }
/** /**
* Connect to the SFTP server. * Connect to the SFTP server.
*/ */
private function connectSftp(): ?SFTP private function connectSftp(): ?SFTP
{ {
$host = (string) $this->config['host']; $host = (string) $this->config['host'];
$port = (int) ($this->config['port'] ?? 22); $port = (int) ($this->config['port'] ?? 22);
$user = (string) $this->config['user']; $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 // Try key auth first
if (!empty($this->config['ssh_key_file'])) { if (!empty($this->config['ssh_key_file'])) {
$keyPath = $this->config['ssh_key_file']; $keyPath = $this->config['ssh_key_file'];
if (!file_exists($keyPath)) { if (!file_exists($keyPath)) {
$keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}"; $keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}";
} }
if (file_exists($keyPath)) { if (file_exists($keyPath)) {
$passphrase = $this->config['key_passphrase'] ?? ''; $passphrase = $this->config['key_passphrase'] ?? '';
$key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase); $key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase);
if ($sftp->login($user, $key)) { if ($sftp->login($user, $key)) {
$this->log('INFO', 'Connected via SSH key'); $this->log('INFO', 'Connected via SSH key');
return $sftp; return $sftp;
} }
$this->warning('Key auth failed'); $this->warning('Key auth failed');
} }
} }
// Fallback to password // Fallback to password
if (!empty($this->config['password'])) { if (!empty($this->config['password'])) {
if ($sftp->login($user, $this->config['password'])) { if ($sftp->login($user, $this->config['password'])) {
$this->log('INFO', 'Connected via password'); $this->log('INFO', 'Connected via password');
return $sftp; return $sftp;
} }
} }
$this->log('ERROR', 'Authentication failed'); $this->log('ERROR', 'Authentication failed');
return null; return null;
} }
} }
$app = new DeployJoomlaCli(); $app = new DeployJoomlaCli();
+668 -667
View File
File diff suppressed because it is too large Load Diff
+151 -150
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -23,192 +24,192 @@ use MokoEnterprise\CliFramework;
class HealthCheckCli extends CliFramework class HealthCheckCli extends CliFramework
{ {
private string $url = ''; private string $url = '';
private int $timeout = 30; private int $timeout = 30;
private array $checks = ['http']; private array $checks = ['http'];
private int $passed = 0; private int $passed = 0;
private int $failed = 0; private int $failed = 0;
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly'); $this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly');
$this->addArgument('--url', 'Site URL to check', ''); $this->addArgument('--url', 'Site URL to check', '');
$this->addArgument('--timeout', 'Request timeout in seconds', '30'); $this->addArgument('--timeout', 'Request timeout in seconds', '30');
$this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http'); $this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http');
} }
protected function run(): int protected function run(): int
{ {
$this->url = $this->getArgument('--url'); $this->url = $this->getArgument('--url');
$this->timeout = (int) $this->getArgument('--timeout'); $this->timeout = (int) $this->getArgument('--timeout');
$checksRaw = $this->getArgument('--checks'); $checksRaw = $this->getArgument('--checks');
$this->checks = array_map('trim', explode(',', $checksRaw)); $this->checks = array_map('trim', explode(',', $checksRaw));
if ($this->url === '') { if ($this->url === '') {
$this->log('ERROR', 'Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]'); $this->log('ERROR', 'Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]');
return 1; return 1;
} }
$this->url = rtrim($this->url, '/'); $this->url = rtrim($this->url, '/');
$this->log('INFO', "Health check for: {$this->url}"); $this->log('INFO', "Health check for: {$this->url}");
$this->log('INFO', "Timeout: {$this->timeout}s"); $this->log('INFO', "Timeout: {$this->timeout}s");
$this->log('INFO', "Checks: " . implode(', ', $this->checks)); $this->log('INFO', "Checks: " . implode(', ', $this->checks));
$this->log('INFO', ''); $this->log('INFO', '');
foreach ($this->checks as $check) { foreach ($this->checks as $check) {
switch ($check) { switch ($check) {
case 'http': case 'http':
$this->checkHttp(); $this->checkHttp();
break; break;
case 'admin': case 'admin':
$this->checkAdmin(); $this->checkAdmin();
break; break;
case 'api': case 'api':
$this->checkApi(); $this->checkApi();
break; break;
default: default:
$this->log('WARN', "UNKNOWN CHECK: {$check} — skipping"); $this->log('WARN', "UNKNOWN CHECK: {$check} — skipping");
break; break;
} }
} }
$this->log('INFO', ''); $this->log('INFO', '');
$this->log('INFO', "Results: {$this->passed} passed, {$this->failed} failed"); $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 private function checkHttp(): void
{ {
$this->log('INFO', '[http] GET ' . $this->url); $this->log('INFO', '[http] GET ' . $this->url);
$result = $this->curlGet($this->url); $result = $this->curlGet($this->url);
if ($result === null) { if ($result === null) {
$this->fail('http', 'Request failed — could not connect'); $this->fail('http', 'Request failed — could not connect');
return; return;
} }
if ($result['http_code'] !== 200) { if ($result['http_code'] !== 200) {
$this->fail('http', "Expected HTTP 200, got {$result['http_code']}"); $this->fail('http', "Expected HTTP 200, got {$result['http_code']}");
return; return;
} }
if ($this->containsFatalError($result['body'])) { if ($this->containsFatalError($result['body'])) {
$this->fail('http', 'Response body contains PHP fatal error'); $this->fail('http', 'Response body contains PHP fatal error');
return; return;
} }
$this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)"); $this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)");
} }
private function checkAdmin(): void private function checkAdmin(): void
{ {
$adminUrl = $this->url . '/administrator/'; $adminUrl = $this->url . '/administrator/';
$this->log('INFO', '[admin] GET ' . $adminUrl); $this->log('INFO', '[admin] GET ' . $adminUrl);
$result = $this->curlGet($adminUrl); $result = $this->curlGet($adminUrl);
if ($result === null) { if ($result === null) {
$this->fail('admin', 'Request failed — could not connect'); $this->fail('admin', 'Request failed — could not connect');
return; return;
} }
if ($result['http_code'] !== 200) { if ($result['http_code'] !== 200) {
$this->fail('admin', "Expected HTTP 200, got {$result['http_code']}"); $this->fail('admin', "Expected HTTP 200, got {$result['http_code']}");
return; return;
} }
$this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)"); $this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)");
} }
private function checkApi(): void private function checkApi(): void
{ {
$apiUrl = $this->url . '/api/index.php/v1'; $apiUrl = $this->url . '/api/index.php/v1';
$this->log('INFO', '[api] GET ' . $apiUrl); $this->log('INFO', '[api] GET ' . $apiUrl);
$result = $this->curlGet($apiUrl); $result = $this->curlGet($apiUrl);
if ($result === null) { if ($result === null) {
$this->fail('api', 'Request failed — could not connect'); $this->fail('api', 'Request failed — could not connect');
return; return;
} }
if ($result['http_code'] !== 200 && $result['http_code'] !== 401) { if ($result['http_code'] !== 200 && $result['http_code'] !== 401) {
$this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}"); $this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}");
return; return;
} }
$this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)"); $this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)");
} }
private function curlGet(string $url): ?array private function curlGet(string $url): ?array
{ {
$ch = curl_init(); $ch = curl_init();
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_URL => $url, CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5, CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $this->timeout, CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => $this->timeout, CURLOPT_CONNECTTIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'MokoHealthCheck/1.0', CURLOPT_USERAGENT => 'MokoHealthCheck/1.0',
]); ]);
$body = curl_exec($ch); $body = curl_exec($ch);
if (curl_errno($ch)) { if (curl_errno($ch)) {
$error = curl_error($ch); $error = curl_error($ch);
$this->log('ERROR', " cURL error: {$error}"); $this->log('ERROR', " cURL error: {$error}");
curl_close($ch); curl_close($ch);
return null; return null;
} }
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME); $totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch); curl_close($ch);
return [ return [
'http_code' => $httpCode, 'http_code' => $httpCode,
'body' => is_string($body) ? $body : '', 'body' => is_string($body) ? $body : '',
'time_ms' => (int) round($totalTime * 1000), 'time_ms' => (int) round($totalTime * 1000),
]; ];
} }
private function containsFatalError(string $body): bool private function containsFatalError(string $body): bool
{ {
$patterns = [ $patterns = [
'Fatal error:', 'Fatal error:',
'Fatal Error', 'Fatal Error',
'Parse error:', 'Parse error:',
'Uncaught Error:', 'Uncaught Error:',
'Uncaught Exception:', 'Uncaught Exception:',
]; ];
foreach ($patterns as $pattern) { foreach ($patterns as $pattern) {
if (stripos($body, $pattern) !== false) { if (stripos($body, $pattern) !== false) {
return true; return true;
} }
} }
return false; return false;
} }
private function pass(string $check, string $message): void private function pass(string $check, string $message): void
{ {
$this->passed++; $this->passed++;
$this->log('INFO', "[{$check}] PASS: {$message}"); $this->log('INFO', "[{$check}] PASS: {$message}");
} }
private function fail(string $check, string $message): void private function fail(string $check, string $message): void
{ {
$this->failed++; $this->failed++;
$this->log('ERROR', "[{$check}] FAIL: {$message}"); $this->log('ERROR', "[{$check}] FAIL: {$message}");
} }
} }
$app = new HealthCheckCli(); $app = new HealthCheckCli();
+130 -129
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -23,164 +24,164 @@ use MokoEnterprise\CliFramework;
class RollbackJoomlaCli extends CliFramework class RollbackJoomlaCli extends CliFramework
{ {
private const JOOMLA_DIRS = [ private const JOOMLA_DIRS = [
'administrator/components', 'administrator/components',
'administrator/language', 'administrator/language',
'administrator/modules', 'administrator/modules',
'administrator/templates', 'administrator/templates',
'components', 'components',
'language', 'language',
'layouts', 'layouts',
'libraries', 'libraries',
'media', 'media',
'modules', 'modules',
'plugins', 'plugins',
'templates', 'templates',
]; ];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot'); $this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot');
$this->addArgument('--config', 'Path to sftp-config.json', ''); $this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--snapshot-dir', 'Path to snapshot directory', ''); $this->addArgument('--snapshot-dir', 'Path to snapshot directory', '');
} }
protected function run(): int protected function run(): int
{ {
$configPath = $this->getArgument('--config'); $configPath = $this->getArgument('--config');
$snapshotDir = $this->getArgument('--snapshot-dir'); $snapshotDir = $this->getArgument('--snapshot-dir');
if ($configPath === '' || $snapshotDir === '') { if ($configPath === '' || $snapshotDir === '') {
$this->log('ERROR', 'Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]'); $this->log('ERROR', 'Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]');
return 1; return 1;
} }
if (!is_dir($snapshotDir)) { if (!is_dir($snapshotDir)) {
$this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}"); $this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}");
return 1; return 1;
} }
$config = $this->loadConfig($configPath); $config = $this->loadConfig($configPath);
if ($config === null) { if ($config === null) {
return 1; return 1;
} }
$host = $config['host'] ?? ''; $host = $config['host'] ?? '';
$user = $config['user'] ?? ''; $user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22); $port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/'); $remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? ''; $sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') { if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.'); $this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1; return 1;
} }
$this->log('INFO', 'Starting Joomla rollback from snapshot...'); $this->log('INFO', 'Starting Joomla rollback from snapshot...');
$this->log('INFO', "Snapshot: {$snapshotDir}"); $this->log('INFO', "Snapshot: {$snapshotDir}");
$this->log('INFO', "Target: {$user}@{$host}:{$remotePath}"); $this->log('INFO', "Target: {$user}@{$host}:{$remotePath}");
if ($this->dryRun) { if ($this->dryRun) {
$this->log('INFO', '*** DRY RUN — no changes will be made ***'); $this->log('INFO', '*** DRY RUN — no changes will be made ***');
} }
$failed = 0; $failed = 0;
foreach (self::JOOMLA_DIRS as $dir) { foreach (self::JOOMLA_DIRS as $dir) {
$localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/'; $localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/';
if (!is_dir($localDir)) { if (!is_dir($localDir)) {
if ($this->verbose) { if ($this->verbose) {
$this->log('INFO', "SKIP: {$dir} (not present in snapshot)"); $this->log('INFO', "SKIP: {$dir} (not present in snapshot)");
} }
continue; continue;
} }
$remoteTarget = "{$remotePath}/{$dir}/"; $remoteTarget = "{$remotePath}/{$dir}/";
$sshCmd = "ssh -p {$port}"; $sshCmd = "ssh -p {$port}";
if ($sshKey !== '') { if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey); $sshCmd .= " -i " . escapeshellarg($sshKey);
} }
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}"); $cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$this->log('INFO', "Restoring: {$dir}"); $this->log('INFO', "Restoring: {$dir}");
if ($this->verbose) { if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}"); $this->log('INFO', "CMD: {$cmd}");
} }
$output = []; $output = [];
$exitCode = 0; $exitCode = 0;
exec($cmd, $output, $exitCode); exec($cmd, $output, $exitCode);
if ($exitCode !== 0) { if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})"); $this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) { foreach ($output as $line) {
$this->log('ERROR', " {$line}"); $this->log('ERROR', " {$line}");
} }
$failed++; $failed++;
} else { } else {
if ($this->verbose) { if ($this->verbose) {
foreach ($output as $line) { foreach ($output as $line) {
$this->log('INFO', " {$line}"); $this->log('INFO', " {$line}");
} }
} }
} }
} }
if ($failed > 0) { if ($failed > 0) {
$this->log('ERROR', "Rollback completed with {$failed} error(s)."); $this->log('ERROR', "Rollback completed with {$failed} error(s).");
return 1; return 1;
} }
$this->log('INFO', 'Rollback completed successfully.'); $this->log('INFO', 'Rollback completed successfully.');
return 0; return 0;
} }
private function loadConfig(string $path): ?array private function loadConfig(string $path): ?array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}"); $this->log('ERROR', "Config file not found: {$path}");
return null; return null;
} }
$raw = file_get_contents($path); $raw = file_get_contents($path);
if ($raw === false) { if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}"); $this->log('ERROR', "Could not read config file: {$path}");
return null; return null;
} }
// Strip // comments (sftp-config.json style) // Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw); $cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true); $config = json_decode($cleaned, true);
if (!is_array($config)) { if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.'); $this->log('ERROR', 'Invalid JSON in config file.');
return null; return null;
} }
return $config; return $config;
} }
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
{ {
$parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php']; $parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php'];
if ($this->dryRun) { if ($this->dryRun) {
$parts[] = '--dry-run'; $parts[] = '--dry-run';
} }
if ($this->verbose) { if ($this->verbose) {
$parts[] = '-v'; $parts[] = '-v';
} }
$parts[] = '-e'; $parts[] = '-e';
$parts[] = escapeshellarg($sshCmd); $parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source); $parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest); $parts[] = escapeshellarg($dest);
return implode(' ', $parts); return implode(' ', $parts);
} }
} }
$app = new RollbackJoomlaCli(); $app = new RollbackJoomlaCli();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
+8 -5
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/** /**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> * Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
@@ -136,11 +137,13 @@ class PinActionShasCli extends CliFramework
*/ */
private function processLine(string $line, string $file, int $lineNum): ?string private function processLine(string $line, string $file, int $lineNum): ?string
{ {
if (!preg_match( if (
'/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/', !preg_match(
$line, '/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/',
$m $line,
)) { $m
)
) {
return null; return null;
} }
+202 -110
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -22,125 +23,216 @@ use MokoEnterprise\CliFramework;
class RepoInventoryCli extends CliFramework class RepoInventoryCli extends CliFramework
{ {
private $api = null; private $api = null;
private string $token = ''; private string $token = '';
private $platformConfig = null; private $platformConfig = null;
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Generate a live inventory dashboard of all governed repos'); $this->setDescription('Generate a live inventory dashboard of all governed repos');
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech'); $this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
$this->addArgument('--json', 'JSON output to stdout', false); $this->addArgument('--json', 'JSON output to stdout', false);
} }
protected function initialize(): void protected function initialize(): void
{ {
$this->platformConfig = \MokoEnterprise\Config::load(); $this->platformConfig = \MokoEnterprise\Config::load();
try { try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig); $adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig);
$this->api = $adapter->getApiClient(); $this->api = $adapter->getApiClient();
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log('ERROR', "Platform init failed: " . $e->getMessage()); $this->log('ERROR', "Platform init failed: " . $e->getMessage());
exit(1); exit(1);
} }
$this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea' $this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea'
? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', ''); ? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', '');
} }
protected function run(): int protected function run(): int
{ {
$org = $this->getArgument('--org'); $org = $this->getArgument('--org');
$jsonOut = (bool) $this->getArgument('--json'); $jsonOut = (bool) $this->getArgument('--json');
if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } if (!$jsonOut) {
$allRepos = []; $page = 1; echo "Fetching repositories from {$org}...\n";
do { }
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null); $allRepos = [];
$allRepos = array_merge($allRepos, $batch); $page++; $page = 1;
} while (count($batch) === 100); do {
if (!$jsonOut) { echo "Found " . count($allRepos) . " total repositories\n\n"; } [$_, $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 = []; $inventory = [];
foreach ($allRepos as $repo) { foreach ($allRepos as $repo) {
$name = $repo['name']; $name = $repo['name'];
if (in_array($name, self::ALWAYS_EXCLUDE, true)) { continue; } if (in_array($name, self::ALWAYS_EXCLUDE, true)) {
$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]; continue;
if ($entry['archived']) { $inventory[] = $entry; continue; } }
foreach (['.github/.mokostandards', '.mokostandards'] as $path) { $entry = [
[$status, $data] = $this->ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null); 'name' => $name,
if ($status === 200 && !empty($data['content'])) { 'visibility' => $repo['private'] ? 'private' : 'public',
$content = base64_decode($data['content']); 'archived' => $repo['archived'] ?? false,
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { $entry['platform'] = trim($m[1], " \t\n\r\"'"); } 'platform' => '-',
if (preg_match('/^version:\s*(.+)/m', $content, $m)) { $entry['version'] = trim($m[1], " \t\n\r\"'"); } 'version' => '-',
break; 'last_push' => $repo['pushed_at'] ?? '-',
} 'open_issues' => $repo['open_issues_count'] ?? 0,
} 'has_project' => false,
[$status, $rulesets] = $this->ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null); 'rulesets' => 0,
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]); if ($entry['archived']) {
$entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0; $inventory[] = $entry;
$inventory[] = $entry; continue;
if (!$jsonOut) { echo " {$name}: {$entry['platform']} | v{$entry['version']} | rulesets:{$entry['rulesets']} | project:" . ($entry['has_project'] ? 'yes' : 'no') . "\n"; } }
} 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'; $now = gmdate('Y-m-d H:i:s') . ' UTC';
$active = array_filter($inventory, fn($r) => !$r['archived']); $active = array_filter($inventory, fn($r) => !$r['archived']);
$archived = 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)); $withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3));
$withProj = count(array_filter($active, fn($r) => $r['has_project'])); $withProj = count(array_filter($active, fn($r) => $r['has_project']));
$activeN = count($active); $archivedN = count($archived); $activeN = count($active);
$rows = []; $archivedN = count($archived);
foreach ($inventory as $r) { $rows = [];
$vis = $r['visibility'] === 'private' ? 'prv' : 'pub'; foreach ($inventory as $r) {
$arch = $r['archived'] ? ' archived' : ''; $vis = $r['visibility'] === 'private' ? 'prv' : 'pub';
$proj = $r['has_project'] ? 'yes' : '-'; $arch = $r['archived'] ? ' archived' : '';
$rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3"); $proj = $r['has_project'] ? 'yes' : '-';
$rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |"; $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"; $table = implode("\n", $rows);
echo "\n" . str_repeat('-', 50) . "\n"; $body = "## Repository Inventory Dashboard\n\n"
echo "Active: {$activeN} | Archived: {$archivedN} | Rulesets 3/3: {$withRules} | Projects: {$withProj}\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) { if (!$this->dryRun) {
$title = "dashboard: repository inventory ({$org})"; $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); $issueQuery = "repos/{$org}/moko-platform/issues"
if (!empty($existing[0]['number'])) { . "?labels=inventory&state=all&per_page=1"
$num = $existing[0]['number']; . "&sort=created&direction=desc";
$this->ghApi('PATCH', "repos/{$org}/moko-platform/issues/{$num}", ['title' => $title, 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']]); [$_, $existing] = $this->ghApi('GET', $issueQuery, null);
echo "Updated inventory issue #{$num}\n"; if (!empty($existing[0]['number'])) {
} else { $num = $existing[0]['number'];
[$_, $issue] = $this->ghApi('POST', "repos/{$org}/moko-platform/issues", ['title' => $title, 'body' => $body, 'labels' => ['inventory', 'type: chore', 'automation'], 'assignees' => ['jmiller']]); $this->ghApi(
echo "Created inventory issue #{$issue['number']}\n"; 'PATCH',
} "repos/{$org}/moko-platform/issues/{$num}",
} else { echo "(dry-run) would post inventory dashboard issue\n"; } [
return 0; '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 private function ghApi(string $method, string $path, ?array $body): array
{ {
try { try {
$result = match ($method) { $result = match ($method) {
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []), 'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$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}"), 'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported: {$method}"),
}; };
return [200, $result]; return [200, $result];
} catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; } } catch (\Exception $e) {
} return [500, ['message' => $e->getMessage()]];
}
}
private function graphql(string $query, array $variables): array private function graphql(string $query, array $variables): array
{ {
$pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea'; $pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea';
if ($pf !== 'github') { return []; } if ($pf !== 'github') {
$payload = json_encode(['query' => $query, 'variables' => $variables]); return [];
$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']]); $payload = json_encode(['query' => $query, 'variables' => $variables]);
$body = (string) curl_exec($ch); curl_close($ch); $ch = curl_init('https://api.github.com/graphql');
return json_decode($body, true)['data'] ?? []; 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(); $app = new RepoInventoryCli();
+224 -145
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -22,164 +23,242 @@ use MokoEnterprise\CliFramework;
class RotateSecretsCli extends CliFramework class RotateSecretsCli extends CliFramework
{ {
private $api = null; private $api = null;
private string $token = ''; private string $token = '';
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private']; private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
private const ENVS = [ private const ENVS = [
'DEV' => ['vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'], 'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD']], 'DEV' => [
'DEMO' => ['vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'], 'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD']], 'vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'],
'RS' => ['vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'], 'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD']], '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 protected function configure(): void
{ {
$this->setDescription('Audit FTP secrets and variables across all governed repos'); $this->setDescription('Audit FTP secrets and variables across all governed repos');
$this->addArgument('--all', 'Audit all repos', false); $this->addArgument('--all', 'Audit all repos', false);
$this->addArgument('--repo', 'Single repo name', null); $this->addArgument('--repo', 'Single repo name', null);
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech'); $this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
$this->addArgument('--json', 'JSON output', false); $this->addArgument('--json', 'JSON output', false);
$this->addArgument('--create-issue', 'Post results as issue', false); $this->addArgument('--create-issue', 'Post results as issue', false);
} }
protected function initialize(): void protected function initialize(): void
{ {
$config = \MokoEnterprise\Config::load(); $config = \MokoEnterprise\Config::load();
try { try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->api = $adapter->getApiClient(); $this->api = $adapter->getApiClient();
} catch (\Exception $e) { } catch (\Exception $e) {
$this->log('ERROR', "Platform init failed: " . $e->getMessage()); $this->log('ERROR', "Platform init failed: " . $e->getMessage());
exit(1); exit(1);
} }
$this->token = $config->getString('platform', 'gitea') === 'gitea' $this->token = $config->getString('platform', 'gitea') === 'gitea'
? $config->getString('gitea.token', '') ? $config->getString('gitea.token', '')
: $config->getString('github.token', ''); : $config->getString('github.token', '');
} }
protected function run(): int protected function run(): int
{ {
$allMode = (bool) $this->getArgument('--all'); $allMode = (bool) $this->getArgument('--all');
$jsonOut = (bool) $this->getArgument('--json'); $jsonOut = (bool) $this->getArgument('--json');
$createIssue = (bool) $this->getArgument('--create-issue'); $createIssue = (bool) $this->getArgument('--create-issue');
$org = $this->getArgument('--org'); $org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo'); $repoName = $this->getArgument('--repo');
if (!$repoName && !$allMode) { if (!$repoName && !$allMode) {
$this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo <name> [--json] [--create-issue]"); $this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo <name> [--json] [--create-issue]");
return 2; return 2;
} }
$repos = []; $repos = [];
if ($allMode) { if ($allMode) {
if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; } if (!$jsonOut) {
$page = 1; echo "Fetching repositories from {$org}...\n";
do { }
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null); $page = 1;
foreach ($batch as $r) { do {
if (!($r['archived'] ?? false) && !in_array($r['name'], self::ALWAYS_EXCLUDE, true)) { [$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null);
$repos[] = $r['name']; 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); $page++;
if (!$jsonOut) { echo "Found " . count($repos) . " repositories\n\n"; } } while (count($batch) === 100);
} else { sort($repos);
$repos = [$repoName]; if (!$jsonOut) {
} echo "Found " . count($repos) . " repositories\n\n";
}
} else {
$repos = [$repoName];
}
$results = []; $results = [];
$issueCount = 0; $issueCount = 0;
foreach ($repos as $repo) { foreach ($repos as $repo) {
$fullRepo = "{$org}/{$repo}"; $fullRepo = "{$org}/{$repo}";
$repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables'); $repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables');
$repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets'); $repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets');
$result = ['repo' => $repo, 'envs' => [], 'missing' => []]; $result = ['repo' => $repo, 'envs' => [], 'missing' => []];
foreach (self::ENVS as $env => $envConfig) { foreach (self::ENVS as $env => $envConfig) {
$missingVars = array_diff($envConfig['vars'], $repoVars); $missingVars = array_diff($envConfig['vars'], $repoVars);
$hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets)); $hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets));
$hostVar = "{$env}_FTP_HOST"; $hostVar = "{$env}_FTP_HOST";
$configured = in_array($hostVar, $repoVars, true); $configured = in_array($hostVar, $repoVars, true);
$result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth]; $result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth];
if ($configured) { if ($configured) {
foreach ($missingVars as $v) { foreach ($missingVars as $v) {
if ($v !== "{$env}_FTP_SUFFIX") { $result['missing'][] = "{$env}: missing {$v}"; $issueCount++; } if ($v !== "{$env}_FTP_SUFFIX") {
} $result['missing'][] = "{$env}: missing {$v}";
if (!$hasAuth) { $result['missing'][] = "{$env}: no auth key/password"; $issueCount++; } $issueCount++;
} }
} }
if (!$hasAuth) {
$result['missing'][] = "{$env}: no auth key/password";
$issueCount++;
}
}
}
if (!$jsonOut) { if (!$jsonOut) {
$parts = []; $parts = [];
foreach (self::ENVS as $env => $_) { foreach (self::ENVS as $env => $_) {
$e = $result['envs'][$env]; $e = $result['envs'][$env];
if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) { $parts[] = "{$env}:OK"; } if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) {
elseif ($e['configured']) { $parts[] = "{$env}:INCOMPLETE"; } $parts[] = "{$env}:OK";
else { $parts[] = "{$env}:--"; } } elseif ($e['configured']) {
} $parts[] = "{$env}:INCOMPLETE";
echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n"; } else {
} $parts[] = "{$env}:--";
$results[] = $result; }
} }
echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n";
}
$results[] = $result;
}
if ($jsonOut) { if ($jsonOut) {
echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
} else { } else {
echo "\n" . str_repeat('-', 50) . "\n"; echo "\n" . str_repeat('-', 50) . "\n";
$total = count($results); $total = count($results);
$devReady = count(array_filter($results, fn($r) => ($r['envs']['DEV']['configured'] ?? false) && ($r['envs']['DEV']['has_auth'] ?? false))); $devReady = count(array_filter(
$demoReady = count(array_filter($results, fn($r) => ($r['envs']['DEMO']['configured'] ?? false) && ($r['envs']['DEMO']['has_auth'] ?? false))); $results,
$rsReady = count(array_filter($results, fn($r) => ($r['envs']['RS']['configured'] ?? false) && ($r['envs']['RS']['has_auth'] ?? false))); fn($r) => ($r['envs']['DEV']['configured'] ?? false)
echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n"; && ($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) { if ($createIssue && $issueCount > 0) {
$now = gmdate('Y-m-d H:i:s') . ' UTC'; $now = gmdate('Y-m-d H:i:s') . ' UTC';
$rows = []; $rows = [];
foreach ($results as $r) { foreach ($results as $r) {
foreach ($r['missing'] as $m) { $rows[] = "| `{$r['repo']}` | {$m} |"; } 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); $table = implode("\n", $rows);
if (!empty($existing[0]['number'])) { $body = "## FTP Secret/Variable Audit\n\n"
$num = $existing[0]['number']; . "**Date:** {$now}\n"
$this->ghApi('PATCH', "repos/{$org}/moko-platform/issues/{$num}", ['title' => "audit: FTP secrets -- {$issueCount} issues", 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']]); . "**Issues:** {$issueCount}\n\n"
if (!$jsonOut) { echo "Updated audit issue #{$num}\n"; } . "| Repository | Issue |\n|---|---|\n"
} else { . "{$table}\n\n---\n"
[$_, $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']]); . "*Auto-created by `rotate_secrets.php`*\n";
if (!$jsonOut) { echo "Created audit issue #{$issue['number']}\n"; } $auditQuery = "repos/{$org}/moko-platform/issues"
} . "?labels=secret-audit&state=all"
} . "&per_page=1&sort=created&direction=desc";
return $issueCount > 0 ? 1 : 0; [$_, $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 private function ghApi(string $method, string $path, ?array $body): array
{ {
try { try {
$result = match ($method) { $result = match ($method) {
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []), 'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$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}"), 'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"),
}; };
return [200, $result]; return [200, $result];
} catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; } } catch (\Exception $e) {
} return [500, ['message' => $e->getMessage()]];
}
}
private function listNames(string $path, string $key): array private function listNames(string $path, string $key): array
{ {
$names = []; $page = 1; $names = [];
do { $page = 1;
[$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null); do {
if ($status !== 200) { break; } [$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null);
$items = ($key === '') ? $data : ($data[$key] ?? []); if ($status !== 200) {
foreach ($items as $item) { if (isset($item['name'])) { $names[] = $item['name']; } } break;
$page++; }
} while (count($items) === 100); $items = ($key === '') ? $data : ($data[$key] ?? []);
return $names; foreach ($items as $item) {
} if (isset($item['name'])) {
$names[] = $item['name'];
}
}
$page++;
} while (count($items) === 100);
return $names;
}
} }
$app = new RotateSecretsCli(); $app = new RotateSecretsCli();
+189 -188
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* REQUIRED FILE: This file must be present in all moko-platform-compliant repositories * REQUIRED FILE: This file must be present in all moko-platform-compliant repositories
@@ -33,218 +34,218 @@ use MokoEnterprise\PlatformAdapterFactory;
*/ */
class SetupLabels extends CliFramework class SetupLabels extends CliFramework
{ {
private ?GitPlatformAdapter $adapter = null; private ?GitPlatformAdapter $adapter = null;
/** /**
* Label definitions — [name, hexColor (no #), description]. * Label definitions — [name, hexColor (no #), description].
* *
* @var list<array{0: string, 1: string, 2: string}> * @var list<array{0: string, 1: string, 2: string}>
*/ */
private const LABELS = [ private const LABELS = [
// Project Type // Project Type
['joomla', '7F52FF', 'Joomla extension or component'], ['joomla', '7F52FF', 'Joomla extension or component'],
['dolibarr', 'FF6B6B', 'Dolibarr module or extension'], ['dolibarr', 'FF6B6B', 'Dolibarr module or extension'],
['generic', '808080', 'Generic project or library'], ['generic', '808080', 'Generic project or library'],
// Language // Language
['php', '4F5D95', 'PHP code changes'], ['php', '4F5D95', 'PHP code changes'],
['javascript', 'F7DF1E', 'JavaScript code changes'], ['javascript', 'F7DF1E', 'JavaScript code changes'],
['typescript', '3178C6', 'TypeScript code changes'], ['typescript', '3178C6', 'TypeScript code changes'],
['python', '3776AB', 'Python code changes'], ['python', '3776AB', 'Python code changes'],
['css', '1572B6', 'CSS/styling changes'], ['css', '1572B6', 'CSS/styling changes'],
['html', 'E34F26', 'HTML template changes'], ['html', 'E34F26', 'HTML template changes'],
// Component // Component
['documentation', '0075CA', 'Documentation changes'], ['documentation', '0075CA', 'Documentation changes'],
['ci-cd', '000000', 'CI/CD pipeline changes'], ['ci-cd', '000000', 'CI/CD pipeline changes'],
['docker', '2496ED', 'Docker configuration changes'], ['docker', '2496ED', 'Docker configuration changes'],
['tests', '00FF00', 'Test suite changes'], ['tests', '00FF00', 'Test suite changes'],
['security', 'FF0000', 'Security-related changes'], ['security', 'FF0000', 'Security-related changes'],
['dependencies', '0366D6', 'Dependency updates'], ['dependencies', '0366D6', 'Dependency updates'],
['config', 'F9D0C4', 'Configuration file changes'], ['config', 'F9D0C4', 'Configuration file changes'],
['build', 'FFA500', 'Build system changes'], ['build', 'FFA500', 'Build system changes'],
// Workflow / Process // Workflow / Process
['automation', '8B4513', 'Automated processes or scripts'], ['automation', '8B4513', 'Automated processes or scripts'],
['moko-platform', 'B60205', 'moko-platform compliance'], ['moko-platform', 'B60205', 'moko-platform compliance'],
['needs-review', 'FBCA04', 'Awaiting code review'], ['needs-review', 'FBCA04', 'Awaiting code review'],
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
['breaking-change', 'D73A4A', 'Breaking API or functionality change'], ['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
// Priority // Priority
['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'], ['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'],
['priority: high', 'D93F0B', 'High priority'], ['priority: high', 'D93F0B', 'High priority'],
['priority: medium', 'FBCA04', 'Medium priority'], ['priority: medium', 'FBCA04', 'Medium priority'],
['priority: low', '0E8A16', 'Low priority'], ['priority: low', '0E8A16', 'Low priority'],
// Type // Type
['type: bug', 'D73A4A', "Something isn't working"], ['type: bug', 'D73A4A', "Something isn't working"],
['type: feature', 'A2EEEF', 'New feature or request'], ['type: feature', 'A2EEEF', 'New feature or request'],
['type: enhancement', '84B6EB', 'Enhancement to existing feature'], ['type: enhancement', '84B6EB', 'Enhancement to existing feature'],
['type: refactor', 'F9D0C4', 'Code refactoring'], ['type: refactor', 'F9D0C4', 'Code refactoring'],
['type: chore', 'FEF2C0', 'Maintenance tasks'], ['type: chore', 'FEF2C0', 'Maintenance tasks'],
// Status // Status
['status: pending', 'FBCA04', 'Pending action or decision'], ['status: pending', 'FBCA04', 'Pending action or decision'],
['status: in-progress', '0E8A16', 'Currently being worked on'], ['status: in-progress', '0E8A16', 'Currently being worked on'],
['status: blocked', 'B60205', 'Blocked by another issue or dependency'], ['status: blocked', 'B60205', 'Blocked by another issue or dependency'],
['status: on-hold', 'D4C5F9', 'Temporarily on hold'], ['status: on-hold', 'D4C5F9', 'Temporarily on hold'],
['status: wontfix', 'FFFFFF', 'This will not be worked on'], ['status: wontfix', 'FFFFFF', 'This will not be worked on'],
// Size // Size
['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'], ['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'],
['size/s', '6FD1E2', 'Small change (11-30 lines)'], ['size/s', '6FD1E2', 'Small change (11-30 lines)'],
['size/m', 'F9DD72', 'Medium change (31-100 lines)'], ['size/m', 'F9DD72', 'Medium change (31-100 lines)'],
['size/l', 'FFA07A', 'Large change (101-300 lines)'], ['size/l', 'FFA07A', 'Large change (101-300 lines)'],
['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'], ['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'],
['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'], ['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'],
// Health // Health
['health: excellent', '0E8A16', 'Health score 90-100'], ['health: excellent', '0E8A16', 'Health score 90-100'],
['health: good', 'FBCA04', 'Health score 70-89'], ['health: good', 'FBCA04', 'Health score 70-89'],
['health: fair', 'FFA500', 'Health score 50-69'], ['health: fair', 'FFA500', 'Health score 50-69'],
['health: poor', 'FF6B6B', 'Health score below 50'], ['health: poor', 'FF6B6B', 'Health score below 50'],
// Sync / Automation // Sync / Automation
['standards-update', 'B60205', 'moko-platform sync update'], ['standards-update', 'B60205', 'moko-platform sync update'],
['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'], ['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'],
['sync-report', '0075CA', 'Bulk sync run report'], ['sync-report', '0075CA', 'Bulk sync run report'],
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
['push-failure', 'D73A4A', 'File push failure requiring attention'], ['push-failure', 'D73A4A', 'File push failure requiring attention'],
['health-check', '0E8A16', 'Repository health check results'], ['health-check', '0E8A16', 'Repository health check results'],
['version-drift', 'FFA500', 'Version mismatch detected'], ['version-drift', 'FFA500', 'Version mismatch detected'],
['deploy-failure', 'CC0000', 'Automated deploy failure tracking'], ['deploy-failure', 'CC0000', 'Automated deploy failure tracking'],
['template-validation-failure', 'D73A4A', 'Template workflow validation failure'], ['template-validation-failure', 'D73A4A', 'Template workflow validation failure'],
['version', '0E8A16', 'Version bump or release'], ['version', '0E8A16', 'Version bump or release'],
['type: version', '0E8A16', 'Version-related change'], ['type: version', '0E8A16', 'Version-related change'],
// Testing // Testing
['type: test', '00FF00', 'Test suite additions or changes'], ['type: test', '00FF00', 'Test suite additions or changes'],
['needs-testing', 'FBCA04', 'Requires manual or automated testing'], ['needs-testing', 'FBCA04', 'Requires manual or automated testing'],
['test-failure', 'D73A4A', 'Automated test failure'], ['test-failure', 'D73A4A', 'Automated test failure'],
['regression', 'B60205', 'Regression from a previous working state'], ['regression', 'B60205', 'Regression from a previous working state'],
// Version & Release // Version & Release
['type: release', '0E8A16', 'Release preparation or tracking'], ['type: release', '0E8A16', 'Release preparation or tracking'],
['release-candidate', 'BFD4F2', 'Release candidate build'], ['release-candidate', 'BFD4F2', 'Release candidate build'],
['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'], ['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'],
['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'], ['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'],
['major-release', 'B60205', 'Major version release (breaking changes)'], ['major-release', 'B60205', 'Major version release (breaking changes)'],
['version-branch', '1D76DB', 'Version branch related'], ['version-branch', '1D76DB', 'Version branch related'],
]; ];
/** /**
* Configure available arguments. * Configure available arguments.
*/ */
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('REQUIRED: Deploy standard labels to repository'); $this->setDescription('REQUIRED: Deploy standard labels to repository');
$this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false); $this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false);
$this->addArgument('--org', 'Organization name', 'mokoconsulting-tech'); $this->addArgument('--org', 'Organization name', 'mokoconsulting-tech');
$this->addArgument('--repo', 'Repository name (defaults to current repo)', ''); $this->addArgument('--repo', 'Repository name (defaults to current repo)', '');
} }
/** /**
* Run the label deployment. * Run the label deployment.
* *
* @return int Exit code: 0 on success, 1 on error. * @return int Exit code: 0 on success, 1 on error.
*/ */
protected function run(): int protected function run(): int
{ {
$dryRun = (bool) $this->getArgument('--dry-run'); $dryRun = (bool) $this->getArgument('--dry-run');
$config = Config::load(); $config = Config::load();
try { try {
$this->adapter = PlatformAdapterFactory::create($config); $this->adapter = PlatformAdapterFactory::create($config);
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
$this->log('ERROR', $e->getMessage()); $this->log('ERROR', $e->getMessage());
return 1; return 1;
} }
$orgArg = (string) $this->getArgument('--org'); $orgArg = (string) $this->getArgument('--org');
$repoArg = (string) $this->getArgument('--repo'); $repoArg = (string) $this->getArgument('--repo');
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
$repo = $repoArg ?: basename(getcwd() ?: '.'); $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 project type labels...', 0, 2, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED language labels...', 3, 8, $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 component labels...', 9, 16, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED workflow labels...', 17, 21, $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 priority labels...', 22, 25, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED type labels...', 26, 30, $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 status labels...', 31, 35, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED size labels...', 36, 41, $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 health labels...', 42, 45, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED sync/automation labels...', 46, 56, $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 testing labels...', 57, 60, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun); $this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun);
echo "\n============================================================\n"; echo "\n============================================================\n";
if ($dryRun) { if ($dryRun) {
$this->log('INFO', '[DRY-RUN] Label deployment simulation completed'); $this->log('INFO', '[DRY-RUN] Label deployment simulation completed');
} else { } else {
$this->log('INFO', 'Label deployment completed successfully!'); $this->log('INFO', 'Label deployment completed successfully!');
echo "\n - TOTAL: " . count(self::LABELS) . " labels\n"; echo "\n - TOTAL: " . count(self::LABELS) . " labels\n";
} }
echo "============================================================\n\n"; echo "============================================================\n\n";
return 0; return 0;
} }
// ── Private helpers ─────────────────────────────────────────────────────── // ── Private helpers ───────────────────────────────────────────────────────
/** /**
* Deploy a named group of labels by index range in self::LABELS. * Deploy a named group of labels by index range in self::LABELS.
* *
* @param string $heading Informational banner printed before the group. * @param string $heading Informational banner printed before the group.
* @param int $fromIndex First label index (inclusive). * @param int $fromIndex First label index (inclusive).
* @param int $toIndex Last label index (inclusive). * @param int $toIndex Last label index (inclusive).
* @param string $org Organization name. * @param string $org Organization name.
* @param string $repo Repository name. * @param string $repo Repository name.
* @param bool $dryRun When true, preview only. * @param bool $dryRun When true, preview only.
*/ */
private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void
{ {
$this->log('INFO', $heading); $this->log('INFO', $heading);
for ($i = $fromIndex; $i <= $toIndex; $i++) { for ($i = $fromIndex; $i <= $toIndex; $i++) {
[$name, $color, $desc] = self::LABELS[$i]; [$name, $color, $desc] = self::LABELS[$i];
$this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun); $this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun);
} }
echo "\n"; echo "\n";
} }
/** /**
* Create or update a single label via the platform adapter. * Create or update a single label via the platform adapter.
* *
* @param string $name Label name. * @param string $name Label name.
* @param string $color Hex colour without the leading '#'. * @param string $color Hex colour without the leading '#'.
* @param string $desc Short description text. * @param string $desc Short description text.
* @param string $org Organization name. * @param string $org Organization name.
* @param string $repo Repository name. * @param string $repo Repository name.
* @param bool $dryRun When true, preview only. * @param bool $dryRun When true, preview only.
*/ */
private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void
{ {
if ($dryRun) { if ($dryRun) {
echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n"; echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n";
return; return;
} }
try { try {
$this->adapter->createLabel($org, $repo, $name, $color, $desc); $this->adapter->createLabel($org, $repo, $name, $color, $desc);
$this->log('INFO', "Created/updated label: {$name}"); $this->log('INFO', "Created/updated label: {$name}");
} catch (\Exception $e) { } catch (\Exception $e) {
// Label may already exist — that's fine // Label may already exist — that's fine
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
$this->log('INFO', "Label already exists: {$name}"); $this->log('INFO', "Label already exists: {$name}");
} else { } else {
$this->log('WARNING', "Failed to create label: {$name}" . $e->getMessage()); $this->log('WARNING', "Failed to create label: {$name}" . $e->getMessage());
} }
} }
} }
} }
$script = new SetupLabels('setup_labels', 'REQUIRED: Deploy standard labels to repository'); $script = new SetupLabels('setup_labels', 'REQUIRED: Deploy standard labels to repository');
+242 -232
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -34,215 +35,224 @@ use MokoEnterprise\CliFramework;
*/ */
class SyncDolibarrReadmes extends CliFramework class SyncDolibarrReadmes extends CliFramework
{ {
/** /**
* Configure available arguments. * Configure available arguments.
*/ */
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Keeps root README.md and src/README.md in sync for Dolibarr module repos'); $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('--path', 'Dolibarr module repo root', '.');
$this->addArgument('--dry-run', 'Preview changes without writing', false); $this->addArgument('--dry-run', 'Preview changes without writing', false);
} }
/** /**
* Run the sync. * Run the sync.
* *
* @return int Exit code: 0 on success, 1 on error. * @return int Exit code: 0 on success, 1 on error.
*/ */
protected function run(): int protected function run(): int
{ {
$repoRoot = rtrim((string) $this->getArgument('--path'), '/'); $repoRoot = rtrim((string) $this->getArgument('--path'), '/');
$dryRun = (bool) $this->getArgument('--dry-run'); $dryRun = (bool) $this->getArgument('--dry-run');
$rootReadme = $repoRoot . '/README.md'; $rootReadme = $repoRoot . '/README.md';
$srcReadme = $repoRoot . '/src/README.md'; $srcReadme = $repoRoot . '/src/README.md';
if (!is_file($rootReadme)) { if (!is_file($rootReadme)) {
$this->log('ERROR', "Root README.md not found at {$rootReadme}"); $this->log('ERROR', "Root README.md not found at {$rootReadme}");
return 1; return 1;
} }
if (!is_dir($repoRoot . '/src')) { if (!is_dir($repoRoot . '/src')) {
$this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?'); $this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?');
return 1; 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)) { 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'); $this->log('ERROR', 'Could not find VERSION in root README.md FILE INFORMATION block');
return 1; return 1;
} }
$version = $m[1]; $version = $m[1];
$moduleName = $this->extractModuleName($rootContent, $repoRoot); $moduleName = $this->extractModuleName($rootContent, $repoRoot);
$repoUrl = $this->extractField($rootContent, 'REPO', 'https://git.mokoconsulting.tech/MokoConsulting'); $repoUrl = $this->extractField($rootContent, 'REPO', 'https://git.mokoconsulting.tech/MokoConsulting');
$defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoPlatform.Module'); $defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoPlatform.Module');
$ingroup = $this->extractField($rootContent, 'INGROUP', 'moko-platform'); $ingroup = $this->extractField($rootContent, 'INGROUP', 'moko-platform');
$brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation"); $brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation");
$installSection = $this->extractSection($rootContent, 'Installation'); $installSection = $this->extractSection($rootContent, 'Installation');
$configSection = $this->extractSection($rootContent, 'Configuration'); $configSection = $this->extractSection($rootContent, 'Configuration');
$usageSection = $this->extractSection($rootContent, 'Usage'); $usageSection = $this->extractSection($rootContent, 'Usage');
$supportSection = $this->extractSection($rootContent, 'Support'); $supportSection = $this->extractSection($rootContent, 'Support');
echo "═══════════════════════════════════════════════════════════\n"; echo "═══════════════════════════════════════════════════════════\n";
echo " Dolibarr README Sync\n"; echo " Dolibarr README Sync\n";
echo "═══════════════════════════════════════════════════════════\n\n"; echo "═══════════════════════════════════════════════════════════\n\n";
echo "Module: {$moduleName}\n"; echo "Module: {$moduleName}\n";
echo "Version: {$version}\n"; echo "Version: {$version}\n";
echo "Root: {$rootReadme}\n"; echo "Root: {$rootReadme}\n";
echo "Src: {$srcReadme}\n"; echo "Src: {$srcReadme}\n";
if ($dryRun) { if ($dryRun) {
echo " DRY RUN — no files will be written\n"; echo " DRY RUN — no files will be written\n";
} }
echo "\n"; echo "\n";
echo "Step 1: Update root README.md badges and VERSION field...\n"; echo "Step 1: Update root README.md badges and VERSION field...\n";
$this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun); $this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun);
echo "Step 2: Sync src/README.md...\n"; echo "Step 2: Sync src/README.md...\n";
$today = gmdate('Y-m-d'); $today = gmdate('Y-m-d');
$newSrcContent = $this->buildSrcReadme( $newSrcContent = $this->buildSrcReadme(
$version, $moduleName, $repoUrl, $defgroup, $ingroup, $brief, $today, $version,
$installSection, $configSection, $usageSection, $supportSection $moduleName,
); $repoUrl,
$this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun); $defgroup,
$ingroup,
$brief,
$today,
$installSection,
$configSection,
$usageSection,
$supportSection
);
$this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun);
echo "\n═══════════════════════════════════════════════════════════\n"; echo "\n═══════════════════════════════════════════════════════════\n";
if ($dryRun) { if ($dryRun) {
echo " Dry Run Complete\n"; echo " Dry Run Complete\n";
echo "═══════════════════════════════════════════════════════════\n"; echo "═══════════════════════════════════════════════════════════\n";
echo "Run without --dry-run to apply changes.\n"; echo "Run without --dry-run to apply changes.\n";
} else { } else {
echo " Dolibarr README Sync Complete\n"; echo " Dolibarr README Sync Complete\n";
echo "═══════════════════════════════════════════════════════════\n"; echo "═══════════════════════════════════════════════════════════\n";
echo "Module version: {$version}\n\n"; echo "Module version: {$version}\n\n";
echo "Next steps:\n"; echo "Next steps:\n";
echo " git diff && git add README.md src/README.md\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 " git commit -m \"docs(readme): sync src/README.md from root for version {$version}\"\n";
} }
echo "\n"; echo "\n";
return 0; return 0;
} }
// ── Private helpers ─────────────────────────────────────────────────────── // ── Private helpers ───────────────────────────────────────────────────────
/** /**
* Extract a named field from the FILE INFORMATION block. * Extract a named field from the FILE INFORMATION block.
* *
* @param string $content Full file content. * @param string $content Full file content.
* @param string $field Field name (e.g. 'REPO'). * @param string $field Field name (e.g. 'REPO').
* @param string $fallback Value to use when the field is absent. * @param string $fallback Value to use when the field is absent.
* @return string Field value or fallback. * @return string Field value or fallback.
*/ */
private function extractField(string $content, string $field, string $fallback): string private function extractField(string $content, string $field, string $fallback): string
{ {
if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) { if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) {
return trim($m[1]); return trim($m[1]);
} }
return $fallback; return $fallback;
} }
/** /**
* Extract the module name from the first H1 heading after the closing '-->' of the header. * 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 $content Full root README.md content.
* @param string $repoRoot Repository root path (used as fallback). * @param string $repoRoot Repository root path (used as fallback).
* @return string Module name. * @return string Module name.
*/ */
private function extractModuleName(string $content, string $repoRoot): string private function extractModuleName(string $content, string $repoRoot): string
{ {
if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) { if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) {
return trim($m[1]); return trim($m[1]);
} }
return basename($repoRoot); return basename($repoRoot);
} }
/** /**
* Extract a Markdown H2 section (from '## Heading' to the next '## '). * Extract a Markdown H2 section (from '## Heading' to the next '## ').
* *
* @param string $content Full file content. * @param string $content Full file content.
* @param string $heading Section heading (without '## ' prefix). * @param string $heading Section heading (without '## ' prefix).
* @return string The extracted section text, or '' if not found. * @return string The extracted section text, or '' if not found.
*/ */
private function extractSection(string $content, string $heading): string private function extractSection(string $content, string $heading): string
{ {
$quoted = preg_quote($heading, '/'); $quoted = preg_quote($heading, '/');
if (!preg_match('/^## ' . $quoted . '$/m', $content)) { if (!preg_match('/^## ' . $quoted . '$/m', $content)) {
return ''; return '';
} }
if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) { if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) {
return '## ' . $heading . $m[1]; return '## ' . $heading . $m[1];
} }
return ''; return '';
} }
/** /**
* Update the version badge and VERSION field in root README.md. * Update the version badge and VERSION field in root README.md.
* *
* @param string $path Path to root README.md. * @param string $path Path to root README.md.
* @param string $content Current file content. * @param string $content Current file content.
* @param string $version New version string. * @param string $version New version string.
* @param bool $dryRun When true, preview only. * @param bool $dryRun When true, preview only.
*/ */
private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void
{ {
$updated = preg_replace( $updated = preg_replace(
'/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i', '/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i',
'${1}' . $version, '${1}' . $version,
$content $content
); );
$updated = preg_replace( $updated = preg_replace(
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m', '/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $version, '${1}' . $version,
(string) $updated (string) $updated
); );
if ($updated === $content) { if ($updated === $content) {
echo " ✓ root README.md already current\n"; echo " ✓ root README.md already current\n";
return; return;
} }
if ($dryRun) { if ($dryRun) {
echo " ~ root README.md (would update version fields)\n"; echo " ~ root README.md (would update version fields)\n";
return; return;
} }
file_put_contents($path, (string) $updated); file_put_contents($path, (string) $updated);
echo " ✓ root README.md updated\n"; echo " ✓ root README.md updated\n";
} }
/** /**
* Build the full content for src/README.md. * Build the full content for src/README.md.
* *
* @param string $version Version string. * @param string $version Version string.
* @param string $moduleName Module display name. * @param string $moduleName Module display name.
* @param string $repoUrl Repository URL. * @param string $repoUrl Repository URL.
* @param string $defgroup DEFGROUP value. * @param string $defgroup DEFGROUP value.
* @param string $ingroup INGROUP value. * @param string $ingroup INGROUP value.
* @param string $brief BRIEF value. * @param string $brief BRIEF value.
* @param string $today ISO date string (YYYY-MM-DD). * @param string $today ISO date string (YYYY-MM-DD).
* @param string $installSection Extracted Installation section (may be ''). * @param string $installSection Extracted Installation section (may be '').
* @param string $configSection Extracted Configuration section (may be ''). * @param string $configSection Extracted Configuration section (may be '').
* @param string $usageSection Extracted Usage section (may be ''). * @param string $usageSection Extracted Usage section (may be '').
* @param string $supportSection Extracted Support section (may be ''). * @param string $supportSection Extracted Support section (may be '').
* @return string Complete file content. * @return string Complete file content.
*/ */
private function buildSrcReadme( private function buildSrcReadme(
string $version, string $version,
string $moduleName, string $moduleName,
string $repoUrl, string $repoUrl,
string $defgroup, string $defgroup,
string $ingroup, string $ingroup,
string $brief, string $brief,
string $today, string $today,
string $installSection, string $installSection,
string $configSection, string $configSection,
string $usageSection, string $usageSection,
string $supportSection string $supportSection
): string { ): string {
$content = <<<SRCREADME $content = <<<SRCREADME
<!-- <!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
@@ -270,54 +280,54 @@ NOTE: This file is auto-generated by sync_dolibarr_readmes.php from root README.
SRCREADME; SRCREADME;
foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) { foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) {
if ($section !== '') { if ($section !== '') {
$content .= "\n" . $section; $content .= "\n" . $section;
} }
} }
$content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n"; $content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n";
return $content; return $content;
} }
/** /**
* Compare and write (or preview) src/README.md. * Compare and write (or preview) src/README.md.
* *
* @param string $path Path to src/README.md. * @param string $path Path to src/README.md.
* @param string $content Desired file content. * @param string $content Desired file content.
* @param bool $dryRun When true, preview only. * @param bool $dryRun When true, preview only.
*/ */
private function syncSrcReadme(string $path, string $content, bool $dryRun): void private function syncSrcReadme(string $path, string $content, bool $dryRun): void
{ {
if (is_file($path)) { if (is_file($path)) {
$existing = (string) file_get_contents($path); $existing = (string) file_get_contents($path);
if ($existing === $content) { if ($existing === $content) {
echo " ✓ src/README.md already current\n"; echo " ✓ src/README.md already current\n";
return; return;
} }
if ($dryRun) { if ($dryRun) {
echo " ~ src/README.md (would regenerate)\n"; echo " ~ src/README.md (would regenerate)\n";
return; return;
} }
if (!is_dir(dirname($path))) { if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true); mkdir(dirname($path), 0755, true);
} }
file_put_contents($path, $content); file_put_contents($path, $content);
echo " ✓ src/README.md regenerated\n"; echo " ✓ src/README.md regenerated\n";
return; return;
} }
if ($dryRun) { if ($dryRun) {
echo " ~ src/README.md (would create — file does not exist)\n"; echo " ~ src/README.md (would create — file does not exist)\n";
return; return;
} }
if (!is_dir(dirname($path))) { if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true); mkdir(dirname($path), 0755, true);
} }
file_put_contents($path, $content); file_put_contents($path, $content);
echo " ✓ src/README.md created\n"; 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'); $script = new SyncDolibarrReadmes('sync_dolibarr_readmes', 'Keeps root README.md and src/README.md in sync for Dolibarr module repos');
+219 -218
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/** /**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> * Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
@@ -36,274 +37,274 @@ use MokoEnterprise\PlatformAdapterFactory;
*/ */
class UpdateRepoInventory extends CliFramework class UpdateRepoInventory extends CliFramework
{ {
private ?GitPlatformAdapter $adapter = null; private ?GitPlatformAdapter $adapter = null;
/** Marker that begins the auto-generated block. */ /** Marker that begins the auto-generated block. */
private const MARKER_START = '<!-- INVENTORY_TABLE_START -->'; private const MARKER_START = '<!-- INVENTORY_TABLE_START -->';
/** Marker that ends the auto-generated block. */ /** Marker that ends the auto-generated block. */
private const MARKER_END = '<!-- INVENTORY_TABLE_END -->'; private const MARKER_END = '<!-- INVENTORY_TABLE_END -->';
/** Path to the Dolibarr module registry relative to repo root. */ /** Path to the Dolibarr module registry relative to repo root. */
private const REGISTRY_PATH = 'docs/development/crm/module-registry.md'; private const REGISTRY_PATH = 'docs/development/crm/module-registry.md';
/** Path to the inventory file relative to repo root. */ /** Path to the inventory file relative to repo root. */
private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md'; private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md';
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list'); $this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list');
$this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech'); $this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech');
$this->addArgument('--path', 'Repository root path', '.'); $this->addArgument('--path', 'Repository root path', '.');
} }
protected function run(): int protected function run(): int
{ {
$root = rtrim((string) $this->getArgument('--path'), '/\\'); $root = rtrim((string) $this->getArgument('--path'), '/\\');
$config = Config::load(); $config = Config::load();
try { try {
$this->adapter = PlatformAdapterFactory::create($config); $this->adapter = PlatformAdapterFactory::create($config);
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
$this->status(false, 'auth', $e->getMessage()); $this->status(false, 'auth', $e->getMessage());
return 2; return 2;
} }
$orgArg = (string) $this->getArgument('--org'); $orgArg = (string) $this->getArgument('--org');
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech'); $org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
// ── 1. Fetch repositories ───────────────────────────────────────────── // ── 1. Fetch repositories ─────────────────────────────────────────────
$this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})"); $this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})");
$repos = $this->fetchAllRepos($org); $repos = $this->fetchAllRepos($org);
if ($repos === null) { if ($repos === null) {
return 1; 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 ────────────────────────────────── // ── 2. Load Dolibarr module registry ──────────────────────────────────
$this->section('Loading Dolibarr module registry'); $this->section('Loading Dolibarr module registry');
$moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH); $moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH);
$this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap))); $this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap)));
// ── 3. Build the Markdown tables ────────────────────────────────────── // ── 3. Build the Markdown tables ──────────────────────────────────────
$this->section('Building inventory tables'); $this->section('Building inventory tables');
$table = $this->buildTables($repos, $moduleMap, $org); $table = $this->buildTables($repos, $moduleMap, $org);
// ── 4. Rewrite the inventory file ──────────────────────────────────── // ── 4. Rewrite the inventory file ────────────────────────────────────
$this->section('Updating ' . self::INVENTORY_PATH); $this->section('Updating ' . self::INVENTORY_PATH);
$inventoryPath = $root . '/' . self::INVENTORY_PATH; $inventoryPath = $root . '/' . self::INVENTORY_PATH;
if (!is_file($inventoryPath)) { if (!is_file($inventoryPath)) {
$this->status(false, self::INVENTORY_PATH, 'file not found'); $this->status(false, self::INVENTORY_PATH, 'file not found');
return 2; return 2;
} }
$original = (string) file_get_contents($inventoryPath); $original = (string) file_get_contents($inventoryPath);
$updated = $this->replaceSection($original, $table); $updated = $this->replaceSection($original, $table);
if ($original === $updated) { if ($original === $updated) {
$this->status(true, self::INVENTORY_PATH, 'no changes needed'); $this->status(true, self::INVENTORY_PATH, 'no changes needed');
} elseif (!$this->isDryRun()) { } elseif (!$this->isDryRun()) {
file_put_contents($inventoryPath, $updated); file_put_contents($inventoryPath, $updated);
$this->status(true, self::INVENTORY_PATH, 'updated'); $this->status(true, self::INVENTORY_PATH, 'updated');
} else { } else {
$this->status(true, self::INVENTORY_PATH, '[dry-run] would update'); $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. * Fetch all repositories for the org via the platform adapter.
* *
* @return list<array<string,mixed>>|null Null on API error. * @return list<array<string,mixed>>|null Null on API error.
*/ */
private function fetchAllRepos(string $org): ?array private function fetchAllRepos(string $org): ?array
{ {
try { try {
// Use the adapter's paginated listing — returns full repo objects // Use the adapter's paginated listing — returns full repo objects
$repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']); $repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
$this->progress(count($repos), count($repos), '', true); $this->progress(count($repos), count($repos), '', true);
return $repos; return $repos;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->status(false, 'API', $e->getMessage()); $this->status(false, 'API', $e->getMessage());
return null; return null;
} }
} }
// ── Module registry ─────────────────────────────────────────────────────── // ── Module registry ───────────────────────────────────────────────────────
/** /**
* Parse the Dolibarr module registry Markdown table. * Parse the Dolibarr module registry Markdown table.
* *
* @return array<string,int> Map of lower-case repo name → module number. * @return array<string,int> Map of lower-case repo name → module number.
*/ */
private function parseModuleRegistry(string $path): array private function parseModuleRegistry(string $path): array
{ {
if (!is_file($path)) { if (!is_file($path)) {
$this->warning("Module registry not found: {$path}"); $this->warning("Module registry not found: {$path}");
return []; return [];
} }
$map = []; $map = [];
$content = (string) file_get_contents($path); $content = (string) file_get_contents($path);
// Match table rows: | ModuleName | 185051 | Status | … | // Match table rows: | ModuleName | 185051 | Status | … |
preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER); preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) { foreach ($matches as $match) {
$id = (int) $match[2]; $id = (int) $match[2];
if ($id >= 100000) { if ($id >= 100000) {
$map[strtolower($match[1])] = $id; $map[strtolower($match[1])] = $id;
} }
} }
return $map; return $map;
} }
// ── Table builder ───────────────────────────────────────────────────────── // ── Table builder ─────────────────────────────────────────────────────────
/** /**
* Build the full Markdown replacement for the inventory tables. * Build the full Markdown replacement for the inventory tables.
* *
* @param list<array<string,mixed>> $repos * @param list<array<string,mixed>> $repos
* @param array<string,int> $moduleMap * @param array<string,int> $moduleMap
*/ */
private function buildTables(array $repos, array $moduleMap, string $org): string private function buildTables(array $repos, array $moduleMap, string $org): string
{ {
// Sort: active first, then archived; within each group alphabetically. // Sort: active first, then archived; within each group alphabetically.
usort($repos, static function (array $a, array $b): int { usort($repos, static function (array $a, array $b): int {
$aArch = (bool) ($a['archived'] ?? false); $aArch = (bool) ($a['archived'] ?? false);
$bArch = (bool) ($b['archived'] ?? false); $bArch = (bool) ($b['archived'] ?? false);
if ($aArch !== $bArch) { if ($aArch !== $bArch) {
return $aArch ? 1 : -1; return $aArch ? 1 : -1;
} }
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
}); });
/** @var array<string,list<array<string,mixed>>> $groups */ /** @var array<string,list<array<string,mixed>>> $groups */
$groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []]; $groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []];
foreach ($repos as $repo) { foreach ($repos as $repo) {
$name = (string) ($repo['name'] ?? ''); $name = (string) ($repo['name'] ?? '');
$topics = array_map('strtolower', (array) ($repo['topics'] ?? [])); $topics = array_map('strtolower', (array) ($repo['topics'] ?? []));
$archived = (bool) ($repo['archived'] ?? false); $archived = (bool) ($repo['archived'] ?? false);
if ($archived) { if ($archived) {
$groups['archived'][] = $repo; $groups['archived'][] = $repo;
continue; continue;
} }
$lower = strtolower($name); $lower = strtolower($name);
if (in_array('mokostandards-core', $topics, true) || $name === 'moko-platform' || $name === '.github-private') { if (in_array('mokostandards-core', $topics, true) || $name === 'moko-platform' || $name === '.github-private') {
$groups['core'][] = $repo; $groups['core'][] = $repo;
} elseif ( } elseif (
in_array('dolibarr-module', $topics, true) in_array('dolibarr-module', $topics, true)
|| str_starts_with($lower, 'mokodoli') || str_starts_with($lower, 'mokodoli')
|| (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme') || (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme')
) { ) {
$groups['extension'][] = $repo; $groups['extension'][] = $repo;
} elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) { } elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) {
$groups['product'][] = $repo; $groups['product'][] = $repo;
} elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) { } elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) {
$groups['template'][] = $repo; $groups['template'][] = $repo;
} else { } else {
$groups['internal'][] = $repo; $groups['internal'][] = $repo;
} }
} }
$updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T'); $updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T');
$lines = [ $lines = [
"> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.", "> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.",
'> Do not edit this section manually; it is overwritten on every bulk sync.', '> Do not edit this section manually; it is overwritten on every bulk sync.',
'', '',
]; ];
$groupLabels = [ $groupLabels = [
'core' => 'Core Repositories', 'core' => 'Core Repositories',
'product' => 'Product Repositories', 'product' => 'Product Repositories',
'extension' => 'Extension Repositories (Dolibarr / CRM)', 'extension' => 'Extension Repositories (Dolibarr / CRM)',
'template' => 'Template Repositories', 'template' => 'Template Repositories',
'internal' => 'Internal and Testing', 'internal' => 'Internal and Testing',
'archived' => 'Archived Repositories', 'archived' => 'Archived Repositories',
]; ];
foreach ($groupLabels as $key => $label) { foreach ($groupLabels as $key => $label) {
if (empty($groups[$key])) { if (empty($groups[$key])) {
continue; continue;
} }
$lines[] = "### {$label}"; $lines[] = "### {$label}";
$lines[] = ''; $lines[] = '';
$isExt = ($key === 'extension'); $isExt = ($key === 'extension');
if ($isExt) { if ($isExt) {
$lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |'; $lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |';
$lines[] = '|------------|--------|-------------|-----------|----------|------------|'; $lines[] = '|------------|--------|-------------|-----------|----------|------------|';
} else { } else {
$lines[] = '| Repository | Status | Description | Language | Visibility |'; $lines[] = '| Repository | Status | Description | Language | Visibility |';
$lines[] = '|------------|--------|-------------|----------|------------|'; $lines[] = '|------------|--------|-------------|----------|------------|';
} }
foreach ($groups[$key] as $repo) { foreach ($groups[$key] as $repo) {
$name = (string) ($repo['name'] ?? ''); $name = (string) ($repo['name'] ?? '');
$desc = str_replace('|', '\\|', (string) ($repo['description'] ?? '')); $desc = str_replace('|', '\\|', (string) ($repo['description'] ?? ''));
$url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}"); $url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}");
$lang = (string) ($repo['language'] ?? '—'); $lang = (string) ($repo['language'] ?? '—');
$private = (bool) ($repo['private'] ?? false); $private = (bool) ($repo['private'] ?? false);
$archived = (bool) ($repo['archived'] ?? false); $archived = (bool) ($repo['archived'] ?? false);
$status = $archived ? '🗄 Archived' : '✅ Active'; $status = $archived ? '🗄 Archived' : '✅ Active';
$vis = $private ? 'Private' : 'Public'; $vis = $private ? 'Private' : 'Public';
$modId = $moduleMap[strtolower($name)] ?? null; $modId = $moduleMap[strtolower($name)] ?? null;
$modCell = $modId !== null ? (string) $modId : '—'; $modCell = $modId !== null ? (string) $modId : '—';
if ($isExt) { if ($isExt) {
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |"; $lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |";
} else { } else {
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |"; $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. * Replace content between the start/end markers in the inventory file.
* If markers are absent, appends a new section at the end. * If markers are absent, appends a new section at the end.
*/ */
private function replaceSection(string $original, string $newContent): string private function replaceSection(string $original, string $newContent): string
{ {
$startPos = strpos($original, self::MARKER_START); $startPos = strpos($original, self::MARKER_START);
$endPos = strpos($original, self::MARKER_END); $endPos = strpos($original, self::MARKER_END);
if ($startPos === false || $endPos === false) { if ($startPos === false || $endPos === false) {
$this->warning('Inventory markers not found; appending section to end of file.'); $this->warning('Inventory markers not found; appending section to end of file.');
return $original return $original
. "\n\n## Active Repositories\n\n" . "\n\n## Active Repositories\n\n"
. self::MARKER_START . "\n" . self::MARKER_START . "\n"
. $newContent . "\n" . $newContent . "\n"
. self::MARKER_END . "\n"; . self::MARKER_END . "\n";
} }
$before = substr($original, 0, $startPos + strlen(self::MARKER_START)); $before = substr($original, 0, $startPos + strlen(self::MARKER_START));
$after = substr($original, $endPos); $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'); $script = new UpdateRepoInventory('update_repo_inventory', 'Updates the repository inventory documentation after sync');
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/** /**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> * Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
+394 -391
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* *
* This file is part of a Moko Consulting project. * This file is part of a Moko Consulting project.
@@ -36,449 +37,451 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework};
*/ */
class UpdateVersionFromReadme extends CliFramework class UpdateVersionFromReadme extends CliFramework
{ {
private AuditLogger $logger; private AuditLogger $logger;
private ?ApiClient $apiClient = null; private ?ApiClient $apiClient = null;
/** Files updated during this run */ /** Files updated during this run */
private array $updatedFiles = []; private array $updatedFiles = [];
/** Errors encountered during this run */ /** Errors encountered during this run */
private array $errors = []; private array $errors = [];
protected function configure(): void protected function configure(): void
{ {
$this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers'); $this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers');
$this->addArgument('--path', 'Repository root path', '.'); $this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--dry-run', 'Preview changes without writing', false); $this->addArgument('--dry-run', 'Preview changes without writing', false);
$this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false); $this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false);
$this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', ''); $this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', '');
} }
protected function initialize(): void protected function initialize(): void
{ {
parent::initialize(); parent::initialize();
$this->logger = new AuditLogger('update_version_from_readme'); $this->logger = new AuditLogger('update_version_from_readme');
} }
protected function run(): int protected function run(): int
{ {
$repoRoot = rtrim((string) $this->getArgument('--path'), '/'); $repoRoot = rtrim((string) $this->getArgument('--path'), '/');
$dryRun = (bool) $this->getArgument('--dry-run'); $dryRun = (bool) $this->getArgument('--dry-run');
$createIssue = (bool) $this->getArgument('--create-issue'); $createIssue = (bool) $this->getArgument('--create-issue');
$repo = (string) $this->getArgument('--repo'); $repo = (string) $this->getArgument('--repo');
$readmePath = $repoRoot . '/README.md'; $readmePath = $repoRoot . '/README.md';
if (!file_exists($readmePath)) { if (!file_exists($readmePath)) {
$this->error("README.md not found at {$readmePath}"); $this->error("README.md not found at {$readmePath}");
return 1; return 1;
} }
// ── 1. Extract version from README.md ──────────────────────────── // ── 1. Extract version from README.md ────────────────────────────
$version = $this->extractVersionFromReadme($readmePath); $version = $this->extractVersionFromReadme($readmePath);
if ($version === null) { if ($version === null) {
$this->error("Could not find VERSION field in README.md FILE INFORMATION block"); $this->error("Could not find VERSION field in README.md FILE INFORMATION block");
return 1; return 1;
} }
$this->log("✅ README.md version: {$version}"); $this->log("✅ README.md version: {$version}");
if ($dryRun) { if ($dryRun) {
$this->log("🔍 DRY RUN — no files will be written"); $this->log("🔍 DRY RUN — no files will be written");
} }
// ── 2. Scan and update every tracked file ──────────────────────── // ── 2. Scan and update every tracked file ────────────────────────
$this->processFiles($repoRoot, $version, $dryRun); $this->processFiles($repoRoot, $version, $dryRun);
// ── 3. Update composer.json ────────────────────────────────────── // ── 3. Update composer.json ──────────────────────────────────────
$this->updateComposerJson($repoRoot, $version, $dryRun); $this->updateComposerJson($repoRoot, $version, $dryRun);
// ── 4. Summary ─────────────────────────────────────────────────── // ── 4. Summary ───────────────────────────────────────────────────
$count = count($this->updatedFiles); $count = count($this->updatedFiles);
if ($dryRun) { if ($dryRun) {
$this->log("🔍 DRY RUN complete — {$count} file(s) would be updated"); $this->log("🔍 DRY RUN complete — {$count} file(s) would be updated");
} else { } else {
$this->log("✅ Updated {$count} file(s) to version {$version}"); $this->log("✅ Updated {$count} file(s) to version {$version}");
} }
foreach ($this->updatedFiles as $f) { foreach ($this->updatedFiles as $f) {
$this->log("{$f}"); $this->log("{$f}");
} }
// ── 5. Create issue if mismatches remain (non-dry-run only) ────── // ── 5. Create issue if mismatches remain (non-dry-run only) ──────
if (!$dryRun && $createIssue && !empty($repo)) { if (!$dryRun && $createIssue && !empty($repo)) {
$remaining = $this->countRemainingMismatches($repoRoot, $version); $remaining = $this->countRemainingMismatches($repoRoot, $version);
if ($remaining > 0) { if ($remaining > 0) {
$this->log("{$remaining} version reference(s) could not be auto-updated"); $this->log("{$remaining} version reference(s) could not be auto-updated");
$this->createDriftIssue($repo, $version, $remaining); $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. * Extract the VERSION value from the FILE INFORMATION block in README.md.
* *
* Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms. * Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms.
* *
* @param string $path Full path to README.md * @param string $path Full path to README.md
* @return string|null Version string (e.g. "04.00.04"), or null if not found * @return string|null Version string (e.g. "04.00.04"), or null if not found
*/ */
private function extractVersionFromReadme(string $path): ?string private function extractVersionFromReadme(string $path): ?string
{ {
$content = file_get_contents($path); $content = file_get_contents($path);
if ($content === false) { if ($content === false) {
return null; return null;
} }
// Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab // 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)) { if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) {
return $m[1]; return $m[1];
} }
return null; return null;
} }
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// File processing // File processing
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
/** /**
* Walk the repository tree and update every eligible file. * Walk the repository tree and update every eligible file.
* *
* @param string $repoRoot Absolute path to repository root * @param string $repoRoot Absolute path to repository root
* @param string $version Target version string * @param string $version Target version string
* @param bool $dryRun If true, compute but do not write changes * @param bool $dryRun If true, compute but do not write changes
*/ */
private function processFiles(string $repoRoot, string $version, bool $dryRun): void private function processFiles(string $repoRoot, string $version, bool $dryRun): void
{ {
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf']; $extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf'];
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs']; $excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
$iterator = new RecursiveIteratorIterator( $iterator = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator( new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator( new RecursiveDirectoryIterator(
$repoRoot, $repoRoot,
RecursiveDirectoryIterator::SKIP_DOTS RecursiveDirectoryIterator::SKIP_DOTS
), ),
function (\SplFileInfo $fi) use ($excludeDirs): bool { function (\SplFileInfo $fi) use ($excludeDirs): bool {
if ($fi->isDir()) { if ($fi->isDir()) {
return !in_array($fi->getFilename(), $excludeDirs, true); return !in_array($fi->getFilename(), $excludeDirs, true);
} }
return true; return true;
} }
) )
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
/** @var \SplFileInfo $file */ /** @var \SplFileInfo $file */
if (!$file->isFile()) { if (!$file->isFile()) {
continue; continue;
} }
$ext = strtolower($file->getExtension()); $ext = strtolower($file->getExtension());
// Strip .template suffix for extension matching // Strip .template suffix for extension matching
if ($ext === 'template') { if ($ext === 'template') {
$inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION)); $inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
if (in_array($inner, $extensions, true)) { if (in_array($inner, $extensions, true)) {
$ext = $inner; $ext = $inner;
} else { } else {
continue; continue;
} }
} elseif (!in_array($ext, $extensions, true)) { } elseif (!in_array($ext, $extensions, true)) {
continue; continue;
} }
$this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext); $this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext);
} }
} }
/** /**
* Apply version replacements to a single file. * Apply version replacements to a single file.
* *
* @param string $path Absolute file path * @param string $path Absolute file path
* @param string $repoRoot Repository root (for display) * @param string $repoRoot Repository root (for display)
* @param string $version Target version * @param string $version Target version
* @param bool $dryRun If true, do not write * @param bool $dryRun If true, do not write
* @param string $ext Canonical extension (without .template) * @param string $ext Canonical extension (without .template)
*/ */
private function processFile( private function processFile(
string $path, string $path,
string $repoRoot, string $repoRoot,
string $version, string $version,
bool $dryRun, bool $dryRun,
string $ext string $ext
): void { ): void {
$original = file_get_contents($path); $original = file_get_contents($path);
if ($original === false) { if ($original === false) {
$this->errors[] = "Cannot read: {$path}"; $this->errors[] = "Cannot read: {$path}";
return; return;
} }
$updated = $original; $updated = $original;
// ── Badge replacement (all file types) ─────────────────────────── // ── Badge replacement (all file types) ───────────────────────────
// shields.io badge: [![moko-platform](...badge/moko--platform-XX.YY.ZZ-color)] // shields.io badge: [![moko-platform](...badge/moko--platform-XX.YY.ZZ-color)]
$updated = preg_replace( $updated = preg_replace(
'/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/', '/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$updated $updated
); );
// Plain text version badge: [VERSION: XX.YY.ZZ] // Plain text version badge: [VERSION: XX.YY.ZZ]
$updated = preg_replace( $updated = preg_replace(
'/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/', '/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/',
'[VERSION: ' . $version . ']', '[VERSION: ' . $version . ']',
$updated $updated
); );
// ── FILE INFORMATION VERSION replacement ────────────────────────── // ── FILE INFORMATION VERSION replacement ──────────────────────────
// Markdown inside <!-- -->: VERSION: OLD or <tab>VERSION: OLD // Markdown inside <!-- -->: VERSION: OLD or <tab>VERSION: OLD
if ($ext === 'md') { if ($ext === 'md') {
$updated = preg_replace( $updated = preg_replace(
'/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', '/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$updated $updated
); );
} }
// PHP inside /** */ or /* */: * VERSION: OLD // PHP inside /** */ or /* */: * VERSION: OLD
if ($ext === 'php') { if ($ext === 'php') {
$updated = preg_replace( $updated = preg_replace(
'/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', '/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$updated $updated
); );
// PHP class VERSION constants: // PHP class VERSION constants:
// private const VERSION = '09.22.00'; // private const VERSION = '09.22.00';
// public const VERSION = '09.22.00'; // public const VERSION = '09.22.00';
// private const VERSION = '09.22.00'; // private const VERSION = '09.22.00';
$updated = preg_replace( $updated = preg_replace(
'/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/', '/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$updated $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 // YAML / Shell / PowerShell / Python: # VERSION: OLD
if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) { if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) {
$updated = preg_replace( $updated = preg_replace(
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', '/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$updated $updated
); );
} }
// Terraform (.tf / .tf.template) — three locations: // Terraform (.tf / .tf.template) — three locations:
// 1. # VERSION: OLD (hash-comment header, template-style files) // 1. # VERSION: OLD (hash-comment header, template-style files)
// 2. * Version: OLD (block-comment header, definition files) // 2. * Version: OLD (block-comment header, definition files)
// 3. version = "OLD" (HCL metadata field) // 3. version = "OLD" (HCL metadata field)
if ($ext === 'tf') { if ($ext === 'tf') {
$updated = preg_replace( $updated = preg_replace(
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', '/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$updated $updated
); );
$updated = preg_replace( $updated = preg_replace(
'/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m', '/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$updated $updated
); );
$updated = preg_replace( $updated = preg_replace(
'/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m', '/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$updated $updated
); );
} }
if ($updated === $original) { if ($updated === $original) {
return; // Nothing to change return; // Nothing to change
} }
$rel = ltrim(str_replace($repoRoot, '', $path), '/'); $rel = ltrim(str_replace($repoRoot, '', $path), '/');
if (!$dryRun) { if (!$dryRun) {
if (file_put_contents($path, $updated) === false) { if (file_put_contents($path, $updated) === false) {
$this->errors[] = "Cannot write: {$path}"; $this->errors[] = "Cannot write: {$path}";
return; return;
} }
} }
$this->updatedFiles[] = $rel; $this->updatedFiles[] = $rel;
} }
/** /**
* Update the "version" key in composer.json if it exists. * Update the "version" key in composer.json if it exists.
* *
* @param string $repoRoot Repository root * @param string $repoRoot Repository root
* @param string $version Target version * @param string $version Target version
* @param bool $dryRun If true, do not write * @param bool $dryRun If true, do not write
*/ */
private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void
{ {
$path = $repoRoot . '/composer.json'; $path = $repoRoot . '/composer.json';
if (!file_exists($path)) { if (!file_exists($path)) {
return; return;
} }
$content = file_get_contents($path); $content = file_get_contents($path);
if ($content === false) { if ($content === false) {
return; return;
} }
$updated = preg_replace( $updated = preg_replace(
'/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m', '/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m',
'${1}' . $version . '${2}', '${1}' . $version . '${2}',
$content $content
); );
if ($updated === $content) { if ($updated === $content) {
return; return;
} }
if (!$dryRun) { if (!$dryRun) {
file_put_contents($path, $updated); 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. * Count FILE INFORMATION VERSION lines that still differ from $version.
* *
* @param string $repoRoot Repository root * @param string $repoRoot Repository root
* @param string $version Expected version * @param string $version Expected version
* @return int Number of remaining mismatches * @return int Number of remaining mismatches
*/ */
private function countRemainingMismatches(string $repoRoot, string $version): int private function countRemainingMismatches(string $repoRoot, string $version): int
{ {
$escaped = preg_quote($version, '/'); $escaped = preg_quote($version, '/');
$count = 0; $count = 0;
$versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/'; $versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/';
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf']; $extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf'];
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs']; $excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
$iterator = new RecursiveIteratorIterator( $iterator = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator( new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS), new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
function (\SplFileInfo $fi) use ($excludeDirs): bool { function (\SplFileInfo $fi) use ($excludeDirs): bool {
return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true)); return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true));
} }
) )
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
/** @var \SplFileInfo $file */ /** @var \SplFileInfo $file */
if (!$file->isFile()) { if (!$file->isFile()) {
continue; continue;
} }
$ext = strtolower($file->getExtension()); $ext = strtolower($file->getExtension());
if ($ext === 'template') { if ($ext === 'template') {
$ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
} }
if (!in_array($ext, $extensions, true)) { if (!in_array($ext, $extensions, true)) {
continue; continue;
} }
$content = file_get_contents($file->getPathname()); $content = file_get_contents($file->getPathname());
if ($content !== false && preg_match($versionRe, $content)) { if ($content !== false && preg_match($versionRe, $content)) {
$count++; $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. * Create or update a GitHub issue listing files that could not be auto-updated.
* *
* @param string $repo owner/repo * @param string $repo owner/repo
* @param string $version Expected version * @param string $version Expected version
* @param int $remaining Number of remaining mismatches * @param int $remaining Number of remaining mismatches
*/ */
private function createDriftIssue(string $repo, string $version, int $remaining): void private function createDriftIssue(string $repo, string $version, int $remaining): void
{ {
if (!isset($this->apiClient)) { if (!isset($this->apiClient)) {
$config = \MokoEnterprise\Config::load(); $config = \MokoEnterprise\Config::load();
try { try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config); $adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->apiClient = $adapter->getApiClient(); $this->apiClient = $adapter->getApiClient();
} catch (\Exception $e) { } catch (\Exception $e) {
$this->error('Platform initialization failed: ' . $e->getMessage()); $this->error('Platform initialization failed: ' . $e->getMessage());
return; return;
} }
} }
$title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}"; $title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}";
$labels = ['version-drift', 'maintenance', 'type: chore', 'automation']; $labels = ['version-drift', 'maintenance', 'type: chore', 'automation'];
$body = implode("\n", [ $body = implode("\n", [
"## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated", "## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated",
"", "",
"**Target version:** `{$version}` (from README.md)", "**Target version:** `{$version}` (from README.md)",
"", "",
"After the automatic version propagation run, **{$remaining}** file(s) still contain", "After the automatic version propagation run, **{$remaining}** file(s) still contain",
"a VERSION field that does not match the README.md version.", "a VERSION field that does not match the README.md version.",
"", "",
"### How to fix", "### How to fix",
"", "",
"1. Run the sync script locally:", "1. Run the sync script locally:",
" ```bash", " ```bash",
" php maintenance/update_version_from_readme.php --path . --dry-run", " php maintenance/update_version_from_readme.php --path . --dry-run",
" php maintenance/update_version_from_readme.php --path .", " php maintenance/update_version_from_readme.php --path .",
" ```", " ```",
"2. Inspect any files still flagged — they may use a non-standard VERSION format.", "2. Inspect any files still flagged — they may use a non-standard VERSION format.",
"3. Update them manually to match `VERSION: {$version}`.", "3. Update them manually to match `VERSION: {$version}`.",
"4. Commit and push — this issue will be closed automatically on the next successful sync.", "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)*", "*Automatically created by [update_version_from_readme.php](maintenance/update_version_from_readme.php)*",
]); ]);
try { try {
// Check for an existing version-drift issue to avoid duplicates // Check for an existing version-drift issue to avoid duplicates
$existing = $this->apiClient->get("/repos/{$repo}/issues", [ $existing = $this->apiClient->get("/repos/{$repo}/issues", [
'labels' => 'version-drift', 'labels' => 'version-drift',
'state' => 'all', 'state' => 'all',
'per_page' => 1, 'per_page' => 1,
'sort' => 'created', 'sort' => 'created',
'direction' => 'desc', 'direction' => 'desc',
]); ]);
if (!empty($existing[0]['number'])) { if (!empty($existing[0]['number'])) {
$num = (int) $existing[0]['number']; $num = (int) $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') { if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open'; $patch['state'] = 'open';
} }
$this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch); $this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch);
try { try {
$this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]); $this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
} catch (\Exception $le) { /* non-fatal */ } } catch (\Exception $le) {
$this->log("✅ Updated issue #{$num} in {$repo}"); /* non-fatal */
} else { }
$issue = $this->apiClient->post("/repos/{$repo}/issues", [ $this->log("✅ Updated issue #{$num} in {$repo}");
'title' => $title, } else {
'body' => $body, $issue = $this->apiClient->post("/repos/{$repo}/issues", [
'labels' => $labels, 'title' => $title,
'assignees' => ['jmiller'], 'body' => $body,
]); 'labels' => $labels,
$this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}"); 'assignees' => ['jmiller'],
} ]);
} catch (\Exception $e) { $this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}");
$this->error('Failed to create/update issue: ' . $e->getMessage()); }
} } catch (\Exception $e) {
} $this->error('Failed to create/update issue: ' . $e->getMessage());
}
}
} }
$script = new UpdateVersionFromReadme(); $script = new UpdateVersionFromReadme();