style: fix PHPCS violations across migrated CLI scripts
Generic: Repo Health / Access control (push) Successful in 18s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 27s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 28s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 1m7s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 1m7s
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
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
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

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'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? MokoStandardsParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
+12 -2
View File
@@ -111,7 +111,9 @@ class EnrichMokostandardsXmlCli extends CliFramework
if (!isset($enrichment['build'])) {
$enrichment['build'] = [];
}
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
$enrichment['build']['language'] = $enrichment['build']['language']
?? $repo['language']
?? MokoStandardsParser::platformLanguage($platform);
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
$enrichedXml = $this->enrichManifestXml($existingXml, $enrichment);
@@ -131,7 +133,15 @@ class EnrichMokostandardsXmlCli extends CliFramework
$this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
$this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml');
[$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}");
$commitMsg = "chore: enrich .mokostandards"
. " with build/deploy/scripts\n\n"
. "Auto-detected: {$details}";
[$cr, $co] = $this->gitCmd(
$workDir,
'commit',
'-m',
$commitMsg
);
if ($cr !== 0) {
echo "SKIP (no diff)\n";
$stats['skipped']++;
+8 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -136,7 +137,13 @@ class ArchiveRepoCli extends CliFramework
$org,
'moko-platform',
"chore: archived repository {$repoName}",
"## Repository Archived\n\n**Repository:** `{$org}/{$repoName}`\n**Archived:** {$now}\n**Platform:** {$platformName}\n**Sync definition removed:** yes\n\n---\n*Auto-created by `archive_repo.php`*\n",
"## Repository Archived\n\n"
. "**Repository:** `{$org}/{$repoName}`\n"
. "**Archived:** {$now}\n"
. "**Platform:** {$platformName}\n"
. "**Sync definition removed:** yes\n\n"
. "---\n"
. "*Auto-created by `archive_repo.php`*\n",
[
'labels' => ['type: chore', 'automation', 'archived'],
'assignees' => ['jmiller'],
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+7 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -127,7 +128,12 @@ class ClientInventoryCli extends CliFramework
$this->log('INFO', '');
$this->log('INFO', sprintf(
'%-20s | %-35s | %-10s | %-11s | %-19s | %s',
'Org', 'Repo', 'Dev Config', 'Live Config', 'Last Push', 'Status'
'Org',
'Repo',
'Dev Config',
'Live Config',
'Last Push',
'Status'
));
$this->log('INFO', str_repeat('-', 120));
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+9 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -282,7 +283,10 @@ class CreateProjectCli extends CliFramework
$result['name'] = $m[1];
}
if (preg_match_all('/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"\s*description\s*=\s*"([^"]+)"(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s', $content, $matches, PREG_SET_ORDER)) {
$fieldPattern = '/\{\s*name\s*=\s*"([^"]+)"\s*type\s*=\s*"([^"]+)"'
. '\s*description\s*=\s*"([^"]+)"'
. '(?:\s*options\s*=\s*\[([^\]]*)\])?\s*\}/s';
if (preg_match_all($fieldPattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$field = [
'name' => $match[1],
@@ -376,7 +380,10 @@ class CreateProjectCli extends CliFramework
$vars['singleSelectOptions'] = $optionInputs;
$this->graphql(
'mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $singleSelectOptions: [ProjectV2SingleSelectFieldOptionInput!]) {
'mutation($projectId: ID!, $name: String!,'
. ' $dataType: ProjectV2CustomFieldType!,'
. ' $singleSelectOptions:'
. ' [ProjectV2SingleSelectFieldOptionInput!]) {
createProjectV2Field(input: {
projectId: $projectId,
dataType: $dataType,
+187 -61
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -25,76 +26,201 @@ use MokoEnterprise\PlatformAdapterFactory;
class CreateRepoCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Scaffold a new governed repository with full moko-platform baseline');
$this->addArgument('--name', 'Repository name', null);
$this->addArgument('--type', 'Project type', null);
$this->addArgument('--description', 'Repository description', '');
$this->addArgument('--private', 'Create as private', false);
}
protected function configure(): void
{
$this->setDescription('Scaffold a new governed repository with full moko-platform baseline');
$this->addArgument('--name', 'Repository name', null);
$this->addArgument('--type', 'Project type', null);
$this->addArgument('--description', 'Repository description', '');
$this->addArgument('--private', 'Create as private', false);
}
protected function run(): int
{
$name = $this->getArgument('--name'); $type = $this->getArgument('--type');
$description = $this->getArgument('--description'); $private = (bool) $this->getArgument('--private');
if (!$name || !$type) { $this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]"); return 2; }
$config = Config::load(); $adapter = PlatformAdapterFactory::create($config);
$org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
$repoRoot = dirname(__DIR__, 2);
$TYPE_TO_PLATFORM = ['dolibarr' => 'crm-module', 'dolibarr-platform' => 'crm-platform', 'joomla' => 'waas-component', 'nodejs' => 'nodejs', 'terraform' => 'terraform', 'python' => 'python', 'wordpress' => 'wordpress', 'generic' => 'generic'];
$TYPE_TO_TOPICS = ['dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'], 'joomla' => ['joomla', 'cms', 'php', 'mokostandards'], 'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'], 'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'], 'python' => ['python', 'mokostandards'], 'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'], 'generic' => ['mokostandards']];
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic'; $topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards'];
$platformName = $adapter->getPlatformName();
echo "Scaffolding new repository: {$org}/{$name} (on {$platformName})\n Type: {$type} (platform: {$platform})\n Visibility: " . ($private ? 'private' : 'public') . "\n";
if ($description) { echo " Description: {$description}\n"; } echo "\n";
protected function run(): int
{
$name = $this->getArgument('--name');
$type = $this->getArgument('--type');
$description = $this->getArgument('--description');
$private = (bool) $this->getArgument('--private');
if (!$name || !$type) {
$this->log('ERROR', "Usage: php create_repo.php --name <RepoName> --type <type> [--description \"...\"] [--private] [--dry-run]");
return 2;
}
$config = Config::load();
$adapter = PlatformAdapterFactory::create($config);
$org = $config->getString($adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
$repoRoot = dirname(__DIR__, 2);
$TYPE_TO_PLATFORM = [
'dolibarr' => 'crm-module',
'dolibarr-platform' => 'crm-platform',
'joomla' => 'waas-component',
'nodejs' => 'nodejs',
'terraform' => 'terraform',
'python' => 'python',
'wordpress' => 'wordpress',
'generic' => 'generic',
];
$TYPE_TO_TOPICS = [
'dolibarr' => ['dolibarr', 'erp', 'crm', 'php', 'mokostandards'],
'joomla' => ['joomla', 'cms', 'php', 'mokostandards'],
'nodejs' => ['nodejs', 'javascript', 'typescript', 'mokostandards'],
'terraform' => ['terraform', 'infrastructure', 'iac', 'mokostandards'],
'python' => ['python', 'mokostandards'],
'wordpress' => ['wordpress', 'php', 'cms', 'mokostandards'],
'generic' => ['mokostandards'],
];
$platform = $TYPE_TO_PLATFORM[$type] ?? 'generic';
$topics = $TYPE_TO_TOPICS[$type] ?? ['mokostandards'];
$platformName = $adapter->getPlatformName();
$vis = $private ? 'private' : 'public';
echo "Scaffolding new repository: {$org}/{$name}"
. " (on {$platformName})\n"
. " Type: {$type} (platform: {$platform})\n"
. " Visibility: {$vis}\n";
if ($description) {
echo " Description: {$description}\n";
} echo "\n";
echo "Step 1: Creating repository...\n";
if (!$this->dryRun) {
try {
$data = $adapter->createOrgRepo($org, $name, ['description' => $description ?: "Managed by moko-platform ({$type})", 'private' => $private, 'has_issues' => true, 'has_projects' => true, 'has_wiki' => false, 'auto_init' => true, 'delete_branch_on_merge' => true, 'allow_squash_merge' => true, 'allow_merge_commit' => false, 'allow_rebase_merge' => false]);
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
} catch (\Exception $e) {
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) { echo " Repository already exists -- continuing with setup\n"; }
else { $this->log('ERROR', "Failed to create repo: " . $e->getMessage()); return 1; }
}
} else { echo " (dry-run) would create {$org}/{$name}\n"; }
echo "Step 1: Creating repository...\n";
if (!$this->dryRun) {
try {
$data = $adapter->createOrgRepo($org, $name, [
'description' => $description ?: "Managed by moko-platform ({$type})",
'private' => $private,
'has_issues' => true,
'has_projects' => true,
'has_wiki' => false,
'auto_init' => true,
'delete_branch_on_merge' => true,
'allow_squash_merge' => true,
'allow_merge_commit' => false,
'allow_rebase_merge' => false,
]);
echo " Created: " . ($data['html_url'] ?? "{$org}/{$name}") . "\n";
} catch (\Exception $e) {
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
echo " Repository already exists -- continuing with setup\n";
} else {
$this->log('ERROR', "Failed to create repo: " . $e->getMessage());
return 1;
}
}
} else {
echo " (dry-run) would create {$org}/{$name}\n";
}
echo "Step 2: Setting topics...\n";
if (!$this->dryRun) { $adapter->setRepoTopics($org, $name, $topics); echo " Topics: " . implode(', ', $topics) . "\n"; }
else { echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n"; }
echo "Step 2: Setting topics...\n";
if (!$this->dryRun) {
$adapter->setRepoTopics($org, $name, $topics);
echo " Topics: " . implode(', ', $topics) . "\n";
} else {
echo " (dry-run) would set topics: " . implode(', ', $topics) . "\n";
}
echo "Step 3: Creating .github/.mokostandards...\n";
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
if (!$this->dryRun) { try { $adapter->createOrUpdateFile($org, $name, '.github/.mokostandards', $mokoContent, 'chore: add .mokostandards platform config [skip ci]'); echo " .mokostandards created\n"; } catch (\Exception $e) { echo " Warning: " . $e->getMessage() . "\n"; } }
else { echo " (dry-run) would create .github/.mokostandards\n"; }
echo "Step 3: Creating .github/.mokostandards...\n";
$mokoContent = "platform: {$platform}\nversion: 04.02.30\nmanaged: true\n";
if (!$this->dryRun) {
try {
$adapter->createOrUpdateFile(
$org,
$name,
'.github/.mokostandards',
$mokoContent,
'chore: add .mokostandards platform config [skip ci]'
);
echo " .mokostandards created\n";
} catch (\Exception $e) {
echo " Warning: " . $e->getMessage() . "\n";
}
} else {
echo " (dry-run) would create .github/.mokostandards\n";
}
echo "Step 4: Creating README.md...\n";
$baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com';
$repoUrl = "{$baseUrl}/{$org}/{$name}"; $standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
$readmeContent = "<!--\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";
if (!$this->dryRun) {
$sha = null;
try { $existing = $adapter->getFileContents($org, $name, 'README.md'); $sha = $existing['sha'] ?? null; } catch (\Exception $e) { $adapter->getApiClient()->resetCircuitBreaker(); }
$adapter->createOrUpdateFile($org, $name, 'README.md', $readmeContent, 'docs: initialize README with moko-platform header [skip ci]', $sha);
echo " README.md created\n";
} else { echo " (dry-run) would create README.md\n"; }
echo "Step 4: Creating README.md...\n";
$baseUrl = $platformName === 'gitea' ? $config->getString('gitea.url', 'https://git.mokoconsulting.tech') : 'https://github.com';
$repoUrl = "{$baseUrl}/{$org}/{$name}";
$standardsUrl = "{$baseUrl}/{$org}/MokoStandards";
$readmeContent = "<!--\n"
. "Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>\n"
. "SPDX-License-Identifier: GPL-3.0-or-later\n"
. "DEFGROUP: {$name}\n"
. "INGROUP: moko-platform\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";
if (!$this->dryRun) { $labelScript = "{$repoRoot}/api/maintenance/setup_labels.php"; if (file_exists($labelScript)) { $exitCode = 0; passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode); } else { echo " Labels will be provisioned on next sync\n"; } }
else { echo " (dry-run) would provision standard labels\n"; }
echo "Step 5: Provisioning labels...\n";
if (!$this->dryRun) {
$labelScript = "{$repoRoot}/api/maintenance/setup_labels.php";
if (file_exists($labelScript)) {
$exitCode = 0;
passthru("php " . escapeshellarg($labelScript) . " --org " . escapeshellarg($org) . " --repo " . escapeshellarg($name), $exitCode);
} else {
echo " Labels will be provisioned on next sync\n";
}
} else {
echo " (dry-run) would provision standard labels\n";
}
echo "Step 6: Running initial sync...\n";
if (!$this->dryRun) { $syncScript = "{$repoRoot}/api/automation/bulk_sync.php"; if (file_exists($syncScript)) { passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes"); } else { echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n"; } }
else { echo " (dry-run) would run initial sync\n"; }
echo "Step 6: Running initial sync...\n";
if (!$this->dryRun) {
$syncScript = "{$repoRoot}/api/automation/bulk_sync.php";
if (file_exists($syncScript)) {
passthru("php " . escapeshellarg($syncScript) . " --repos " . escapeshellarg($name) . " --force --yes");
} else {
echo " Run manually: php automation/bulk_sync.php --repos {$name} --force --yes\n";
}
} else {
echo " (dry-run) would run initial sync\n";
}
echo "Step 7: Creating Project...\n";
if (!$this->dryRun) { $projectScript = "{$repoRoot}/api/cli/create_project.php"; if (file_exists($projectScript)) { passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type)); } else { echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n"; } }
else { echo " (dry-run) would create Project\n"; }
echo "Step 7: Creating Project...\n";
if (!$this->dryRun) {
$projectScript = "{$repoRoot}/api/cli/create_project.php";
if (file_exists($projectScript)) {
passthru("php " . escapeshellarg($projectScript) . " --repo " . escapeshellarg($name) . " --type " . escapeshellarg($type));
} else {
echo " Run manually: php cli/create_project.php --repo {$name} --type {$type}\n";
}
} else {
echo " (dry-run) would create Project\n";
}
echo "\n" . str_repeat('-', 50) . "\nRepository {$org}/{$name} scaffolded successfully\n URL: {$repoUrl}\n Platform: {$platform} ({$platformName})\n Next: verify the sync and merge any PRs\n";
return 0;
}
echo "\n" . str_repeat('-', 50) . "\n"
. "Repository {$org}/{$name} scaffolded successfully\n"
. " URL: {$repoUrl}\n"
. " Platform: {$platform} ({$platformName})\n"
. " Next: verify the sync and merge any PRs\n";
return 0;
}
}
$app = new CreateRepoCli();
+4 -1
View File
@@ -233,7 +233,10 @@ class DeployJoomla extends CliFramework
/**
* Parse extension metadata from the Joomla XML manifest.
*
* @return array{type:string, element:string, client:string, group:string, name:string, shortName:string, version:string, subExtensions:list<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
{
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+59 -19
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -50,7 +51,10 @@ class JoomlaBuildCli extends CliFramework
// ── Find source directory ──────────────────────────────────────────────
$srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$path}/{$d}")) { $srcDir = "{$path}/{$d}"; break; }
if (is_dir("{$path}/{$d}")) {
$srcDir = "{$path}/{$d}";
break;
}
}
if ($srcDir === null) {
$this->log('ERROR', "::error::No src/ or htdocs/ directory in {$path}");
@@ -72,7 +76,9 @@ class JoomlaBuildCli extends CliFramework
// Resolve language-key names (e.g. PLG_SYSTEM_MOKOWAAS -> "System - Moko WaaS")
if (preg_match('/^[A-Z_]+$/', $meta['name'])) {
$resolved = $this->resolveLanguageKey($srcDir, $meta['name']);
if ($resolved !== null) { $meta['name'] = $resolved; }
if ($resolved !== null) {
$meta['name'] = $resolved;
}
}
$prefix = $this->typePrefix($meta);
@@ -87,7 +93,9 @@ class JoomlaBuildCli extends CliFramework
$this->log('INFO', " Output: {$zipName}");
// ── Build ──────────────────────────────────────────────────────────────
if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); }
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
if ($meta['type'] === 'package') {
$this->buildPackageZip($srcDir, $zipPath);
@@ -114,11 +122,15 @@ class JoomlaBuildCli extends CliFramework
if ($ghOutput && ($ghFile = getenv('GITHUB_OUTPUT')) !== false && $ghFile !== '') {
$fh = fopen($ghFile, 'a');
foreach ($vars as $k => $v) { fwrite($fh, "{$k}={$v}\n"); }
foreach ($vars as $k => $v) {
fwrite($fh, "{$k}={$v}\n");
}
fclose($fh);
$this->log('INFO', "Wrote " . count($vars) . " outputs to GITHUB_OUTPUT");
} else {
foreach ($vars as $k => $v) { echo "{$k}={$v}\n"; }
foreach ($vars as $k => $v) {
echo "{$k}={$v}\n";
}
}
return 0;
@@ -131,9 +143,13 @@ class JoomlaBuildCli extends CliFramework
private function findManifest(string $dir): ?string
{
// Priority: pkg_*.xml (packages), then any *.xml with <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) {
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
$iter = new RecursiveIteratorIterator(
@@ -167,8 +183,12 @@ class JoomlaBuildCli extends CliFramework
}
// Fallback element detection
if ($element === '') { $element = (string) ($xml->attributes()->plugin ?? ''); }
if ($element === '') { $element = (string) ($xml->attributes()->module ?? ''); }
if ($element === '') {
$element = (string) ($xml->attributes()->plugin ?? '');
}
if ($element === '') {
$element = (string) ($xml->attributes()->module ?? '');
}
if ($element === '') {
$element = strtolower(basename($file, '.xml'));
if (in_array($element, ['templatedetails', 'manifest'], true)) {
@@ -179,7 +199,9 @@ class JoomlaBuildCli extends CliFramework
// Strip existing type prefix to prevent duplication (e.g. pkg_mokowaas -> mokowaas)
$element = preg_replace('/^(pkg_|com_|mod_|plg_\w+_|tpl_|lib_)/', '', $element);
if ($name === '') { $name = $element; }
if ($name === '') {
$name = $element;
}
return compact('name', 'type', 'element', 'group');
}
@@ -216,10 +238,18 @@ class JoomlaBuildCli extends CliFramework
private function isExcluded(string $name): bool
{
if ($name === '.ftpignore') return true;
if (str_starts_with($name, 'sftp-config')) return true;
if (str_starts_with($name, '.env')) return true;
if (str_starts_with($name, '.build-trigger')) return true;
if ($name === '.ftpignore') {
return true;
}
if (str_starts_with($name, 'sftp-config')) {
return true;
}
if (str_starts_with($name, '.env')) {
return true;
}
if (str_starts_with($name, '.build-trigger')) {
return true;
}
$ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key', 'local'], true);
}
@@ -237,7 +267,9 @@ class JoomlaBuildCli extends CliFramework
);
foreach ($iter as $file) {
$local = str_replace('\\', '/', substr($file->getPathname(), strlen($srcDir) + 1));
if ($this->isExcluded(basename($local))) continue;
if ($this->isExcluded(basename($local))) {
continue;
}
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
}
$zip->close();
@@ -268,8 +300,12 @@ class JoomlaBuildCli extends CliFramework
}
// 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
foreach (glob("{$srcDir}/*.php") ?: [] as $f) copy($f, "{$staging}/" . basename($f));
foreach (glob("{$srcDir}/*.xml") ?: [] as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (glob("{$srcDir}/*.php") ?: [] as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) {
$this->copyTree("{$srcDir}/{$d}", "{$staging}/{$d}");
@@ -285,7 +321,9 @@ class JoomlaBuildCli extends CliFramework
private function copyTree(string $src, string $dst): void
{
if (!is_dir($dst)) mkdir($dst, 0755, true);
if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
@@ -298,7 +336,9 @@ class JoomlaBuildCli extends CliFramework
private function rmTree(string $dir): void
{
if (!is_dir($dir)) return;
if (!is_dir($dir)) {
return;
}
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+44 -17
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -55,17 +56,17 @@ class JoomlaRelease extends CliFramework
'stable' => '',
];
private ApiClient $api;
private ApiClient $api;
private \MokoEnterprise\GitPlatformAdapter $adapter;
protected function configure(): void
{
$this->setDescription('Joomla release pipeline — build packages, upload, update updates.xml');
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
$this->addArgument('--repo', 'Repository name (e.g., MokoCassiopeia)', '');
$this->addArgument('--path', 'Local repo path (alternative to --repo)', '.');
$this->addArgument('--stability', 'Stability level: development|alpha|beta|rc|stable', 'stable');
$this->addArgument('--dry-run', 'Preview without making changes', false);
$this->addArgument('--verbose', 'Show detailed output', false);
$this->addArgument('--dry-run', 'Preview without making changes', false);
$this->addArgument('--verbose', 'Show detailed output', false);
}
protected function run(): int
@@ -86,7 +87,9 @@ class JoomlaRelease extends CliFramework
if ($repo !== '') {
$path = $this->cloneRepo($repo);
if ($path === null) { return 1; }
if ($path === null) {
return 1;
}
}
$path = rtrim($path, '/\\');
@@ -191,7 +194,9 @@ class JoomlaRelease extends CliFramework
private function findManifest(string $path): ?string
{
foreach ([$path, "{$path}/src", "{$path}/htdocs"] as $dir) {
if (!is_dir($dir)) { continue; }
if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") as $file) {
if (str_contains((string) file_get_contents($file), '<extension')) {
return $file;
@@ -235,7 +240,9 @@ class JoomlaRelease extends CliFramework
private function readVersion(string $path): ?string
{
$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)) {
return $m[1];
}
@@ -301,8 +308,12 @@ class JoomlaRelease extends CliFramework
}
// 2. Copy package-level files (manifest, script, language)
foreach (glob("{$srcDir}/*.xml") as $f) { copy($f, "{$staging}/" . basename($f)); }
foreach (glob("{$srcDir}/*.php") as $f) { copy($f, "{$staging}/" . basename($f)); }
foreach (glob("{$srcDir}/*.xml") as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (glob("{$srcDir}/*.php") as $f) {
copy($f, "{$staging}/" . basename($f));
}
foreach (['language', 'administrator'] as $d) {
if (is_dir("{$srcDir}/{$d}")) {
$this->copyDir("{$srcDir}/{$d}", "{$staging}/{$d}");
@@ -321,7 +332,9 @@ class JoomlaRelease extends CliFramework
*/
private function copyDir(string $src, string $dst): void
{
if (!is_dir($dst)) { mkdir($dst, 0755, true); }
if (!is_dir($dst)) {
mkdir($dst, 0755, true);
}
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
@@ -342,7 +355,9 @@ class JoomlaRelease extends CliFramework
);
foreach ($iter as $file) {
$local = str_replace('\\', '/', str_replace($srcDir . DIRECTORY_SEPARATOR, '', $file->getPathname()));
if ($this->isExcluded(basename($local))) { continue; }
if ($this->isExcluded(basename($local))) {
continue;
}
$file->isDir() ? $zip->addEmptyDir($local) : $zip->addFile($file->getPathname(), $local);
}
$zip->close();
@@ -359,17 +374,29 @@ class JoomlaRelease extends CliFramework
private function isExcluded(string $name): bool
{
if ($name === '.ftpignore') { return true; }
if (str_starts_with($name, 'sftp-config')) { return true; }
if (str_starts_with($name, '.env')) { return true; }
if ($name === '.ftpignore') {
return true;
}
if (str_starts_with($name, 'sftp-config')) {
return true;
}
if (str_starts_with($name, '.env')) {
return true;
}
$ext = pathinfo($name, PATHINFO_EXTENSION);
return in_array($ext, ['ppk', 'pem', 'key'], true);
}
// ── GitHub Release ───────────────────────────────────────────────
private function ensureRelease(string $repo, string $tag, string $version, string $stability, string $extName = '', string $packageName = ''): void
{
private function ensureRelease(
string $repo,
string $tag,
string $version,
string $stability,
string $extName = '',
string $packageName = ''
): void {
$releaseName = $extName !== ''
? "{$extName} {$version} ({$packageName})"
: (($stability === 'stable') ? "v" . explode('.', $version)[0] . " (latest: {$version})" : "{$tag} ({$version})");
+5 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -90,11 +91,13 @@ class LicenseManage extends CliFramework
// Determine subcommand from argv
global $argv;
foreach ($argv as $arg) {
if (in_array($arg, [
if (
in_array($arg, [
'list', 'create-package', 'update-package', 'delete-package',
'issue', 'revoke', 'activate', 'renew', 'validate',
'usage', 'master-key', 'keys', 'packages',
], true)) {
], true)
) {
$this->subcommand = $arg;
break;
}
+164 -66
View File
@@ -21,73 +21,171 @@ use MokoEnterprise\CliFramework;
class ManifestElementCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Version string', null);
$this->addArgument('--stability', 'Stability level', 'stable');
$this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
}
protected function configure(): void
{
$this->setDescription('Extract element name, type, type prefix, and ZIP name from manifest');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Version string', null);
$this->addArgument('--stability', 'Stability level', 'stable');
$this->addArgument('--repo', 'Repository name', '');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
}
protected function run(): int
{
$path = $this->getArgument('--path'); $version = $this->getArgument('--version');
$stability = $this->getArgument('--stability'); $repoName = $this->getArgument('--repo');
$githubOutput = (bool) $this->getArgument('--github-output');
$root = realpath($path) ?: $path;
$platform = 'generic';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) { $content = file_get_contents($manifestXml); if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) { $platform = trim($pm[1]); } }
$extManifest = null;
$manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
foreach ($manifestFiles as $file) { $c = file_get_contents($file); if (strpos($c, '<extension') !== false) { $extManifest = $file; break; } }
$modFile = null;
$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") ?: []);
foreach ($modFiles as $file) { $c = file_get_contents($file); if (strpos($c, 'extends DolibarrModules') !== false) { $modFile = $file; break; } }
$extElement = ''; $extType = ''; $extFolder = ''; $extName = '';
switch (true) {
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
if (preg_match('/type="([^"]*)"/', $xml, $tm)) { $extType = $tm[1]; }
if (preg_match('/group="([^"]*)"/', $xml, $gm)) { $extFolder = $gm[1]; }
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) { $extElement = $em[1]; }
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) { $extElement = $mm[1]; }
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) { $extElement = $pm2[1]; }
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/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;
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$stability = $this->getArgument('--stability');
$repoName = $this->getArgument('--repo');
$githubOutput = (bool) $this->getArgument('--github-output');
$root = realpath($path) ?: $path;
$platform = 'generic';
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$content = file_get_contents($manifestXml);
if (preg_match('/<platform>([^<]+)<\/platform>/', $content, $pm)) {
$platform = trim($pm[1]);
}
}
$extManifest = null;
$manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
foreach ($manifestFiles as $file) {
$c = file_get_contents($file);
if (strpos($c, '<extension') !== false) {
$extManifest = $file;
break;
}
}
$modFile = null;
$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") ?: []
);
foreach ($modFiles as $file) {
$c = file_get_contents($file);
if (strpos($c, 'extends DolibarrModules') !== false) {
$modFile = $file;
break;
}
}
$extElement = '';
$extType = '';
$extFolder = '';
$extName = '';
switch (true) {
case in_array($platform, ['joomla', 'waas-component'], true) && $extManifest !== null:
$xml = file_get_contents($extManifest);
if (preg_match('/type="([^"]*)"/', $xml, $tm)) {
$extType = $tm[1];
}
if (preg_match('/group="([^"]*)"/', $xml, $gm)) {
$extFolder = $gm[1];
}
if (preg_match('/<element>([^<]+)<\/element>/', $xml, $em)) {
$extElement = $em[1];
}
if (empty($extElement) && preg_match('/module="([^"]*)"/', $xml, $mm)) {
$extElement = $mm[1];
}
if (empty($extElement) && preg_match('/plugin="([^"]*)"/', $xml, $pm2)) {
$extElement = $pm2[1];
}
if ($extType === 'package' && preg_match('/<packagename>([^<]+)<\/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();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+146 -80
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,89 +21,154 @@ use MokoEnterprise\CliFramework;
class ReleaseManageCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Create/update Gitea releases, upload assets, update release body');
$this->addArgument('--action', 'create | upload | update-body | delete', null);
$this->addArgument('--tag', 'Release tag name', null);
$this->addArgument('--name', 'Release name/title', null);
$this->addArgument('--body', 'Release body/description', null);
$this->addArgument('--body-file', 'Read body from file', null);
$this->addArgument('--target', 'Target branch/commitish', 'main');
$this->addArgument('--files', 'Comma-separated file paths to upload', null);
$this->addArgument('--token', 'Gitea API token', null);
$this->addArgument('--api-base', 'Gitea API base URL', null);
}
protected function configure(): void
{
$this->setDescription('Create/update Gitea releases, upload assets, update release body');
$this->addArgument('--action', 'create | upload | update-body | delete', null);
$this->addArgument('--tag', 'Release tag name', null);
$this->addArgument('--name', 'Release name/title', null);
$this->addArgument('--body', 'Release body/description', null);
$this->addArgument('--body-file', 'Read body from file', null);
$this->addArgument('--target', 'Target branch/commitish', 'main');
$this->addArgument('--files', 'Comma-separated file paths to upload', null);
$this->addArgument('--token', 'Gitea API token', null);
$this->addArgument('--api-base', 'Gitea API base URL', null);
}
protected function run(): int
{
$action = $this->getArgument('--action'); $tag = $this->getArgument('--tag');
$name = $this->getArgument('--name'); $body = $this->getArgument('--body');
$bodyFile = $this->getArgument('--body-file'); $target = $this->getArgument('--target');
$filesArg = $this->getArgument('--files'); $token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : [];
if ($token === null) { $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null; }
if ($bodyFile !== null && file_exists($bodyFile)) { $body = file_get_contents($bodyFile); }
if ($action === null || $tag === null || $token === null || $apiBase === null) { $this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL"); return 1; }
switch ($action) {
case 'create':
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) { $existingId = $existing['id']; $this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token); $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); echo "Deleted previous release: {$tag} (id: {$existingId})\n"; }
$payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]);
$result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) { $releaseId = $result['data']['id'] ?? 'unknown'; echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n"; }
else { $this->log('ERROR', "Failed to create release: HTTP {$result['code']}"); return 1; }
break;
case 'upload':
if (empty($files)) { $this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2"); return 1; }
$release = $this->getReleaseByTag($apiBase, $tag, $token);
if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; }
$releaseId = $release['id'];
$assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$existingAssets = $assetsResult['data'] ?? [];
foreach ($files as $filePath) {
$filePath = trim($filePath); if (!file_exists($filePath)) { $this->log('ERROR', "File not found: {$filePath}"); continue; }
$fileName = basename($filePath);
foreach ($existingAssets as $asset) { if (($asset['name'] ?? '') === $fileName) { $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token); echo "Deleted existing asset: {$fileName}\n"; break; } }
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
$result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
if ($result['code'] >= 200 && $result['code'] < 300) { echo "Uploaded: {$fileName}\n"; } else { $this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}"); }
}
break;
case 'update-body':
$release = $this->getReleaseByTag($apiBase, $tag, $token);
if ($release === null) { $this->log('ERROR', "No release found for tag: {$tag}"); return 1; }
$payload = json_encode(['body' => $body ?? '']);
$result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) { echo "Release body updated for tag: {$tag}\n"; } else { $this->log('ERROR', "Failed to update body: HTTP {$result['code']}"); return 1; }
break;
case 'delete':
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) { $this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token); $this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token); echo "Deleted: {$tag} (id: {$existing['id']})\n"; }
else { echo "No release found for tag: {$tag}\n"; }
break;
default: $this->log('ERROR', "Unknown action: {$action}"); return 1;
}
return 0;
}
protected function run(): int
{
$action = $this->getArgument('--action');
$tag = $this->getArgument('--tag');
$name = $this->getArgument('--name');
$body = $this->getArgument('--body');
$bodyFile = $this->getArgument('--body-file');
$target = $this->getArgument('--target');
$filesArg = $this->getArgument('--files');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$files = $filesArg !== null ? array_filter(explode(',', $filesArg)) : [];
if ($token === null) {
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
if ($bodyFile !== null && file_exists($bodyFile)) {
$body = file_get_contents($bodyFile);
}
if ($action === null || $tag === null || $token === null || $apiBase === null) {
$this->log('ERROR', "Usage: release_manage.php --action [create|upload|update-body|delete] --tag TAG --token TOKEN --api-base URL");
return 1;
}
switch ($action) {
case 'create':
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$existingId = $existing['id'];
$this->releaseGiteaApi("{$apiBase}/releases/{$existingId}", 'DELETE', $token);
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted previous release: {$tag} (id: {$existingId})\n";
}
$payload = json_encode(['tag_name' => $tag, 'name' => $name ?? $tag, 'body' => $body ?? '', 'target_commitish' => $target]);
$result = $this->releaseGiteaApi("{$apiBase}/releases", 'POST', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
$releaseId = $result['data']['id'] ?? 'unknown';
echo "Release created: {$name} (tag: {$tag}, id: {$releaseId})\n";
} else {
$this->log('ERROR', "Failed to create release: HTTP {$result['code']}");
return 1;
}
break;
case 'upload':
if (empty($files)) {
$this->log('ERROR', "No files specified. Use --files /path/to/file1,/path/to/file2");
return 1;
}
$release = $this->getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
$this->log('ERROR', "No release found for tag: {$tag}");
return 1;
}
$releaseId = $release['id'];
$assetsResult = $this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets", 'GET', $token);
$existingAssets = $assetsResult['data'] ?? [];
foreach ($files as $filePath) {
$filePath = trim($filePath);
if (!file_exists($filePath)) {
$this->log('ERROR', "File not found: {$filePath}");
continue;
}
$fileName = basename($filePath);
foreach ($existingAssets as $asset) {
if (($asset['name'] ?? '') === $fileName) {
$this->releaseGiteaApi("{$apiBase}/releases/{$releaseId}/assets/{$asset['id']}", 'DELETE', $token);
echo "Deleted existing asset: {$fileName}\n";
break;
}
}
$uploadUrl = "{$apiBase}/releases/{$releaseId}/assets?name=" . urlencode($fileName);
$result = $this->releaseGiteaApi($uploadUrl, 'POST', $token, null, $filePath);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Uploaded: {$fileName}\n";
} else {
$this->log('ERROR', "Failed to upload {$fileName}: HTTP {$result['code']}");
}
}
break;
case 'update-body':
$release = $this->getReleaseByTag($apiBase, $tag, $token);
if ($release === null) {
$this->log('ERROR', "No release found for tag: {$tag}");
return 1;
}
$payload = json_encode(['body' => $body ?? '']);
$result = $this->releaseGiteaApi("{$apiBase}/releases/{$release['id']}", 'PATCH', $token, $payload);
if ($result['code'] >= 200 && $result['code'] < 300) {
echo "Release body updated for tag: {$tag}\n";
} else {
$this->log('ERROR', "Failed to update body: HTTP {$result['code']}");
return 1;
}
break;
case 'delete':
$existing = $this->getReleaseByTag($apiBase, $tag, $token);
if ($existing !== null) {
$this->releaseGiteaApi("{$apiBase}/releases/{$existing['id']}", 'DELETE', $token);
$this->releaseGiteaApi("{$apiBase}/tags/{$tag}", 'DELETE', $token);
echo "Deleted: {$tag} (id: {$existing['id']})\n";
} else {
echo "No release found for tag: {$tag}\n";
}
break;
default:
$this->log('ERROR', "Unknown action: {$action}");
return 1;
}
return 0;
}
private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{
$ch = curl_init($url); $headers = ["Authorization: token {$token}"];
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
if ($jsonBody !== null) { $headers[] = 'Content-Type: application/json'; $opts[CURLOPT_POSTFIELDS] = $jsonBody; }
elseif ($filePath !== null) { $headers[] = 'Content-Type: application/octet-stream'; $opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath); }
$opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts);
$response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []];
}
private function releaseGiteaApi(string $url, string $method, string $token, ?string $jsonBody = null, ?string $filePath = null): array
{
$ch = curl_init($url);
$headers = ["Authorization: token {$token}"];
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_CUSTOMREQUEST => $method];
if ($jsonBody !== null) {
$headers[] = 'Content-Type: application/json';
$opts[CURLOPT_POSTFIELDS] = $jsonBody;
} elseif ($filePath !== null) {
$headers[] = 'Content-Type: application/octet-stream';
$opts[CURLOPT_POSTFIELDS] = file_get_contents($filePath);
}
$opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['code' => $httpCode, 'data' => json_decode($response ?: '{}', true) ?: []];
}
private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
{
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
}
private function getReleaseByTag(string $apiBase, string $tag, string $token): ?array
{
$result = $this->releaseGiteaApi("{$apiBase}/releases/tags/{$tag}", 'GET', $token);
return ($result['code'] === 200 && isset($result['data']['id'])) ? $result['data'] : null;
}
}
$app = new ReleaseManageCli();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -61,10 +62,18 @@ class ReleasePublishCli extends CliFramework
// Auto-detect org/repo from git remote if not set
if (empty($org) || empty($repo)) {
$remote = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git remote get-url origin 2>/dev/null"));
$cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$remote = trim((string) @shell_exec(
$cd . escapeshellarg($resolvedPath)
. " && git remote get-url origin 2>/dev/null"
));
if (preg_match('#/([^/]+)/([^/.]+?)(?:\.git)?$#', $remote, $m)) {
if (empty($org)) $org = $m[1];
if (empty($repo)) $repo = $m[2];
if (empty($org)) {
$org = $m[1];
}
if (empty($repo)) {
$repo = $m[2];
}
}
}
@@ -76,7 +85,12 @@ class ReleasePublishCli extends CliFramework
// Auto-detect branch
if (empty($branch)) {
$branch = getenv('GITHUB_REF_NAME') ?: trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($resolvedPath) . " && git rev-parse --abbrev-ref HEAD 2>/dev/null"));
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$branch = getenv('GITHUB_REF_NAME')
?: trim((string) @shell_exec(
$cdCmd . escapeshellarg($resolvedPath)
. " && git rev-parse --abbrev-ref HEAD 2>/dev/null"
));
}
$apiBase = "{$giteaUrl}/api/v1/repos/{$org}/{$repo}";
@@ -145,12 +159,23 @@ class ReleasePublishCli extends CliFramework
// -- Step 2b: Update badges and changelog --
if (!$this->dryRun) {
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
passthru(
"{$php} {$cli}/badge_update.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
$changelogFile = realpath($path) . '/CHANGELOG.md';
if (file_exists($changelogFile)) {
passthru("{$php} {$cli}/changelog_promote.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
passthru("{$php} {$cli}/changelog_prune.php --path " . escapeshellarg($path) . " --keep 5 2>/dev/null");
passthru(
"{$php} {$cli}/changelog_promote.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
passthru(
"{$php} {$cli}/changelog_prune.php --path "
. escapeshellarg($path) . " --keep 5 2>/dev/null"
);
}
}
@@ -158,30 +183,67 @@ class ReleasePublishCli extends CliFramework
$root = realpath($path) ?: $path;
if (!$this->dryRun) {
// Configure git
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\"");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\"");
$cdPfx = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$cdR = $cdPfx . escapeshellarg($root);
@shell_exec(
$cdR . " && git config --local user.email"
. " \"gitea-actions[bot]@mokoconsulting.tech\""
);
@shell_exec(
$cdR . " && git config --local user.name"
. " \"gitea-actions[bot]\""
);
if (!empty($repoUrl)) {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl));
@shell_exec(
$cdR . " && git remote set-url origin "
. escapeshellarg($repoUrl)
);
}
// Ensure we're on the actual branch (not detached HEAD from PR merge)
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git fetch origin " . escapeshellarg($branch) . " 2>/dev/null");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git checkout -B " . escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null");
@shell_exec(
$cdR . " && git fetch origin "
. escapeshellarg($branch) . " 2>/dev/null"
);
@shell_exec(
$cdR . " && git checkout -B "
. escapeshellarg($branch) . " FETCH_HEAD 2>/dev/null"
);
// Re-apply version changes on the checked-out branch
passthru("{$php} {$cli}/version_set_platform.php --path " . escapeshellarg($path)
. " --version " . escapeshellarg($baseVersion)
. " --branch " . escapeshellarg($branch)
. " --stability " . escapeshellarg($stability) . " 2>/dev/null");
passthru("{$php} {$cli}/version_check.php --path " . escapeshellarg($path) . " --fix 2>/dev/null");
passthru("{$php} {$cli}/badge_update.php --path " . escapeshellarg($path) . " --version " . escapeshellarg($baseVersion) . " 2>/dev/null");
passthru(
"{$php} {$cli}/version_check.php --path "
. escapeshellarg($path) . " --fix 2>/dev/null"
);
passthru(
"{$php} {$cli}/badge_update.php --path "
. escapeshellarg($path) . " --version "
. escapeshellarg($baseVersion) . " 2>/dev/null"
);
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty"));
$diffCheck = trim((string) @shell_exec(
$cdR . " && git diff --quiet"
. " && git diff --cached --quiet"
. " 2>&1 && echo clean || echo dirty"
));
if ($diffCheck === 'dirty') {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore(release): build {$releaseVersion} [skip ci]")
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
$pushResult = @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
@shell_exec($cdR . " && git add -A");
$commitMsg = "chore(release): build"
. " {$releaseVersion} [skip ci]";
@shell_exec(
$cdR . " && git commit -m "
. escapeshellarg($commitMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
$pushResult = @shell_exec(
$cdR . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo " Committed release changes\n";
echo " Push: " . trim($pushResult ?? '') . "\n";
}
@@ -258,12 +320,26 @@ class ReleasePublishCli extends CliFramework
$root = realpath($path) ?: $path;
if (!$this->dryRun) {
$diffCheck = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet updates.xml 2>&1 && echo clean || echo dirty"));
$cdX = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$cdRt = $cdX . escapeshellarg($root);
$diffCheck = trim((string) @shell_exec(
$cdRt . " && git diff --quiet updates.xml"
. " 2>&1 && echo clean || echo dirty"
));
if ($diffCheck === 'dirty') {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add updates.xml");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg("chore: update channels for {$releaseVersion} [skip ci]")
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git push origin " . escapeshellarg($branch) . " 2>&1");
@shell_exec($cdRt . " && git add updates.xml");
$chMsg = "chore: update channels for"
. " {$releaseVersion} [skip ci]";
@shell_exec(
$cdRt . " && git commit -m "
. escapeshellarg($chMsg)
. " --author=\"gitea-actions[bot]"
. " <gitea-actions[bot]@mokoconsulting.tech>\""
);
@shell_exec(
$cdRt . " && git push origin "
. escapeshellarg($branch) . " 2>&1"
);
echo " Committed updates.xml\n";
}
+207 -61
View File
@@ -21,70 +21,216 @@ use MokoEnterprise\CliFramework;
class ReleaseValidateCli extends CliFramework
{
private int $pass = 0;
private int $fail = 0;
private int $warn = 0;
private array $results = [];
private int $pass = 0;
private int $fail = 0;
private int $warn = 0;
private array $results = [];
protected function configure(): void
{
$this->setDescription('Pre-release validation -- version consistency, required files, manifest checks');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Expected version string', null);
$this->addArgument('--platform', 'joomla|dolibarr|generic', null);
$this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false);
$this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false);
}
protected function configure(): void
{
$this->setDescription('Pre-release validation -- version consistency, required files, manifest checks');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--version', 'Expected version string', null);
$this->addArgument('--platform', 'joomla|dolibarr|generic', null);
$this->addArgument('--output-summary', 'Write markdown to $GITHUB_STEP_SUMMARY', false);
$this->addArgument('--github-output', 'Export counts to $GITHUB_OUTPUT', false);
}
protected function run(): int
{
$path = $this->getArgument('--path'); $version = $this->getArgument('--version');
$platform = $this->getArgument('--platform');
$outputSummary = (bool) $this->getArgument('--output-summary');
$githubOutput = (bool) $this->getArgument('--github-output');
if ($version === null) { $this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]"); return 1; }
$root = realpath($path) ?: $path;
if ($platform === null) {
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) { $mContent = file_get_contents($manifestXml); if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) { $platform = trim($pm[1]); } }
if (in_array($platform, ['waas-component'], true)) { $platform = 'joomla'; }
if (in_array($platform, ['crm-module'], true)) { $platform = 'dolibarr'; }
if ($platform === null) { $platform = 'generic'; }
}
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory');
if (!file_exists("{$root}/README.md")) { $this->addVResult('README.md', 'FAIL', 'Not found'); }
else { $readme = file_get_contents("{$root}/README.md"); $this->addVResult('README.md version', (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || strpos($readme, $version) !== false) ? 'PASS' : 'FAIL', (preg_match('/VERSION:\s*' . preg_quote($version, '/') . '/', $readme) || strpos($readme, $version) !== false) ? "`{$version}` found" : "`{$version}` not found"); }
if (!file_exists("{$root}/CHANGELOG.md")) { $this->addVResult('CHANGELOG.md', 'WARN', 'Not found'); }
else { $cl = file_get_contents("{$root}/CHANGELOG.md"); $this->addVResult('CHANGELOG.md version', preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl) ? 'PASS' : 'WARN', preg_match('/^##\s.*' . preg_quote($version, '/') . '/m', $cl) ? "Section found" : "No section header"); }
$licenseFound = false; foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) { if (file_exists("{$root}/{$lf}")) { $licenseFound = true; break; } }
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
if ($platform === 'joomla') {
$manifest = null; foreach (["{$root}/src", $root] as $dir) { if (!is_dir($dir)) continue; foreach (glob("{$dir}/*.xml") as $xmlFile) { $content = file_get_contents($xmlFile); if (strpos($content, '<extension') !== false) { $manifest = $xmlFile; break 2; } } }
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 (!file_exists("{$root}/updates.xml")) { $this->addVResult('updates.xml', 'WARN', 'Not found'); }
else { $ux = file_get_contents("{$root}/updates.xml"); $this->addVResult('updates.xml version', preg_match('/<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; } }
if ($modFile === null) { $this->addVResult('Dolibarr mod file', 'FAIL', 'No mod*.class.php found'); }
else { $mc = file_get_contents($modFile); $this->addVResult('Dolibarr version', preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc) ? 'PASS' : 'FAIL', preg_match("/\\\$this->version\s*=\s*'" . preg_quote($version, '/') . "'/", $mc) ? "`{$version}` matches" : "`{$version}` not found"); }
}
if (file_exists("{$root}/composer.json")) { $composer = json_decode(file_get_contents("{$root}/composer.json"), true); if (isset($composer['version'])) { $this->addVResult('composer.json version', $composer['version'] === $version ? 'PASS' : 'WARN', $composer['version'] === $version ? "`{$version}` matches" : "`{$composer['version']}` != `{$version}`"); } }
$table = "| Check | Result | Details |\n|-------|--------|--------|\n";
foreach ($this->results as $r) { $table .= "| {$r['check']} | {$r['status']} | {$r['details']} |\n"; }
$table .= "\n**Validation: {$this->pass} passed, {$this->fail} failed, {$this->warn} warnings**\n";
echo $table;
if ($outputSummary) { $summaryFile = getenv('GITHUB_STEP_SUMMARY'); if ($summaryFile) { file_put_contents($summaryFile, "## Pre-Release Sanity Checks ({$platform})\n\n{$table}\n", FILE_APPEND); } }
if ($githubOutput) { $ghOutput = getenv('GITHUB_OUTPUT'); if ($ghOutput) { file_put_contents($ghOutput, "validation_pass={$this->pass}\nvalidation_fail={$this->fail}\nvalidation_warn={$this->warn}\nvalidation_platform={$platform}\n", FILE_APPEND); } }
return $this->fail > 0 ? 1 : 0;
}
protected function run(): int
{
$path = $this->getArgument('--path');
$version = $this->getArgument('--version');
$platform = $this->getArgument('--platform');
$outputSummary = (bool) $this->getArgument('--output-summary');
$githubOutput = (bool) $this->getArgument('--github-output');
if ($version === null) {
$this->log('ERROR', "Usage: release_validate.php --path . --version XX.YY.ZZ [--platform joomla]");
return 1;
}
$root = realpath($path) ?: $path;
if ($platform === null) {
$manifestXml = "{$root}/.mokogitea/manifest.xml";
if (file_exists($manifestXml)) {
$mContent = file_get_contents($manifestXml);
if (preg_match('/<platform>([^<]+)<\/platform>/', $mContent, $pm)) {
$platform = trim($pm[1]);
}
}
if (in_array($platform, ['waas-component'], true)) {
$platform = 'joomla';
}
if (in_array($platform, ['crm-module'], true)) {
$platform = 'dolibarr';
}
if ($platform === null) {
$platform = 'generic';
}
}
$hasSource = is_dir("{$root}/src") || is_dir("{$root}/htdocs");
$this->addVResult('Source directory', $hasSource ? 'PASS' : 'WARN', $hasSource ? 'src/ or htdocs/ found' : 'No src/ or htdocs/ directory');
if (!file_exists("{$root}/README.md")) {
$this->addVResult('README.md', 'FAIL', 'Not found');
} else {
$readme = file_get_contents("{$root}/README.md");
$quotedVer = preg_quote($version, '/');
$readmeHasVer = preg_match(
'/VERSION:\s*' . $quotedVer . '/',
$readme
) || strpos($readme, $version) !== false;
$this->addVResult(
'README.md version',
$readmeHasVer ? 'PASS' : 'FAIL',
$readmeHasVer
? "`{$version}` found"
: "`{$version}` not found"
);
}
if (!file_exists("{$root}/CHANGELOG.md")) {
$this->addVResult('CHANGELOG.md', 'WARN', 'Not found');
} else {
$cl = file_get_contents("{$root}/CHANGELOG.md");
$clHasVer = preg_match(
'/^##\s.*' . preg_quote($version, '/') . '/m',
$cl
);
$this->addVResult(
'CHANGELOG.md version',
$clHasVer ? 'PASS' : 'WARN',
$clHasVer ? "Section found" : "No section header"
);
}
$licenseFound = false;
foreach (['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'COPYING'] as $lf) {
if (file_exists("{$root}/{$lf}")) {
$licenseFound = true;
break;
}
}
$this->addVResult('LICENSE', $licenseFound ? 'PASS' : 'FAIL', $licenseFound ? 'Found' : 'Not found');
if ($platform === 'joomla') {
$manifest = null;
foreach (["{$root}/src", $root] as $dir) {
if (!is_dir($dir)) {
continue;
} foreach (glob("{$dir}/*.xml") as $xmlFile) {
$content = file_get_contents($xmlFile);
if (strpos($content, '<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
{
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') { $this->pass++; } elseif ($status === 'FAIL') { $this->fail++; } elseif ($status === 'WARN') { $this->warn++; }
}
private function addVResult(string $check, string $status, string $details): void
{
$this->results[] = ['check' => $check, 'status' => $status, 'details' => $details];
if ($status === 'PASS') {
$this->pass++;
} elseif ($status === 'FAIL') {
$this->fail++;
} elseif ($status === 'WARN') {
$this->warn++;
}
}
}
$app = new ReleaseValidateCli();
+15 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -103,7 +104,13 @@ class ReleaseVerifyCli extends CliFramework
if ($zipSha === $expectedSha) {
$this->addResult('SHA256 vs updates.xml', 'PASS', '`' . substr($zipSha, 0, 16) . '...`');
} else {
$this->addResult('SHA256 vs updates.xml', 'FAIL', "ZIP=`" . substr($zipSha, 0, 16) . "...` updates.xml=`" . substr($expectedSha, 0, 16) . "...`");
$this->addResult(
'SHA256 vs updates.xml',
'FAIL',
"ZIP=`" . substr($zipSha, 0, 16)
. "...` updates.xml=`"
. substr($expectedSha, 0, 16) . "...`"
);
}
} else {
$this->addResult('SHA256 vs updates.xml', 'WARN', 'No <sha256> in updates.xml');
@@ -145,7 +152,13 @@ class ReleaseVerifyCli extends CliFramework
}
// Clean up
$rit = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($tmpDir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
$rit = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$tmpDir,
\RecursiveDirectoryIterator::SKIP_DOTS
),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($rit as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
+103 -51
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -23,60 +24,111 @@ use MokoEnterprise\CliFramework;
class ScaffoldClientCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
$this->addArgument('--name', 'Client name', '');
$this->addArgument('--org', 'Gitea organization', '');
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
}
protected function configure(): void
{
$this->setDescription('Scaffold a new client-waas repo from Template-Client-WaaS');
$this->addArgument('--name', 'Client name', '');
$this->addArgument('--org', 'Gitea organization', '');
$this->addArgument('--gitea-url', 'Gitea URL', 'https://git.mokoconsulting.tech');
$this->addArgument('--token', 'Gitea API token', '');
}
protected function run(): int
{
$name = $this->getArgument('--name'); $org = $this->getArgument('--org');
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $token = $this->getArgument('--token');
if ($name === '' || $org === '' || $token === '') { $this->log('ERROR', '--name, --org, and --token are required.'); return 1; }
$repoName = 'client-waas-' . $name;
$this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}");
$this->log('INFO', "Gitea URL: {$giteaUrl}");
if ($this->dryRun) {
$this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
$this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}");
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
return 0;
}
$this->log('INFO', 'Step 1: Creating repo from template...');
$createPayload = json_encode(['owner' => $org, 'name' => $repoName, 'description' => "{$name} WaaS site", 'private' => true, 'git_content' => true, 'topics' => true, 'labels' => true]);
$response = $this->apiRequest('POST', "/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate", $giteaUrl, $token, $createPayload);
if ($response['code'] < 200 || $response['code'] >= 300) { $this->log('ERROR', "Failed to create repo (HTTP {$response['code']})."); return 1; }
$this->log('INFO', "Repo created: {$org}/{$repoName}");
$this->log('INFO', 'Step 2: Updating repo description...');
$this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"]));
$this->log('INFO', 'Step 3: Creating dev branch from main...');
$response = $this->apiRequest('POST', "/api/v1/repos/{$org}/{$repoName}/branches", $giteaUrl, $token, json_encode(['new_branch_name' => 'dev', 'old_branch_name' => 'main']));
if ($response['code'] >= 200 && $response['code'] < 300) { $this->log('INFO', 'Branch "dev" created from "main".'); }
else { $this->log('WARN', "Could not create dev branch (HTTP {$response['code']})."); }
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
$this->log('INFO', 'Scaffold complete.');
return 0;
}
protected function run(): int
{
$name = $this->getArgument('--name');
$org = $this->getArgument('--org');
$giteaUrl = rtrim($this->getArgument('--gitea-url'), '/');
$token = $this->getArgument('--token');
if ($name === '' || $org === '' || $token === '') {
$this->log('ERROR', '--name, --org, and --token are required.');
return 1;
}
$repoName = 'client-waas-' . $name;
$this->log('INFO', "Scaffolding client repo: {$org}/{$repoName}");
$this->log('INFO', "Gitea URL: {$giteaUrl}");
if ($this->dryRun) {
$this->log('INFO', '[DRY RUN] Would create repo from template MokoConsulting/Template-Client-WaaS');
$this->log('INFO', "[DRY RUN] Repo: {$org}/{$repoName}");
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
return 0;
}
$this->log('INFO', 'Step 1: Creating repo from template...');
$createPayload = json_encode([
'owner' => $org,
'name' => $repoName,
'description' => "{$name} WaaS site",
'private' => true,
'git_content' => true,
'topics' => true,
'labels' => true,
]);
$response = $this->apiRequest(
'POST',
"/api/v1/repos/MokoConsulting/Template-Client-WaaS/generate",
$giteaUrl,
$token,
$createPayload
);
if ($response['code'] < 200 || $response['code'] >= 300) {
$this->log('ERROR', "Failed to create repo (HTTP {$response['code']}).");
return 1;
}
$this->log('INFO', "Repo created: {$org}/{$repoName}");
$this->log('INFO', 'Step 2: Updating repo description...');
$this->apiRequest('PATCH', "/api/v1/repos/{$org}/{$repoName}", $giteaUrl, $token, json_encode(['description' => "{$name} WaaS site"]));
$this->log('INFO', 'Step 3: Creating dev branch from main...');
$response = $this->apiRequest(
'POST',
"/api/v1/repos/{$org}/{$repoName}/branches",
$giteaUrl,
$token,
json_encode([
'new_branch_name' => 'dev',
'old_branch_name' => 'main',
])
);
if ($response['code'] >= 200 && $response['code'] < 300) {
$this->log('INFO', 'Branch "dev" created from "main".');
} else {
$this->log('WARN', "Could not create dev branch (HTTP {$response['code']}).");
}
$this->printPostSetupInstructions($repoName, $giteaUrl, $org);
$this->log('INFO', 'Scaffold complete.');
return 0;
}
private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
{
fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\nNavigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\nSet REPO VARIABLES:\n DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\nSet REPO SECRETS:\n DEV_SYNC_KEY, LIVE_SSH_KEY\n\n================================\n");
}
private function printPostSetupInstructions(string $repoName, string $giteaUrl, string $org): void
{
fwrite(STDERR, "\n=== POST-SETUP INSTRUCTIONS ===\n\n"
. "Navigate to: {$giteaUrl}/{$org}/{$repoName}/settings\n\n"
. "Set REPO VARIABLES:\n"
. " DEV_SYNC_HOST, DEV_SYNC_PORT, DEV_SYNC_USER, DEV_SYNC_PATH\n"
. " LIVE_SSH_HOST, LIVE_SSH_PORT, LIVE_SSH_USER, LIVE_SYNC_PATH\n\n"
. "Set REPO SECRETS:\n"
. " DEV_SYNC_KEY, LIVE_SSH_KEY\n\n"
. "================================\n");
}
private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
{
$ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); }
$responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) { $error = curl_error($ch); curl_close($ch); return ['code' => 0, 'body' => "cURL error: {$error}"]; }
curl_close($ch); return ['code' => $httpCode, 'body' => $responseBody];
}
private function apiRequest(string $method, string $endpoint, string $giteaUrl, string $token, ?string $body = null): array
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $giteaUrl . $endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}"]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return ['code' => 0, 'body' => "cURL error: {$error}"];
}
curl_close($ch);
return ['code' => $httpCode, 'body' => $responseBody];
}
}
$app = new ScaffoldClientCli();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
+153 -139
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,159 +21,172 @@ use MokoEnterprise\CliFramework;
class ThemeLintCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
$this->addArgument('--strict', 'Exit 1 on any warning', false);
}
protected function configure(): void
{
$this->setDescription('Lint theme files -- CSS syntax, image sizes, hardcoded URLs');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--max-image-kb', 'Maximum image file size in KB', '500');
$this->addArgument('--github-output', 'Export results to $GITHUB_OUTPUT', false);
$this->addArgument('--strict', 'Exit 1 on any warning', false);
}
protected function run(): int
{
$path = $this->getArgument('--path');
$maxImageKb = (int) $this->getArgument('--max-image-kb');
$ghOutput = (bool) $this->getArgument('--github-output');
$strict = (bool) $this->getArgument('--strict');
protected function run(): int
{
$path = $this->getArgument('--path');
$maxImageKb = (int) $this->getArgument('--max-image-kb');
$ghOutput = (bool) $this->getArgument('--github-output');
$strict = (bool) $this->getArgument('--strict');
$root = realpath($path) ?: $path;
$errors = 0;
$warnings = 0;
$root = realpath($path) ?: $path;
$errors = 0;
$warnings = 0;
$srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$root}/{$d}")) { $srcDir = "{$root}/{$d}"; break; }
}
if ($srcDir === null) {
$this->log('ERROR', "No src/ or htdocs/ directory in {$root}");
return 1;
}
$srcDir = null;
foreach (['src', 'htdocs'] as $d) {
if (is_dir("{$root}/{$d}")) {
$srcDir = "{$root}/{$d}";
break;
}
}
if ($srcDir === null) {
$this->log('ERROR', "No src/ or htdocs/ directory in {$root}");
return 1;
}
echo "Theme Lint: {$srcDir}\n\n";
echo "Theme Lint: {$srcDir}\n\n";
echo "--- CSS Syntax ---\n";
$cssFiles = $this->findFiles($srcDir, '*.css');
$cssMinFiles = $this->findFiles($srcDir, '*.min.css');
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
echo "--- CSS Syntax ---\n";
$cssFiles = $this->findFiles($srcDir, '*.css');
$cssMinFiles = $this->findFiles($srcDir, '*.min.css');
$cssToCheck = array_diff($cssFiles, $cssMinFiles);
if (empty($cssToCheck)) {
echo " No CSS files to check\n";
} else {
foreach ($cssToCheck as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
$openBraces = substr_count($content, '{');
$closeBraces = substr_count($content, '}');
if ($openBraces !== $closeBraces) {
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
$errors++;
}
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
$count = count($m[0]);
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
$warnings++;
}
$importantCount = substr_count($content, '!important');
if ($importantCount > 10) {
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
$warnings++;
}
}
if ($errors === 0) {
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
}
}
if (empty($cssToCheck)) {
echo " No CSS files to check\n";
} else {
foreach ($cssToCheck as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
$openBraces = substr_count($content, '{');
$closeBraces = substr_count($content, '}');
if ($openBraces !== $closeBraces) {
echo " ERROR: {$relPath}: unmatched braces (open={$openBraces}, close={$closeBraces})\n";
$errors++;
}
if (preg_match_all('/\{[\s]*\}/', $content, $m)) {
$count = count($m[0]);
echo " WARN: {$relPath}: {$count} empty rule(s)\n";
$warnings++;
}
$importantCount = substr_count($content, '!important');
if ($importantCount > 10) {
echo " WARN: {$relPath}: {$importantCount} !important declarations (consider refactoring)\n";
$warnings++;
}
}
if ($errors === 0) {
echo " OK: " . count($cssToCheck) . " CSS file(s) checked\n";
}
}
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
$images = [];
foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles($srcDir, $ext));
}
if (is_dir("{$root}/images")) {
foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles("{$root}/images", $ext));
}
}
echo "\n--- Image Sizes (max {$maxImageKb}KB) ---\n";
$imageExts = ['*.jpg', '*.jpeg', '*.png', '*.gif', '*.webp', '*.svg', '*.bmp'];
$images = [];
foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles($srcDir, $ext));
}
if (is_dir("{$root}/images")) {
foreach ($imageExts as $ext) {
$images = array_merge($images, $this->findFiles("{$root}/images", $ext));
}
}
$oversized = 0;
$totalSize = 0;
foreach ($images as $file) {
$size = filesize($file);
$totalSize += $size;
$relPath = str_replace($root . '/', '', $file);
$sizeKb = round($size / 1024);
if ($sizeKb > $maxImageKb) {
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
$oversized++;
$warnings++;
}
}
$oversized = 0;
$totalSize = 0;
foreach ($images as $file) {
$size = filesize($file);
$totalSize += $size;
$relPath = str_replace($root . '/', '', $file);
$sizeKb = round($size / 1024);
if ($sizeKb > $maxImageKb) {
echo " WARN: {$relPath}: {$sizeKb}KB (exceeds {$maxImageKb}KB limit)\n";
$oversized++;
$warnings++;
}
}
$totalMb = round($totalSize / 1024 / 1024, 1);
echo " " . count($images) . " image(s), {$totalMb}MB total";
if ($oversized > 0) { echo ", {$oversized} oversized"; }
echo "\n";
$totalMb = round($totalSize / 1024 / 1024, 1);
echo " " . count($images) . " image(s), {$totalMb}MB total";
if ($oversized > 0) {
echo ", {$oversized} oversized";
}
echo "\n";
echo "\n--- Hardcoded URLs ---\n";
$codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
$codeFiles = array_filter($codeFiles, function ($f) {
return !preg_match('/\.min\.(css|js)$/', $f);
});
$urlPatterns = [
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
'/https?:\/\/localhost/' => 'localhost reference',
];
$urlIssues = 0;
foreach ($codeFiles as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
foreach ($urlPatterns as $pattern => $desc) {
if (preg_match_all($pattern, $content, $matches)) {
$count = count($matches[0]);
echo " WARN: {$relPath}: {$count} {$desc}\n";
$urlIssues++;
$warnings++;
}
}
}
if ($urlIssues === 0) { echo " OK: No hardcoded URLs found\n"; }
echo "\n--- Hardcoded URLs ---\n";
$codeFiles = array_merge($this->findFiles($srcDir, '*.css'), $this->findFiles($srcDir, '*.js'));
$codeFiles = array_filter($codeFiles, function ($f) {
return !preg_match('/\.min\.(css|js)$/', $f);
});
$urlPatterns = [
'/https?:\/\/clarksvillefurs\.com/' => 'hardcoded production URL',
'/https?:\/\/[a-z]+\.dev\.mokoconsulting\.tech/' => 'hardcoded dev URL',
'/https?:\/\/localhost/' => 'localhost reference',
];
$urlIssues = 0;
foreach ($codeFiles as $file) {
$content = file_get_contents($file);
$relPath = str_replace($root . '/', '', $file);
foreach ($urlPatterns as $pattern => $desc) {
if (preg_match_all($pattern, $content, $matches)) {
$count = count($matches[0]);
echo " WARN: {$relPath}: {$count} {$desc}\n";
$urlIssues++;
$warnings++;
}
}
}
if ($urlIssues === 0) {
echo " OK: No hardcoded URLs found\n";
}
echo "\n=== Summary ===\n";
echo "Errors: {$errors}\n";
echo "Warnings: {$warnings}\n";
echo "\n=== Summary ===\n";
echo "Errors: {$errors}\n";
echo "Warnings: {$warnings}\n";
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
}
}
if ($ghOutput) {
$ghFile = getenv('GITHUB_OUTPUT');
if ($ghFile) {
file_put_contents($ghFile, "lint_errors={$errors}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_warnings={$warnings}\n", FILE_APPEND);
file_put_contents($ghFile, "lint_images=" . count($images) . "\n", FILE_APPEND);
file_put_contents($ghFile, "lint_css=" . count($cssToCheck) . "\n", FILE_APPEND);
}
}
if ($errors > 0) { return 1; }
if ($strict && $warnings > 0) { return 1; }
return 0;
}
if ($errors > 0) {
return 1;
}
if ($strict && $warnings > 0) {
return 1;
}
return 0;
}
private function findFiles(string $dir, string $pattern): array
{
$results = [];
if (!is_dir($dir)) { return $results; }
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (fnmatch($pattern, $file->getFilename())) {
$results[] = $file->getPathname();
}
}
return $results;
}
private function findFiles(string $dir, string $pattern): array
{
$results = [];
if (!is_dir($dir)) {
return $results;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (fnmatch($pattern, $file->getFilename())) {
$results[] = $file->getPathname();
}
}
return $results;
}
}
$app = new ThemeLintCli();
+24 -8
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -94,7 +95,8 @@ class UpdatesXmlSyncCli extends CliFramework
$discovered = [];
foreach ($branchList as $b) {
$name = $b['name'] ?? '';
if ($name !== '' && $name !== $current
if (
$name !== '' && $name !== $current
&& !str_starts_with($name, 'version/')
&& !str_starts_with($name, 'feature/')
&& !str_starts_with($name, 'patch/')
@@ -144,8 +146,14 @@ class UpdatesXmlSyncCli extends CliFramework
continue;
}
$ok = $this->putFile($apiBase, $token, $branch, $encoded, $sha,
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]");
$ok = $this->putFile(
$apiBase,
$token,
$branch,
$encoded,
$sha,
"chore: sync updates.xml{$vLabel} from {$current} [skip ci]"
);
if ($ok) {
$this->log('INFO', "Synced to {$branch}");
@@ -166,9 +174,14 @@ class UpdatesXmlSyncCli extends CliFramework
return $resp['sha'] ?? null;
}
private function putFile(string $apiBase, string $token, string $branch,
string $encoded, string $sha, string $msg): bool
{
private function putFile(
string $apiBase,
string $token,
string $branch,
string $encoded,
string $sha,
string $msg
): bool {
$resp = $this->apiCall('PUT', "{$apiBase}/contents/updates.xml", $token, [
'content' => $encoded,
'sha' => $sha,
@@ -194,8 +207,11 @@ class UpdatesXmlSyncCli extends CliFramework
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
if ($data !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS,
json_encode($data, JSON_UNESCAPED_SLASHES));
curl_setopt(
$ch,
CURLOPT_POSTFIELDS,
json_encode($data, JSON_UNESCAPED_SLASHES)
);
}
$body = curl_exec($ch);
+35 -9
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -82,8 +83,11 @@ class VersionAutoBumpCli extends CliFramework
$shouldBump = true;
if (!empty($watchPath)) {
$root = realpath($path) ?: $path;
$cdCmd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$diffOutput = trim((string) @shell_exec(
(PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --name-only HEAD~1 HEAD -- " . escapeshellarg($watchPath) . " 2>/dev/null"
$cdCmd . escapeshellarg($root)
. " && git diff --name-only HEAD~1 HEAD -- "
. escapeshellarg($watchPath) . " 2>/dev/null"
));
if (empty($diffOutput)) {
echo "No changes in {$watchPath} — skipping version bump\n";
@@ -148,28 +152,50 @@ class VersionAutoBumpCli extends CliFramework
$root = realpath($path) ?: $path;
// Check if anything changed
$diffStatus = trim((string) @shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git diff --quiet && git diff --cached --quiet 2>&1 && echo clean || echo dirty"));
$cdPrefix = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$diffStatus = trim((string) @shell_exec(
$cdPrefix . escapeshellarg($root)
. " && git diff --quiet && git diff --cached --quiet"
. " 2>&1 && echo clean || echo dirty"
));
if ($diffStatus === 'clean') {
echo "No version changes to commit\n";
return 0;
}
// Configure git
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.email \"gitea-actions[bot]@mokoconsulting.tech\"");
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git config --local user.name \"gitea-actions[bot]\"");
$cd = PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ";
$cdRoot = $cd . escapeshellarg($root);
@shell_exec(
$cdRoot . " && git config --local user.email"
. " \"gitea-actions[bot]@mokoconsulting.tech\""
);
@shell_exec(
$cdRoot . " && git config --local user.name"
. " \"gitea-actions[bot]\""
);
if (!empty($repoUrl)) {
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git remote set-url origin " . escapeshellarg($repoUrl));
@shell_exec(
$cdRoot . " && git remote set-url origin "
. escapeshellarg($repoUrl)
);
}
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git add -A");
@shell_exec($cdRoot . " && git add -A");
$commitMsg = $shouldBump
? "chore(version): auto-bump patch {$displayVersion} [skip ci]"
: "chore(version): set {$stability} suffix {$displayVersion} [skip ci]";
@shell_exec((PHP_OS_FAMILY === 'Windows' ? "cd /d " : "cd ") . escapeshellarg($root) . " && git commit -m " . escapeshellarg($commitMsg)
. " --author=\"gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>\"");
@shell_exec(
$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 "Bumped to {$displayVersion}\n";
+227 -64
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,71 +21,233 @@ use MokoEnterprise\CliFramework;
class VersionBumpCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--minor', 'Bump minor version', false);
$this->addArgument('--major', 'Bump major version', false);
}
protected function configure(): void
{
$this->setDescription('Auto-increment version -- manifest.xml is canonical, cascades to all XML and MD files');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--minor', 'Bump minor version', false);
$this->addArgument('--major', 'Bump major version', false);
}
protected function run(): int
{
$path = $this->getArgument('--path'); $type = 'patch';
if ($this->getArgument('--minor')) $type = 'minor';
if ($this->getArgument('--major')) $type = 'major';
$root = realpath($path) ?: $path;
$mokoVersion = null; $mokoManifest = "{$root}/.mokogitea/manifest.xml"; $mokoContent = '';
if (file_exists($mokoManifest)) { $mokoContent = file_get_contents($mokoManifest); if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?</version>#', $mokoContent, $m)) { $mokoVersion = $m[1]; } }
$readmeVersion = null; $readme = "{$root}/README.md"; $readmeContent = '';
if (file_exists($readme)) { $readmeContent = file_get_contents($readme); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) { $readmeVersion = $m[1]; } }
$manifestVersion = null;
$manifestFiles = array_merge(glob("{$root}/src/pkg_*.xml") ?: [], glob("{$root}/src/*.xml") ?: [], glob("{$root}/src/packages/*/mokowaas.xml") ?: [], glob("{$root}/src/packages/*/*.xml") ?: [], glob("{$root}/*.xml") ?: []);
foreach ($manifestFiles as $xmlFile) { $xmlContent = file_get_contents($xmlFile); if (strpos($xmlContent, '<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; } } }
$baseVersion = null; $candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
foreach ($candidates as $v) { if ($baseVersion === null || version_compare($v, $baseVersion, '>')) { $baseVersion = $v; } }
if ($baseVersion === null) { $this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML"); return 1; }
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) { $this->log('ERROR', "Invalid version format: {$baseVersion}"); return 1; }
$major = (int)$parts[1]; $minor = (int)$parts[2]; $patch = (int)$parts[3];
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
switch ($type) { case 'major': $major++; $minor = 0; $patch = 0; break; case 'minor': $minor++; $patch = 0; break; default: $patch++; if ($patch > 99) { $minor++; $patch = 0; } if ($minor > 99) { $major++; $minor = 0; } break; }
$newFull = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
if (file_exists($mokoManifest) && !empty($mokoContent)) { $updated = preg_replace('#<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); } }
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; } $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 (!empty($updatedFiles)) { fwrite(STDERR, "Updated " . count($updatedFiles) . " Joomla manifest(s): " . implode(', ', $updatedFiles) . "\n"); }
$packageJsonFile = "{$root}/package.json";
if (file_exists($packageJsonFile)) { $pkgContent = file_get_contents($packageJsonFile); $updatedPkg = preg_replace('/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $newFull . '${2}', $pkgContent); if ($updatedPkg !== $pkgContent) { file_put_contents($packageJsonFile, $updatedPkg); fwrite(STDERR, "Updated package.json\n"); } }
$pyprojectFile = "{$root}/pyproject.toml";
if (file_exists($pyprojectFile)) { $pyContent = file_get_contents($pyprojectFile); $updatedPy = preg_replace('/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $newFull . '${2}', $pyContent); if ($updatedPy !== $pyContent) { file_put_contents($pyprojectFile, $updatedPy); fwrite(STDERR, "Updated pyproject.toml\n"); } }
$changelogFile = "{$root}/CHANGELOG.md";
if (file_exists($changelogFile)) { $clContent = file_get_contents($changelogFile); $updatedCl = preg_replace('/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m', '${1}' . $newFull, $clContent); if ($updatedCl !== null && $updatedCl !== $clContent) { file_put_contents($changelogFile, $updatedCl); fwrite(STDERR, "Updated CHANGELOG.md\n"); } }
$scanExtensions = ['php', 'yml', 'yaml', 'md', 'txt', 'xml', 'sh', 'toml', 'ini', 'css', 'js'];
$excludeDirs = ['.git', 'vendor', 'node_modules', 'build', 'dist', '.claude'];
$versionPattern = '/(VERSION:\s*)\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?/m';
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excludeDirs) { if ($current->isDir() && in_array($current->getFilename(), $excludeDirs, true)) { return false; } return true; });
$iterator = new RecursiveIteratorIterator($filter);
$genericUpdated = [];
foreach ($iterator as $fileInfo) {
if ($fileInfo->isDir()) { continue; }
$ext = strtolower($fileInfo->getExtension()); if (!in_array($ext, $scanExtensions, true)) { continue; }
$filePath = $fileInfo->getPathname(); $relPath = str_replace([$root . '/', $root . '\\'], '', $filePath);
if (in_array($relPath, ['README.md', 'CHANGELOG.md', 'package.json', 'pyproject.toml'], true)) { continue; }
if (in_array($relPath, $updatedFiles ?? [], true)) { continue; }
if (strpos($relPath, '.mokogitea/manifest.xml') !== false) { continue; }
$content = @file_get_contents($filePath); if ($content === false) { continue; }
if (preg_match('/^#\s*REPO:\s*https?:\/\//m', $content)) { continue; }
$updated = preg_replace($versionPattern, '${1}' . $newFull, $content);
if ($updated !== null && $updated !== $content) { file_put_contents($filePath, $updated); $genericUpdated[] = $relPath; }
}
if (!empty($genericUpdated)) { fwrite(STDERR, "Updated VERSION: in " . count($genericUpdated) . " file(s): " . implode(', ', $genericUpdated) . "\n"); }
echo "{$old} -> {$newFull}\n";
return 0;
}
protected function run(): int
{
$path = $this->getArgument('--path');
$type = 'patch';
if ($this->getArgument('--minor')) {
$type = 'minor';
}
if ($this->getArgument('--major')) {
$type = 'major';
}
$root = realpath($path) ?: $path;
$mokoVersion = null;
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
$mokoContent = '';
if (file_exists($mokoManifest)) {
$mokoContent = file_get_contents($mokoManifest);
if (preg_match('#<version>(\d{2}\.\d{2}\.\d{2})(?:-((?:(?:dev|alpha|beta|rc)-?)+))?</version>#', $mokoContent, $m)) {
$mokoVersion = $m[1];
}
}
$readmeVersion = null;
$readme = "{$root}/README.md";
$readmeContent = '';
if (file_exists($readme)) {
$readmeContent = file_get_contents($readme);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $readmeContent, $m)) {
$readmeVersion = $m[1];
}
}
$manifestVersion = null;
$manifestFiles = array_merge(
glob("{$root}/src/pkg_*.xml") ?: [],
glob("{$root}/src/*.xml") ?: [],
glob("{$root}/src/packages/*/mokowaas.xml") ?: [],
glob("{$root}/src/packages/*/*.xml") ?: [],
glob("{$root}/*.xml") ?: []
);
foreach ($manifestFiles as $xmlFile) {
$xmlContent = file_get_contents($xmlFile);
if (strpos($xmlContent, '<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;
}
}
}
$baseVersion = null;
$candidates = array_filter([$mokoVersion, $readmeVersion, $manifestVersion]);
foreach ($candidates as $v) {
if ($baseVersion === null || version_compare($v, $baseVersion, '>')) {
$baseVersion = $v;
}
}
if ($baseVersion === null) {
$this->log('ERROR', "No version found in manifest.xml, README.md, or Joomla XML");
return 1;
}
if (!preg_match('/^(\d{2})\.(\d{2})\.(\d{2})$/', $baseVersion, $parts)) {
$this->log('ERROR', "Invalid version format: {$baseVersion}");
return 1;
}
$major = (int)$parts[1];
$minor = (int)$parts[2];
$patch = (int)$parts[3];
$old = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
switch ($type) {
case 'major':
$major++;
$minor = 0;
$patch = 0;
break;
case 'minor':
$minor++;
$patch = 0;
break;
default:
$patch++;
if ($patch > 99) {
$minor++;
$patch = 0;
} if ($minor > 99) {
$major++;
$minor = 0;
}
break;
}
$newFull = sprintf('%02d.%02d.%02d', $major, $minor, $patch);
if (file_exists($mokoManifest) && !empty($mokoContent)) {
$pattern = '#<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();
+171 -85
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -20,95 +21,180 @@ use MokoEnterprise\CliFramework;
class VersionBumpRemoteCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--branch', 'Target branch to bump (required)', null);
$this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor');
$this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null);
$this->addArgument('--api-base', 'Gitea API base URL for the repo', null);
$this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false);
$this->addArgument('--repo', 'Repository path (owner/repo)', null);
$this->addArgument('--gitea-url', 'Gitea instance URL', null);
}
protected function configure(): void
{
$this->setDescription('Bump version in manifest XML and CHANGELOG.md on a remote branch via Gitea API');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--branch', 'Target branch to bump (required)', null);
$this->addArgument('--bump', 'Bump type: patch | minor | major', 'minor');
$this->addArgument('--token', 'Gitea API token (or MOKOGITEA_TOKEN env var)', null);
$this->addArgument('--api-base', 'Gitea API base URL for the repo', null);
$this->addArgument('--no-changelog', 'Skip CHANGELOG.md bump', false);
$this->addArgument('--repo', 'Repository path (owner/repo)', null);
$this->addArgument('--gitea-url', 'Gitea instance URL', null);
}
protected function run(): int
{
$path = $this->getArgument('--path'); $branch = $this->getArgument('--branch');
$bumpType = $this->getArgument('--bump'); $token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base'); $noChangelog = (bool) $this->getArgument('--no-changelog');
$repo = $this->getArgument('--repo'); $giteaUrl = $this->getArgument('--gitea-url');
if ($token === null) $token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
if ($giteaUrl === null) $giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
if ($apiBase === null && $repo !== null) { $apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo; }
if ($branch === null || $token === null || $apiBase === null) {
$this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]");
return 1;
}
$root = realpath($path) ?: $path;
$version = null; $manifestFile = null;
foreach (["{$root}/src", $root] as $dir) {
if (!is_dir($dir)) continue;
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (strpos($xml, '<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";
protected function run(): int
{
$path = $this->getArgument('--path');
$branch = $this->getArgument('--branch');
$bumpType = $this->getArgument('--bump');
$token = $this->getArgument('--token');
$apiBase = $this->getArgument('--api-base');
$noChangelog = (bool) $this->getArgument('--no-changelog');
$repo = $this->getArgument('--repo');
$giteaUrl = $this->getArgument('--gitea-url');
if ($token === null) {
$token = getenv('MOKOGITEA_TOKEN') ?: getenv('GITEA_TOKEN') ?: null;
}
if ($giteaUrl === null) {
$giteaUrl = getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech';
}
if ($apiBase === null && $repo !== null) {
$apiBase = rtrim($giteaUrl, '/') . '/api/v1/repos/' . $repo;
}
if ($branch === null || $token === null || $apiBase === null) {
$this->log('ERROR', "Usage: version_bump_remote.php --branch BRANCH --token TOKEN --api-base URL [--bump minor|patch|major]");
return 1;
}
$root = realpath($path) ?: $path;
$version = null;
$manifestFile = null;
foreach (["{$root}/src", $root] as $dir) {
if (!is_dir($dir)) {
continue;
}
foreach (glob("{$dir}/*.xml") ?: [] as $f) {
$xml = file_get_contents($f);
if (strpos($xml, '<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 = [];
if ($manifestFile !== null) { $manifestPaths[] = "src/{$manifestFile}"; }
$manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']);
$manifestUpdated = false;
foreach ($manifestPaths as $mPath) {
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string { return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content); }, "chore(version): bump {$version} -> {$nextVersion} [skip ci]");
if ($result) { $manifestUpdated = true; break; }
}
if (!$manifestUpdated) { $this->log('WARN', "could not update manifest on {$branch}"); }
if (!$noChangelog) {
$this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string {
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) {
$marker = "## [{$version}]";
if (strpos($content, $marker) !== false) { $content = str_replace($marker, "## [{$nextVersion}] - Unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n\n" . $marker, $content); }
}
return $content;
}, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]");
}
return 0;
}
$manifestPaths = [];
if ($manifestFile !== null) {
$manifestPaths[] = "src/{$manifestFile}";
}
$manifestPaths = array_merge($manifestPaths, ['src/templateDetails.xml', 'src/manifest.xml']);
$manifestUpdated = false;
foreach ($manifestPaths as $mPath) {
$result = $this->updateRemoteFile($apiBase, $token, $mPath, $branch, function (string $content) use ($version, $nextVersion): string {
return str_replace("<version>{$version}</version>", "<version>{$nextVersion}</version>", $content);
}, "chore(version): bump {$version} -> {$nextVersion} [skip ci]");
if ($result) {
$manifestUpdated = true;
break;
}
}
if (!$manifestUpdated) {
$this->log('WARN', "could not update manifest on {$branch}");
}
if (!$noChangelog) {
$this->updateRemoteFile($apiBase, $token, 'CHANGELOG.md', $branch, function (string $content) use ($version, $nextVersion): string {
$content = str_replace("VERSION: {$version}", "VERSION: {$nextVersion}", $content);
if (strpos($content, '[Unreleased]') === false && strpos($content, "## [{$nextVersion}]") === false) {
$marker = "## [{$version}]";
if (strpos($content, $marker) !== false) {
$header = "## [{$nextVersion}] - Unreleased\n\n"
. "### Added\n\n### Changed\n\n"
. "### Fixed\n\n";
$content = str_replace(
$marker,
$header . $marker,
$content
);
}
}
return $content;
}, "chore(version): bump CHANGELOG {$version} -> {$nextVersion} [skip ci]");
}
return 0;
}
private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
{
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}", 'Content-Type: application/json'], CURLOPT_CUSTOMREQUEST => $method, CURLOPT_TIMEOUT => 30]);
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); }
$response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
if ($httpCode >= 400 || $response === false) { return null; }
return json_decode($response, true) ?: [];
}
private function giteaApi(string $method, string $url, string $token, ?string $body = null): ?array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: token {$token}",
'Content-Type: application/json',
],
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400 || $response === false) {
return null;
}
return json_decode($response, true) ?: [];
}
private function updateRemoteFile(string $apiBase, string $token, string $filePath, string $branch, callable $transform, string $commitMessage): bool
{
$file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token);
if ($file === null || !isset($file['sha']) || !isset($file['content'])) { return false; }
$content = base64_decode($file['content']); $newContent = $transform($content);
if ($newContent === $content) { $this->log('INFO', "{$filePath}: no changes needed"); return true; }
$payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]);
$result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
if ($result === null) { $this->log('ERROR', "{$filePath}: failed to update"); return false; }
echo " {$filePath}: updated on {$branch}\n"; return true;
}
private function updateRemoteFile(
string $apiBase,
string $token,
string $filePath,
string $branch,
callable $transform,
string $commitMessage
): bool {
$file = $this->giteaApi('GET', "{$apiBase}/contents/{$filePath}?ref={$branch}", $token);
if ($file === null || !isset($file['sha']) || !isset($file['content'])) {
return false;
}
$content = base64_decode($file['content']);
$newContent = $transform($content);
if ($newContent === $content) {
$this->log('INFO', "{$filePath}: no changes needed");
return true;
}
$payload = json_encode(['content' => base64_encode($newContent), 'sha' => $file['sha'], 'message' => $commitMessage, 'branch' => $branch]);
$result = $this->giteaApi('PUT', "{$apiBase}/contents/{$filePath}", $token, $payload);
if ($result === null) {
$this->log('ERROR', "{$filePath}: failed to update");
return false;
}
echo " {$filePath}: updated on {$branch}\n";
return true;
}
}
$app = new VersionBumpRemoteCli();
+175 -50
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -21,57 +22,181 @@ use MokoEnterprise\CliFramework;
class VersionCheckCli extends CliFramework
{
protected function configure(): void
{
$this->setDescription('Validate version consistency across README, manifests, and sub-packages');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--strict', 'Exit 1 on mismatch', false);
$this->addArgument('--fix', 'Fix mismatches to highest version', false);
}
protected function configure(): void
{
$this->setDescription('Validate version consistency across README, manifests, and sub-packages');
$this->addArgument('--path', 'Repository root', '.');
$this->addArgument('--strict', 'Exit 1 on mismatch', false);
$this->addArgument('--fix', 'Fix mismatches to highest version', false);
}
protected function run(): int
{
$path = $this->getArgument('--path'); $strict = (bool) $this->getArgument('--strict'); $fix = (bool) $this->getArgument('--fix');
$root = realpath($path) ?: $path; $errors = 0; $versions = [];
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) { $xml = @simplexml_load_file($mokoManifest); if ($xml !== false) { $v = (string)($xml->identity->version ?? ''); $base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v); if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) { $versions['.mokogitea/manifest.xml'] = $base; } } }
$readme = "{$root}/README.md";
if (file_exists($readme)) { $content = file_get_contents($readme); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { $versions['README.md'] = $m[1]; } }
$changelog = "{$root}/CHANGELOG.md";
if (file_exists($changelog)) { $content = file_get_contents($changelog); if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { $versions['CHANGELOG.md'] = $m[1]; } }
$packageJson = "{$root}/package.json";
if (file_exists($packageJson)) { $pkg = json_decode(file_get_contents($packageJson), true); if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) { $versions['package.json'] = $pkg['version']; } }
$pyproject = "{$root}/pyproject.toml";
if (file_exists($pyproject)) { $content = file_get_contents($pyproject); if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) { $versions['pyproject.toml'] = $m[1]; } }
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) {
foreach (glob($glob) ?: [] as $file) {
if (basename($file) === 'updates.xml') continue;
$xmlContent = file_get_contents($file); if (strpos($xmlContent, '<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); $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"; }
if (isset($versions['package.json']) && $versions['package.json'] !== $highestVersion) { $content = file_get_contents($packageJson); $updated = preg_replace('/("version"\s*:\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $highestVersion . '${2}', $content); if ($updated !== null) { file_put_contents($packageJson, $updated); } echo " Fixed: package.json -> {$highestVersion}\n"; }
if (isset($versions['pyproject.toml']) && $versions['pyproject.toml'] !== $highestVersion) { $content = file_get_contents($pyproject); $updated = preg_replace('/^(version\s*=\s*")\d{2}\.\d{2}\.\d{2}(?:(?:-(?:dev|alpha|beta|rc))+)?(")/m', '${1}' . $highestVersion . '${2}', $content); if ($updated !== null) { file_put_contents($pyproject, $updated); } echo " Fixed: pyproject.toml -> {$highestVersion}\n"; }
foreach ($versions as $source => $ver) { if (in_array($source, ['README.md', 'CHANGELOG.md', '.mokogitea/manifest.xml', 'package.json', 'pyproject.toml'], true)) continue; if ($ver === $highestVersion) continue; $file = "{$root}/{$source}"; if (!file_exists($file)) continue; $content = file_get_contents($file); $updated = preg_replace('#<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;
}
protected function run(): int
{
$path = $this->getArgument('--path');
$strict = (bool) $this->getArgument('--strict');
$fix = (bool) $this->getArgument('--fix');
$root = realpath($path) ?: $path;
$errors = 0;
$versions = [];
$mokoManifest = "{$root}/.mokogitea/manifest.xml";
if (file_exists($mokoManifest)) {
$xml = @simplexml_load_file($mokoManifest);
if ($xml !== false) {
$v = (string)($xml->identity->version ?? '');
$base = preg_replace('/(-(dev|alpha|beta|rc))+$/', '', $v);
if (preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $base)) {
$versions['.mokogitea/manifest.xml'] = $base;
}
}
}
$readme = "{$root}/README.md";
if (file_exists($readme)) {
$content = file_get_contents($readme);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$versions['README.md'] = $m[1];
}
}
$changelog = "{$root}/CHANGELOG.md";
if (file_exists($changelog)) {
$content = file_get_contents($changelog);
if (preg_match('/VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
$versions['CHANGELOG.md'] = $m[1];
}
}
$packageJson = "{$root}/package.json";
if (file_exists($packageJson)) {
$pkg = json_decode(file_get_contents($packageJson), true);
if (isset($pkg['version']) && preg_match('/^\d{2}\.\d{2}\.\d{2}$/', $pkg['version'])) {
$versions['package.json'] = $pkg['version'];
}
}
$pyproject = "{$root}/pyproject.toml";
if (file_exists($pyproject)) {
$content = file_get_contents($pyproject);
if (preg_match('/^version\s*=\s*"(\d{2}\.\d{2}\.\d{2})"/m', $content, $m)) {
$versions['pyproject.toml'] = $m[1];
}
}
foreach (["{$root}/src/pkg_*.xml", "{$root}/src/*.xml", "{$root}/src/packages/*/*.xml", "{$root}/*.xml"] as $glob) {
foreach (glob($glob) ?: [] as $file) {
if (basename($file) === 'updates.xml') {
continue;
}
$xmlContent = file_get_contents($file);
if (strpos($xmlContent, '<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();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
+7 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
@@ -204,7 +205,9 @@ class WikiSyncCli extends CliFramework
];
$ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx);
if ($result === false) return null;
if ($result === false) {
return null;
}
$data = json_decode($result, true);
return is_array($data) ? $data : null;
}
@@ -232,7 +235,9 @@ class WikiSyncCli extends CliFramework
];
$ctx = stream_context_create($opts);
$result = @file_get_contents($url, false, $ctx);
if ($result === false) return null;
if ($result === false) {
return null;
}
$data = json_decode($result, true);
return is_array($data) ? $data : null;
}
+137 -136
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -23,171 +24,171 @@ use MokoEnterprise\CliFramework;
class BackupBeforeDeployCli extends CliFramework
{
private const JOOMLA_DIRS = [
'administrator/components',
'administrator/language',
'administrator/modules',
'administrator/templates',
'components',
'language',
'layouts',
'libraries',
'media',
'modules',
'plugins',
'templates',
];
private const JOOMLA_DIRS = [
'administrator/components',
'administrator/language',
'administrator/modules',
'administrator/templates',
'components',
'language',
'layouts',
'libraries',
'media',
'modules',
'plugins',
'templates',
];
protected function configure(): void
{
$this->setDescription('Snapshot Joomla directories before deployment for rollback capability');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--output', 'Local output directory for snapshot', '');
}
protected function configure(): void
{
$this->setDescription('Snapshot Joomla directories before deployment for rollback capability');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--output', 'Local output directory for snapshot', '');
}
protected function run(): int
{
$configPath = $this->getArgument('--config');
$outputDir = $this->getArgument('--output');
protected function run(): int
{
$configPath = $this->getArgument('--config');
$outputDir = $this->getArgument('--output');
if ($configPath === '') {
$this->log('ERROR', 'Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
return 1;
}
if ($configPath === '') {
$this->log('ERROR', 'Usage: backup-before-deploy.php --config <sftp-config.json> [--output <local-dir>] [--verbose]');
return 1;
}
if ($outputDir === '') {
$outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
}
if ($outputDir === '') {
$outputDir = '/tmp/moko-snapshot-' . date('Ymd-His');
}
$config = $this->loadConfig($configPath);
if ($config === null) {
return 1;
}
$config = $this->loadConfig($configPath);
if ($config === null) {
return 1;
}
$host = $config['host'] ?? '';
$user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? '';
$host = $config['host'] ?? '';
$user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1;
}
if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1;
}
// Create output directory
if (!is_dir($outputDir)) {
if (!mkdir($outputDir, 0755, true)) {
$this->log('ERROR', "Could not create output directory: {$outputDir}");
return 1;
}
}
// Create output directory
if (!is_dir($outputDir)) {
if (!mkdir($outputDir, 0755, true)) {
$this->log('ERROR', "Could not create output directory: {$outputDir}");
return 1;
}
}
$this->log('INFO', 'Starting pre-deploy snapshot...');
$this->log('INFO', "Source: {$user}@{$host}:{$remotePath}");
$this->log('INFO', "Output: {$outputDir}");
$this->log('INFO', 'Starting pre-deploy snapshot...');
$this->log('INFO', "Source: {$user}@{$host}:{$remotePath}");
$this->log('INFO', "Output: {$outputDir}");
$failed = 0;
$failed = 0;
foreach (self::JOOMLA_DIRS as $dir) {
$remoteSource = "{$remotePath}/{$dir}/";
$localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/';
foreach (self::JOOMLA_DIRS as $dir) {
$remoteSource = "{$remotePath}/{$dir}/";
$localTarget = rtrim($outputDir, '/\\') . '/' . $dir . '/';
// Ensure local subdirectory exists
if (!is_dir($localTarget)) {
mkdir($localTarget, 0755, true);
}
// Ensure local subdirectory exists
if (!is_dir($localTarget)) {
mkdir($localTarget, 0755, true);
}
$sshCmd = "ssh -p {$port}";
if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey);
}
$sshCmd = "ssh -p {$port}";
if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey);
}
$cmd = $this->buildRsyncCommand(
$sshCmd,
"{$user}@{$host}:{$remoteSource}",
$localTarget
);
$cmd = $this->buildRsyncCommand(
$sshCmd,
"{$user}@{$host}:{$remoteSource}",
$localTarget
);
$this->log('INFO', "Downloading: {$dir}");
if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}");
}
$this->log('INFO', "Downloading: {$dir}");
if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}");
}
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) {
$this->log('ERROR', " {$line}");
}
$failed++;
} else {
if ($this->verbose) {
foreach ($output as $line) {
$this->log('INFO', " {$line}");
}
}
}
}
if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) {
$this->log('ERROR', " {$line}");
}
$failed++;
} else {
if ($this->verbose) {
foreach ($output as $line) {
$this->log('INFO', " {$line}");
}
}
}
}
if ($failed > 0) {
$this->log('ERROR', "Snapshot completed with {$failed} error(s).");
return 1;
}
if ($failed > 0) {
$this->log('ERROR', "Snapshot completed with {$failed} error(s).");
return 1;
}
$this->log('INFO', '');
$this->log('INFO', 'Snapshot completed successfully.');
$this->log('INFO', "SNAPSHOT_PATH={$outputDir}");
$this->log('INFO', '');
$this->log('INFO', 'To rollback, run:');
$this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}");
$this->log('INFO', '');
$this->log('INFO', 'Snapshot completed successfully.');
$this->log('INFO', "SNAPSHOT_PATH={$outputDir}");
$this->log('INFO', '');
$this->log('INFO', 'To rollback, run:');
$this->log('INFO', " php rollback-joomla.php --config {$configPath} --snapshot-dir {$outputDir}");
return 0;
}
return 0;
}
private function loadConfig(string $path): ?array
{
if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}");
return null;
}
private function loadConfig(string $path): ?array
{
if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}");
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}");
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}");
return null;
}
// Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true);
// Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true);
if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.');
return null;
}
if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.');
return null;
}
return $config;
}
return $config;
}
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
{
$parts = ['rsync', '-rlptz', '--exclude=configuration.php'];
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
{
$parts = ['rsync', '-rlptz', '--exclude=configuration.php'];
if ($this->verbose) {
$parts[] = '-v';
}
if ($this->verbose) {
$parts[] = '-v';
}
$parts[] = '-e';
$parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest);
$parts[] = '-e';
$parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest);
return implode(' ', $parts);
}
return implode(' ', $parts);
}
}
$app = new BackupBeforeDeployCli();
+204 -203
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -23,258 +24,258 @@ use MokoEnterprise\CliFramework;
class DeployDolibarrCli extends CliFramework
{
private string $source = '';
private string $source = '';
private const MODULE_DIRS = [
'core/modules',
'class',
'lib',
'sql',
'langs',
'css',
'js',
'img',
];
private const MODULE_DIRS = [
'core/modules',
'class',
'lib',
'sql',
'langs',
'css',
'js',
'img',
];
private const EXCLUDES = [
'.git/',
'vendor/',
'tests/',
'node_modules/',
];
private const EXCLUDES = [
'.git/',
'vendor/',
'tests/',
'node_modules/',
];
protected function configure(): void
{
$this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync');
$this->addArgument('--source', 'Local source directory', '');
$this->addArgument('--config', 'Path to sftp-config.json', '');
}
protected function configure(): void
{
$this->setDescription('Deploy Dolibarr module files to a remote server via SFTP/rsync');
$this->addArgument('--source', 'Local source directory', '');
$this->addArgument('--config', 'Path to sftp-config.json', '');
}
protected function run(): int
{
$configPath = $this->getArgument('--config');
$this->source = $this->getArgument('--source');
protected function run(): int
{
$configPath = $this->getArgument('--config');
$this->source = $this->getArgument('--source');
if ($configPath === '' || $this->source === '') {
$this->log('ERROR', 'Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
return 1;
}
if ($configPath === '' || $this->source === '') {
$this->log('ERROR', 'Usage: deploy-dolibarr.php --source <local-path> --config <sftp-config.json> [--dry-run] [--verbose]');
return 1;
}
if (!is_dir($this->source)) {
$this->log('ERROR', "Source directory does not exist: {$this->source}");
return 1;
}
if (!is_dir($this->source)) {
$this->log('ERROR', "Source directory does not exist: {$this->source}");
return 1;
}
$moduleName = $this->detectModuleName();
if ($moduleName === null) {
$this->log('ERROR', 'Could not auto-detect module name. Expected core/modules/mod*.class.php');
return 1;
}
$moduleName = $this->detectModuleName();
if ($moduleName === null) {
$this->log('ERROR', 'Could not auto-detect module name. Expected core/modules/mod*.class.php');
return 1;
}
$config = $this->loadConfig($configPath);
if ($config === null) {
return 1;
}
$config = $this->loadConfig($configPath);
if ($config === null) {
return 1;
}
$host = $config['host'] ?? '';
$user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? '';
$host = $config['host'] ?? '';
$user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1;
}
if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1;
}
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
$remoteBase = "{$remotePath}/htdocs/custom/{$moduleName}";
$this->log('INFO', "Deploying Dolibarr module: {$moduleName}");
$this->log('INFO', "Source: {$this->source}");
$this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}");
$this->log('INFO', "Deploying Dolibarr module: {$moduleName}");
$this->log('INFO', "Source: {$this->source}");
$this->log('INFO', "Target: {$user}@{$host}:{$remoteBase}");
if ($this->dryRun) {
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
}
if ($this->dryRun) {
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
}
$failed = 0;
$failed = 0;
// Deploy subdirectories
foreach (self::MODULE_DIRS as $dir) {
$localDir = rtrim($this->source, '/\\') . '/' . $dir . '/';
// Deploy subdirectories
foreach (self::MODULE_DIRS as $dir) {
$localDir = rtrim($this->source, '/\\') . '/' . $dir . '/';
if (!is_dir($localDir)) {
if ($this->verbose) {
$this->log('INFO', "SKIP: {$dir} (not present in source)");
}
continue;
}
if (!is_dir($localDir)) {
if ($this->verbose) {
$this->log('INFO', "SKIP: {$dir} (not present in source)");
}
continue;
}
$remoteTarget = "{$remoteBase}/{$dir}/";
$result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey);
$remoteTarget = "{$remoteBase}/{$dir}/";
$result = $this->rsyncDir($localDir, $remoteTarget, $host, $user, $port, $sshKey);
if (!$result) {
$failed++;
}
}
if (!$result) {
$failed++;
}
}
// Deploy root PHP files
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
if (!empty($rootPhpFiles)) {
$this->log('INFO', 'Syncing root PHP files...');
$sourceRoot = rtrim($this->source, '/\\') . '/';
$remoteTarget = "{$remoteBase}/";
// Deploy root PHP files
$rootPhpFiles = glob(rtrim($this->source, '/\\') . '/*.php');
if (!empty($rootPhpFiles)) {
$this->log('INFO', 'Syncing root PHP files...');
$sourceRoot = rtrim($this->source, '/\\') . '/';
$remoteTarget = "{$remoteBase}/";
$sshCmd = "ssh -p {$port}";
if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey);
}
$sshCmd = "ssh -p {$port}";
if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey);
}
$cmd = $this->buildRsyncCommand(
$sshCmd,
$sourceRoot,
"{$user}@{$host}:{$remoteTarget}",
['--include=*.php', '--exclude=*/', '--exclude=.*']
);
$cmd = $this->buildRsyncCommand(
$sshCmd,
$sourceRoot,
"{$user}@{$host}:{$remoteTarget}",
['--include=*.php', '--exclude=*/', '--exclude=.*']
);
if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}");
}
if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}");
}
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})");
foreach ($output as $line) {
$this->log('ERROR', " {$line}");
}
$failed++;
} else {
if ($this->verbose) {
foreach ($output as $line) {
$this->log('INFO', " {$line}");
}
}
}
}
if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for root PHP files (exit code {$exitCode})");
foreach ($output as $line) {
$this->log('ERROR', " {$line}");
}
$failed++;
} else {
if ($this->verbose) {
foreach ($output as $line) {
$this->log('INFO', " {$line}");
}
}
}
}
if ($failed > 0) {
$this->log('ERROR', "Deployment completed with {$failed} error(s).");
return 1;
}
if ($failed > 0) {
$this->log('ERROR', "Deployment completed with {$failed} error(s).");
return 1;
}
$this->log('INFO', 'Deployment completed successfully.');
return 0;
}
$this->log('INFO', 'Deployment completed successfully.');
return 0;
}
private function detectModuleName(): ?string
{
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
$matches = glob($pattern);
private function detectModuleName(): ?string
{
$pattern = rtrim($this->source, '/\\') . '/core/modules/mod*.class.php';
$matches = glob($pattern);
if (empty($matches)) {
return null;
}
if (empty($matches)) {
return null;
}
$filename = basename($matches[0]);
// mod{ModuleName}.class.php -> extract ModuleName, lowercase it
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
return strtolower($m[1]);
}
$filename = basename($matches[0]);
// mod{ModuleName}.class.php -> extract ModuleName, lowercase it
if (preg_match('/^mod(.+)\.class\.php$/', $filename, $m)) {
return strtolower($m[1]);
}
return null;
}
return null;
}
private function loadConfig(string $path): ?array
{
if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}");
return null;
}
private function loadConfig(string $path): ?array
{
if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}");
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}");
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}");
return null;
}
// Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true);
// Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true);
if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.');
return null;
}
if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.');
return null;
}
return $config;
}
return $config;
}
private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool
{
$dirName = basename(rtrim($localDir, '/'));
$sshCmd = "ssh -p {$port}";
if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey);
}
private function rsyncDir(string $localDir, string $remoteTarget, string $host, string $user, int $port, string $sshKey): bool
{
$dirName = basename(rtrim($localDir, '/'));
$sshCmd = "ssh -p {$port}";
if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey);
}
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$this->log('INFO', "Syncing: {$dirName}");
if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}");
}
$this->log('INFO', "Syncing: {$dirName}");
if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}");
}
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})");
foreach ($output as $line) {
$this->log('ERROR', " {$line}");
}
return false;
}
if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dirName} (exit code {$exitCode})");
foreach ($output as $line) {
$this->log('ERROR', " {$line}");
}
return false;
}
if ($this->verbose) {
foreach ($output as $line) {
$this->log('INFO', " {$line}");
}
}
if ($this->verbose) {
foreach ($output as $line) {
$this->log('INFO', " {$line}");
}
}
return true;
}
return true;
}
private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string
{
$parts = ['rsync', '-rlptz', '--delete'];
private function buildRsyncCommand(string $sshCmd, string $source, string $dest, array $extraArgs = []): string
{
$parts = ['rsync', '-rlptz', '--delete'];
foreach (self::EXCLUDES as $exclude) {
$parts[] = '--exclude=' . $exclude;
}
foreach (self::EXCLUDES as $exclude) {
$parts[] = '--exclude=' . $exclude;
}
foreach ($extraArgs as $arg) {
$parts[] = $arg;
}
foreach ($extraArgs as $arg) {
$parts[] = $arg;
}
if ($this->dryRun) {
$parts[] = '--dry-run';
}
if ($this->dryRun) {
$parts[] = '--dry-run';
}
if ($this->verbose) {
$parts[] = '-v';
}
if ($this->verbose) {
$parts[] = '-v';
}
$parts[] = '-e';
$parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest);
$parts[] = '-e';
$parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest);
return implode(' ', $parts);
}
return implode(' ', $parts);
}
}
$app = new DeployDolibarrCli();
+440 -439
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -68,499 +69,499 @@ use phpseclib3\Crypt\PublicKeyLoader;
*/
class DeployJoomlaCli extends CliFramework
{
private string $repoPath = '.';
private string $srcDir = 'src';
private array $config = [];
private int $uploaded = 0;
private int $unchanged = 0;
private int $skipped = 0;
private int $deleted = 0;
private array $ignorePatterns = [];
private string $repoPath = '.';
private string $srcDir = 'src';
private array $config = [];
private int $uploaded = 0;
private int $unchanged = 0;
private int $skipped = 0;
private int $deleted = 0;
private array $ignorePatterns = [];
protected function configure(): void
{
$this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--src-dir', 'Source directory relative to path', 'src');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--key-passphrase', 'SSH key passphrase', '');
}
protected function configure(): void
{
$this->setDescription('Smart Joomla deploy — routes files to correct Joomla directories based on XML manifest');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--src-dir', 'Source directory relative to path', 'src');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--key-passphrase', 'SSH key passphrase', '');
}
protected function run(): int
{
$this->repoPath = $this->getArgument('--path');
$this->srcDir = $this->getArgument('--src-dir');
$configPath = $this->getArgument('--config');
$keyPassphrase = $this->getArgument('--key-passphrase');
protected function run(): int
{
$this->repoPath = $this->getArgument('--path');
$this->srcDir = $this->getArgument('--src-dir');
$configPath = $this->getArgument('--config');
$keyPassphrase = $this->getArgument('--key-passphrase');
if ($keyPassphrase !== '') {
$this->config['key_passphrase'] = $keyPassphrase;
}
if ($keyPassphrase !== '') {
$this->config['key_passphrase'] = $keyPassphrase;
}
$this->repoPath = realpath($this->repoPath) ?: $this->repoPath;
$this->repoPath = realpath($this->repoPath) ?: $this->repoPath;
// Resolve src dir
if (!str_starts_with($this->srcDir, '/')) {
$this->srcDir = "{$this->repoPath}/{$this->srcDir}";
}
// Try htdocs/ as fallback
if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) {
$this->srcDir = "{$this->repoPath}/htdocs";
}
// Resolve src dir
if (!str_starts_with($this->srcDir, '/')) {
$this->srcDir = "{$this->repoPath}/{$this->srcDir}";
}
// Try htdocs/ as fallback
if (!is_dir($this->srcDir) && is_dir("{$this->repoPath}/htdocs")) {
$this->srcDir = "{$this->repoPath}/htdocs";
}
// Load config
if ($configPath !== '' && file_exists($configPath)) {
$json = file_get_contents($configPath);
$json = preg_replace('#^\s*//.*$#m', '', $json);
$json = preg_replace('#,\s*([\]}])#', '$1', $json);
$parsed = json_decode($json, true);
if (is_array($parsed)) {
$this->config = array_merge($this->config, $parsed);
}
}
// Load config
if ($configPath !== '' && file_exists($configPath)) {
$json = file_get_contents($configPath);
$json = preg_replace('#^\s*//.*$#m', '', $json);
$json = preg_replace('#,\s*([\]}])#', '$1', $json);
$parsed = json_decode($json, true);
if (is_array($parsed)) {
$this->config = array_merge($this->config, $parsed);
}
}
$manifest = $this->findManifest();
if ($manifest === null) {
$this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}");
return 1;
}
$manifest = $this->findManifest();
if ($manifest === null) {
$this->log('ERROR', "No Joomla XML manifest found in {$this->srcDir}");
return 1;
}
$ext = $this->parseManifest($manifest);
if ($ext === null) {
$this->log('ERROR', "Failed to parse manifest: {$manifest}");
return 1;
}
$ext = $this->parseManifest($manifest);
if ($ext === null) {
$this->log('ERROR', "Failed to parse manifest: {$manifest}");
return 1;
}
$this->log('INFO', "Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})");
$this->log('INFO', "Extension: {$ext['type']} / {$ext['element']} (client: {$ext['client']})");
$deployMap = $this->buildDeployMap($ext);
if (empty($deployMap)) {
$this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}");
return 1;
}
$deployMap = $this->buildDeployMap($ext);
if (empty($deployMap)) {
$this->log('ERROR', "No deploy mappings for extension type: {$ext['type']}");
return 1;
}
$this->log('INFO', "Deploy mappings:");
foreach ($deployMap as $map) {
$this->log('INFO', " {$map['local']} -> {$map['remote']}");
}
$this->log('INFO', "Deploy mappings:");
foreach ($deployMap as $map) {
$this->log('INFO', " {$map['local']} -> {$map['remote']}");
}
// Load ignore patterns
$this->ignorePatterns = $this->loadFtpIgnore();
// Load ignore patterns
$this->ignorePatterns = $this->loadFtpIgnore();
// Check if manifest changed (warn user about reinstall)
$this->checkManifestChange($ext, $manifest);
// Check if manifest changed (warn user about reinstall)
$this->checkManifestChange($ext, $manifest);
if ($this->dryRun) {
$this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings");
foreach ($deployMap as $map) {
if (is_dir($map['local'])) {
$count = iterator_count(
new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS)
)
);
$this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}");
}
}
return 0;
}
if ($this->dryRun) {
$this->log('INFO', "[DRY RUN] Would deploy " . count($deployMap) . " mappings");
foreach ($deployMap as $map) {
if (is_dir($map['local'])) {
$count = iterator_count(
new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($map['local'], \FilesystemIterator::SKIP_DOTS)
)
);
$this->log('INFO', " {$map['local']} ({$count} files) -> {$map['remote']}");
}
}
return 0;
}
// Connect
$sftp = $this->connectSftp();
if ($sftp === null) {
return 1;
}
// Connect
$sftp = $this->connectSftp();
if ($sftp === null) {
return 1;
}
// Deploy each mapping
$errors = 0;
foreach ($deployMap as $map) {
if (!is_dir($map['local'])) {
if ($this->verbose) {
$this->log('INFO', " SKIP: {$map['local']} (directory not found)");
}
continue;
}
// Deploy each mapping
$errors = 0;
foreach ($deployMap as $map) {
if (!is_dir($map['local'])) {
if ($this->verbose) {
$this->log('INFO', " SKIP: {$map['local']} (directory not found)");
}
continue;
}
// Ensure remote directory exists
$stat = @$sftp->stat($map['remote']);
if ($stat === false) {
$this->log('INFO', " MKDIR: {$map['remote']}");
$sftp->mkdir($map['remote'], -1, true);
}
// Ensure remote directory exists
$stat = @$sftp->stat($map['remote']);
if ($stat === false) {
$this->log('INFO', " MKDIR: {$map['remote']}");
$sftp->mkdir($map['remote'], -1, true);
}
$result = $this->uploadDirectory($sftp, $map['local'], $map['remote']);
if ($result !== 0) {
$errors++;
}
}
$result = $this->uploadDirectory($sftp, $map['local'], $map['remote']);
if ($result !== 0) {
$errors++;
}
}
// Also deploy the manifest file itself to the admin component directory
if ($ext['type'] === 'component' && file_exists($manifest)) {
$adminRemote = $this->getRemotePath($ext, 'admin');
$manifestName = basename($manifest);
$remoteDest = "{$adminRemote}/{$manifestName}";
$this->uploadFile($sftp, $manifest, $remoteDest);
$this->log('INFO', " Manifest: {$manifestName} -> {$remoteDest}");
}
// Also deploy the manifest file itself to the admin component directory
if ($ext['type'] === 'component' && file_exists($manifest)) {
$adminRemote = $this->getRemotePath($ext, 'admin');
$manifestName = basename($manifest);
$remoteDest = "{$adminRemote}/{$manifestName}";
$this->uploadFile($sftp, $manifest, $remoteDest);
$this->log('INFO', " Manifest: {$manifestName} -> {$remoteDest}");
}
$this->log('INFO', "Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}");
$this->log('INFO', "Done. Uploaded: {$this->uploaded}, Unchanged: {$this->unchanged}, Skipped: {$this->skipped}");
return $errors > 0 ? 1 : 0;
}
return $errors > 0 ? 1 : 0;
}
/**
* Find the Joomla XML manifest file.
*/
private function findManifest(): ?string
{
$searchDirs = [$this->srcDir, $this->repoPath];
foreach ($searchDirs as $dir) {
$iterator = new \DirectoryIterator($dir);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'xml') {
$content = file_get_contents($file->getPathname());
if ($content !== false && str_contains($content, '<extension')) {
return $file->getPathname();
}
}
}
// Also check one level deep
foreach (new \DirectoryIterator($dir) as $subdir) {
if ($subdir->isDir() && !$subdir->isDot()) {
foreach (new \DirectoryIterator($subdir->getPathname()) as $file) {
if ($file->isFile() && $file->getExtension() === 'xml') {
$content = file_get_contents($file->getPathname());
if ($content !== false && str_contains($content, '<extension')) {
return $file->getPathname();
}
}
}
}
}
}
return null;
}
/**
* Find the Joomla XML manifest file.
*/
private function findManifest(): ?string
{
$searchDirs = [$this->srcDir, $this->repoPath];
foreach ($searchDirs as $dir) {
$iterator = new \DirectoryIterator($dir);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'xml') {
$content = file_get_contents($file->getPathname());
if ($content !== false && str_contains($content, '<extension')) {
return $file->getPathname();
}
}
}
// Also check one level deep
foreach (new \DirectoryIterator($dir) as $subdir) {
if ($subdir->isDir() && !$subdir->isDot()) {
foreach (new \DirectoryIterator($subdir->getPathname()) as $file) {
if ($file->isFile() && $file->getExtension() === 'xml') {
$content = file_get_contents($file->getPathname());
if ($content !== false && str_contains($content, '<extension')) {
return $file->getPathname();
}
}
}
}
}
}
return null;
}
/**
* Parse extension metadata from the XML manifest.
*
* @return array{type: string, element: string, client: string, group: string, name: string}|null
*/
private function parseManifest(string $path): ?array
{
$xml = @simplexml_load_file($path);
if ($xml === false || $xml->getName() !== 'extension') {
return null;
}
/**
* Parse extension metadata from the XML manifest.
*
* @return array{type: string, element: string, client: string, group: string, name: string}|null
*/
private function parseManifest(string $path): ?array
{
$xml = @simplexml_load_file($path);
if ($xml === false || $xml->getName() !== 'extension') {
return null;
}
$type = (string) ($xml['type'] ?? 'component');
$client = (string) ($xml['client'] ?? 'site');
$group = (string) ($xml['group'] ?? '');
$element = (string) ($xml->element ?? '');
$name = (string) ($xml->name ?? '');
$type = (string) ($xml['type'] ?? 'component');
$client = (string) ($xml['client'] ?? 'site');
$group = (string) ($xml['group'] ?? '');
$element = (string) ($xml->element ?? '');
$name = (string) ($xml->name ?? '');
// Derive element from type + name if not explicit
if (empty($element)) {
$cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name));
$element = match ($type) {
'component' => "com_{$cleanName}",
'module' => "mod_{$cleanName}",
'plugin' => "plg_{$group}_{$cleanName}",
'template' => "tpl_{$cleanName}",
'library' => "lib_{$cleanName}",
default => $cleanName,
};
}
// Derive element from type + name if not explicit
if (empty($element)) {
$cleanName = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '', $name));
$element = match ($type) {
'component' => "com_{$cleanName}",
'module' => "mod_{$cleanName}",
'plugin' => "plg_{$group}_{$cleanName}",
'template' => "tpl_{$cleanName}",
'library' => "lib_{$cleanName}",
default => $cleanName,
};
}
// For plugins, derive the short name (without plg_group_ prefix)
$shortName = $element;
if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) {
$shortName = $m[1];
} elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) {
$shortName = $m[1];
}
// For plugins, derive the short name (without plg_group_ prefix)
$shortName = $element;
if ($type === 'plugin' && preg_match('/^plg_\w+_(.+)$/', $element, $m)) {
$shortName = $m[1];
} elseif ($type === 'template' && preg_match('/^tpl_(.+)$/', $element, $m)) {
$shortName = $m[1];
}
return [
'type' => $type,
'element' => $element,
'client' => $client,
'group' => $group,
'name' => $name,
'shortName' => $shortName,
];
}
return [
'type' => $type,
'element' => $element,
'client' => $client,
'group' => $group,
'name' => $name,
'shortName' => $shortName,
];
}
/**
* Build the local->remote deploy mapping based on extension type.
*
* @return array<int, array{local: string, remote: string}>
*/
private function buildDeployMap(array $ext): array
{
$remotePath = rtrim((string) $this->config['remote_path'], '/');
$src = $this->srcDir;
$map = [];
/**
* Build the local->remote deploy mapping based on extension type.
*
* @return array<int, array{local: string, remote: string}>
*/
private function buildDeployMap(array $ext): array
{
$remotePath = rtrim((string) $this->config['remote_path'], '/');
$src = $this->srcDir;
$map = [];
switch ($ext['type']) {
case 'component':
// Admin files
if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) {
$adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator";
$map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"];
}
// Site files
if (is_dir("{$src}/site")) {
$map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"];
}
// Media files
if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
}
// API files (Joomla 4+)
if (is_dir("{$src}/api")) {
$map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"];
}
// Language files (admin)
if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) {
$langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language";
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"];
}
// Language files (site)
if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) {
$langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language";
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"];
}
break;
switch ($ext['type']) {
case 'component':
// Admin files
if (is_dir("{$src}/admin") || is_dir("{$src}/administrator")) {
$adminLocal = is_dir("{$src}/admin") ? "{$src}/admin" : "{$src}/administrator";
$map[] = ['local' => $adminLocal, 'remote' => "{$remotePath}/administrator/components/{$ext['element']}"];
}
// Site files
if (is_dir("{$src}/site")) {
$map[] = ['local' => "{$src}/site", 'remote' => "{$remotePath}/components/{$ext['element']}"];
}
// Media files
if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
}
// API files (Joomla 4+)
if (is_dir("{$src}/api")) {
$map[] = ['local' => "{$src}/api", 'remote' => "{$remotePath}/api/components/{$ext['element']}"];
}
// Language files (admin)
if (is_dir("{$src}/language/admin") || is_dir("{$src}/admin/language")) {
$langDir = is_dir("{$src}/language/admin") ? "{$src}/language/admin" : "{$src}/admin/language";
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/administrator/language"];
}
// Language files (site)
if (is_dir("{$src}/language/site") || is_dir("{$src}/site/language")) {
$langDir = is_dir("{$src}/language/site") ? "{$src}/language/site" : "{$src}/site/language";
$map[] = ['local' => $langDir, 'remote' => "{$remotePath}/language"];
}
break;
case 'module':
$base = $ext['client'] === 'administrator'
? "{$remotePath}/administrator/modules/{$ext['element']}"
: "{$remotePath}/modules/{$ext['element']}";
$map[] = ['local' => $src, 'remote' => $base];
if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
}
break;
case 'module':
$base = $ext['client'] === 'administrator'
? "{$remotePath}/administrator/modules/{$ext['element']}"
: "{$remotePath}/modules/{$ext['element']}";
$map[] = ['local' => $src, 'remote' => $base];
if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
}
break;
case 'plugin':
$map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"];
if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
}
break;
case 'plugin':
$map[] = ['local' => $src, 'remote' => "{$remotePath}/plugins/{$ext['group']}/{$ext['shortName']}"];
if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
}
break;
case 'template':
$clientDir = $ext['client'] === 'administrator' ? 'administrator/' : '';
$map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"];
if (is_dir("{$src}/media")) {
$mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site';
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"];
}
break;
case 'template':
$clientDir = $ext['client'] === 'administrator' ? 'administrator/' : '';
$map[] = ['local' => $src, 'remote' => "{$remotePath}/{$clientDir}templates/{$ext['shortName']}"];
if (is_dir("{$src}/media")) {
$mediaClient = $ext['client'] === 'administrator' ? 'administrator' : 'site';
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/templates/{$mediaClient}/{$ext['shortName']}"];
}
break;
case 'library':
$map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"];
if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
}
break;
case 'library':
$map[] = ['local' => $src, 'remote' => "{$remotePath}/libraries/{$ext['shortName']}"];
if (is_dir("{$src}/media")) {
$map[] = ['local' => "{$src}/media", 'remote' => "{$remotePath}/media/{$ext['element']}"];
}
break;
case 'package':
// Packages deploy their sub-extensions individually
// For now, deploy to administrator/manifests/packages/
$map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"];
break;
}
case 'package':
// Packages deploy their sub-extensions individually
// For now, deploy to administrator/manifests/packages/
$map[] = ['local' => $src, 'remote' => "{$remotePath}/administrator/manifests/packages"];
break;
}
return $map;
}
return $map;
}
/**
* Get the remote path for a specific section of the extension.
*/
private function getRemotePath(array $ext, string $section): string
{
$remotePath = rtrim((string) $this->config['remote_path'], '/');
return match ($section) {
'admin' => "{$remotePath}/administrator/components/{$ext['element']}",
'site' => "{$remotePath}/components/{$ext['element']}",
'media' => "{$remotePath}/media/{$ext['element']}",
default => $remotePath,
};
}
/**
* Get the remote path for a specific section of the extension.
*/
private function getRemotePath(array $ext, string $section): string
{
$remotePath = rtrim((string) $this->config['remote_path'], '/');
return match ($section) {
'admin' => "{$remotePath}/administrator/components/{$ext['element']}",
'site' => "{$remotePath}/components/{$ext['element']}",
'media' => "{$remotePath}/media/{$ext['element']}",
default => $remotePath,
};
}
/**
* Check if the XML manifest has changed and warn about reinstall.
*/
private function checkManifestChange(array $ext, string $manifestPath): void
{
$manifestName = basename($manifestPath);
$this->log('INFO', '');
$this->log('INFO', "NOTE: If {$manifestName} has changed (new fields, permissions, menu items,");
$this->log('INFO', ' database schema), you must reinstall the extension through Joomla.');
$this->log('INFO', ' Code changes (PHP, JS, CSS, language) do NOT require reinstall.');
$this->log('INFO', '');
}
/**
* Check if the XML manifest has changed and warn about reinstall.
*/
private function checkManifestChange(array $ext, string $manifestPath): void
{
$manifestName = basename($manifestPath);
$this->log('INFO', '');
$this->log('INFO', "NOTE: If {$manifestName} has changed (new fields, permissions, menu items,");
$this->log('INFO', ' database schema), you must reinstall the extension through Joomla.');
$this->log('INFO', ' Code changes (PHP, JS, CSS, language) do NOT require reinstall.');
$this->log('INFO', '');
}
/**
* Upload a directory recursively to the remote server.
*/
private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int
{
$errors = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
/**
* Upload a directory recursively to the remote server.
*/
private function uploadDirectory(SFTP $sftp, string $localDir, string $remoteDir): int
{
$errors = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($localDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
$relativePath = substr($item->getPathname(), strlen($localDir) + 1);
$relativePath = str_replace('\\', '/', $relativePath);
$remotePath = "{$remoteDir}/{$relativePath}";
foreach ($iterator as $item) {
$relativePath = substr($item->getPathname(), strlen($localDir) + 1);
$relativePath = str_replace('\\', '/', $relativePath);
$remotePath = "{$remoteDir}/{$relativePath}";
// Check ignore patterns
if ($this->shouldIgnore($relativePath)) {
$this->skipped++;
continue;
}
// Check ignore patterns
if ($this->shouldIgnore($relativePath)) {
$this->skipped++;
continue;
}
if ($item->isDir()) {
$stat = @$sftp->stat($remotePath);
if ($stat === false) {
$sftp->mkdir($remotePath, -1, true);
}
} else {
$result = $this->uploadFile($sftp, $item->getPathname(), $remotePath);
if (!$result) {
$errors++;
}
}
}
if ($item->isDir()) {
$stat = @$sftp->stat($remotePath);
if ($stat === false) {
$sftp->mkdir($remotePath, -1, true);
}
} else {
$result = $this->uploadFile($sftp, $item->getPathname(), $remotePath);
if (!$result) {
$errors++;
}
}
}
return $errors;
}
return $errors;
}
/**
* Upload a single file with smart diff (skip if unchanged).
*/
private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool
{
$localSize = filesize($localPath);
$remoteStat = @$sftp->stat($remotePath);
/**
* Upload a single file with smart diff (skip if unchanged).
*/
private function uploadFile(SFTP $sftp, string $localPath, string $remotePath): bool
{
$localSize = filesize($localPath);
$remoteStat = @$sftp->stat($remotePath);
// Smart diff: skip if same size and hash
if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) {
$remoteContent = @$sftp->get($remotePath);
if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) {
$this->unchanged++;
return true;
}
}
// Smart diff: skip if same size and hash
if ($remoteStat !== false && ($remoteStat['size'] ?? -1) === $localSize) {
$remoteContent = @$sftp->get($remotePath);
if ($remoteContent !== false && md5($remoteContent) === md5_file($localPath)) {
$this->unchanged++;
return true;
}
}
// Ensure parent directory exists
$parentDir = dirname($remotePath);
$parentStat = @$sftp->stat($parentDir);
if ($parentStat === false) {
$sftp->mkdir($parentDir, -1, true);
}
// Ensure parent directory exists
$parentDir = dirname($remotePath);
$parentStat = @$sftp->stat($parentDir);
if ($parentStat === false) {
$sftp->mkdir($parentDir, -1, true);
}
$result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE);
if ($result) {
$this->uploaded++;
if ($this->verbose) {
$this->log('INFO', " UPLOAD: {$remotePath}");
}
} else {
$this->log('ERROR', " FAIL: {$remotePath}");
}
return $result;
}
$result = $sftp->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE);
if ($result) {
$this->uploaded++;
if ($this->verbose) {
$this->log('INFO', " UPLOAD: {$remotePath}");
}
} else {
$this->log('ERROR', " FAIL: {$remotePath}");
}
return $result;
}
/**
* Check if a relative path should be ignored.
*/
private function shouldIgnore(string $relativePath): bool
{
foreach ($this->ignorePatterns as $pattern) {
if (preg_match($pattern, $relativePath)) {
return true;
}
}
// Always skip dotfiles and common non-deploy files
$basename = basename($relativePath);
if (str_starts_with($basename, '.') && $basename !== '.htaccess') {
return true;
}
return false;
}
/**
* Check if a relative path should be ignored.
*/
private function shouldIgnore(string $relativePath): bool
{
foreach ($this->ignorePatterns as $pattern) {
if (preg_match($pattern, $relativePath)) {
return true;
}
}
// Always skip dotfiles and common non-deploy files
$basename = basename($relativePath);
if (str_starts_with($basename, '.') && $basename !== '.htaccess') {
return true;
}
return false;
}
/**
* Load .ftpignore patterns.
*
* @return string[] Regex patterns
*/
private function loadFtpIgnore(): array
{
$patterns = [];
foreach ([$this->srcDir, $this->repoPath] as $dir) {
$file = "{$dir}/.ftpignore";
if (!file_exists($file)) {
continue;
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
// Convert glob to regex
$regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line);
$patterns[] = "#^{$regex}(/|$)#i";
}
}
return $patterns;
}
/**
* Load .ftpignore patterns.
*
* @return string[] Regex patterns
*/
private function loadFtpIgnore(): array
{
$patterns = [];
foreach ([$this->srcDir, $this->repoPath] as $dir) {
$file = "{$dir}/.ftpignore";
if (!file_exists($file)) {
continue;
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
// Convert glob to regex
$regex = str_replace(['.', '*', '?'], ['\\.', '.*', '.'], $line);
$patterns[] = "#^{$regex}(/|$)#i";
}
}
return $patterns;
}
/**
* Connect to the SFTP server.
*/
private function connectSftp(): ?SFTP
{
$host = (string) $this->config['host'];
$port = (int) ($this->config['port'] ?? 22);
$user = (string) $this->config['user'];
/**
* Connect to the SFTP server.
*/
private function connectSftp(): ?SFTP
{
$host = (string) $this->config['host'];
$port = (int) ($this->config['port'] ?? 22);
$user = (string) $this->config['user'];
$this->log('INFO', "Connecting to {$user}@{$host}:{$port}...");
$this->log('INFO', "Connecting to {$user}@{$host}:{$port}...");
$sftp = new SFTP($host, $port, 30);
$sftp = new SFTP($host, $port, 30);
// Try key auth first
if (!empty($this->config['ssh_key_file'])) {
$keyPath = $this->config['ssh_key_file'];
if (!file_exists($keyPath)) {
$keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}";
}
if (file_exists($keyPath)) {
$passphrase = $this->config['key_passphrase'] ?? '';
$key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase);
if ($sftp->login($user, $key)) {
$this->log('INFO', 'Connected via SSH key');
return $sftp;
}
$this->warning('Key auth failed');
}
}
// Try key auth first
if (!empty($this->config['ssh_key_file'])) {
$keyPath = $this->config['ssh_key_file'];
if (!file_exists($keyPath)) {
$keyPath = "{$this->repoPath}/scripts/keys/{$keyPath}";
}
if (file_exists($keyPath)) {
$passphrase = $this->config['key_passphrase'] ?? '';
$key = PublicKeyLoader::load(file_get_contents($keyPath), $passphrase);
if ($sftp->login($user, $key)) {
$this->log('INFO', 'Connected via SSH key');
return $sftp;
}
$this->warning('Key auth failed');
}
}
// Fallback to password
if (!empty($this->config['password'])) {
if ($sftp->login($user, $this->config['password'])) {
$this->log('INFO', 'Connected via password');
return $sftp;
}
}
// Fallback to password
if (!empty($this->config['password'])) {
if ($sftp->login($user, $this->config['password'])) {
$this->log('INFO', 'Connected via password');
return $sftp;
}
}
$this->log('ERROR', 'Authentication failed');
return null;
}
$this->log('ERROR', 'Authentication failed');
return null;
}
}
$app = new DeployJoomlaCli();
+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
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -23,192 +24,192 @@ use MokoEnterprise\CliFramework;
class HealthCheckCli extends CliFramework
{
private string $url = '';
private int $timeout = 30;
private array $checks = ['http'];
private string $url = '';
private int $timeout = 30;
private array $checks = ['http'];
private int $passed = 0;
private int $failed = 0;
private int $passed = 0;
private int $failed = 0;
protected function configure(): void
{
$this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly');
$this->addArgument('--url', 'Site URL to check', '');
$this->addArgument('--timeout', 'Request timeout in seconds', '30');
$this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http');
}
protected function configure(): void
{
$this->setDescription('Post-deploy health check — verify a Joomla site is responding correctly');
$this->addArgument('--url', 'Site URL to check', '');
$this->addArgument('--timeout', 'Request timeout in seconds', '30');
$this->addArgument('--checks', 'Comma-separated list of checks: http,admin,api', 'http');
}
protected function run(): int
{
$this->url = $this->getArgument('--url');
$this->timeout = (int) $this->getArgument('--timeout');
$checksRaw = $this->getArgument('--checks');
$this->checks = array_map('trim', explode(',', $checksRaw));
protected function run(): int
{
$this->url = $this->getArgument('--url');
$this->timeout = (int) $this->getArgument('--timeout');
$checksRaw = $this->getArgument('--checks');
$this->checks = array_map('trim', explode(',', $checksRaw));
if ($this->url === '') {
$this->log('ERROR', 'Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]');
return 1;
}
if ($this->url === '') {
$this->log('ERROR', 'Usage: health-check.php --url <site-url> [--timeout <seconds>] [--checks <http,admin,api>]');
return 1;
}
$this->url = rtrim($this->url, '/');
$this->url = rtrim($this->url, '/');
$this->log('INFO', "Health check for: {$this->url}");
$this->log('INFO', "Timeout: {$this->timeout}s");
$this->log('INFO', "Checks: " . implode(', ', $this->checks));
$this->log('INFO', '');
$this->log('INFO', "Health check for: {$this->url}");
$this->log('INFO', "Timeout: {$this->timeout}s");
$this->log('INFO', "Checks: " . implode(', ', $this->checks));
$this->log('INFO', '');
foreach ($this->checks as $check) {
switch ($check) {
case 'http':
$this->checkHttp();
break;
case 'admin':
$this->checkAdmin();
break;
case 'api':
$this->checkApi();
break;
default:
$this->log('WARN', "UNKNOWN CHECK: {$check} — skipping");
break;
}
}
foreach ($this->checks as $check) {
switch ($check) {
case 'http':
$this->checkHttp();
break;
case 'admin':
$this->checkAdmin();
break;
case 'api':
$this->checkApi();
break;
default:
$this->log('WARN', "UNKNOWN CHECK: {$check} — skipping");
break;
}
}
$this->log('INFO', '');
$this->log('INFO', "Results: {$this->passed} passed, {$this->failed} failed");
$this->log('INFO', '');
$this->log('INFO', "Results: {$this->passed} passed, {$this->failed} failed");
return $this->failed > 0 ? 1 : 0;
}
return $this->failed > 0 ? 1 : 0;
}
private function checkHttp(): void
{
$this->log('INFO', '[http] GET ' . $this->url);
private function checkHttp(): void
{
$this->log('INFO', '[http] GET ' . $this->url);
$result = $this->curlGet($this->url);
$result = $this->curlGet($this->url);
if ($result === null) {
$this->fail('http', 'Request failed — could not connect');
return;
}
if ($result === null) {
$this->fail('http', 'Request failed — could not connect');
return;
}
if ($result['http_code'] !== 200) {
$this->fail('http', "Expected HTTP 200, got {$result['http_code']}");
return;
}
if ($result['http_code'] !== 200) {
$this->fail('http', "Expected HTTP 200, got {$result['http_code']}");
return;
}
if ($this->containsFatalError($result['body'])) {
$this->fail('http', 'Response body contains PHP fatal error');
return;
}
if ($this->containsFatalError($result['body'])) {
$this->fail('http', 'Response body contains PHP fatal error');
return;
}
$this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)");
}
$this->pass('http', "HTTP 200 OK ({$result['time_ms']}ms)");
}
private function checkAdmin(): void
{
$adminUrl = $this->url . '/administrator/';
$this->log('INFO', '[admin] GET ' . $adminUrl);
private function checkAdmin(): void
{
$adminUrl = $this->url . '/administrator/';
$this->log('INFO', '[admin] GET ' . $adminUrl);
$result = $this->curlGet($adminUrl);
$result = $this->curlGet($adminUrl);
if ($result === null) {
$this->fail('admin', 'Request failed — could not connect');
return;
}
if ($result === null) {
$this->fail('admin', 'Request failed — could not connect');
return;
}
if ($result['http_code'] !== 200) {
$this->fail('admin', "Expected HTTP 200, got {$result['http_code']}");
return;
}
if ($result['http_code'] !== 200) {
$this->fail('admin', "Expected HTTP 200, got {$result['http_code']}");
return;
}
$this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)");
}
$this->pass('admin', "HTTP 200 OK ({$result['time_ms']}ms)");
}
private function checkApi(): void
{
$apiUrl = $this->url . '/api/index.php/v1';
$this->log('INFO', '[api] GET ' . $apiUrl);
private function checkApi(): void
{
$apiUrl = $this->url . '/api/index.php/v1';
$this->log('INFO', '[api] GET ' . $apiUrl);
$result = $this->curlGet($apiUrl);
$result = $this->curlGet($apiUrl);
if ($result === null) {
$this->fail('api', 'Request failed — could not connect');
return;
}
if ($result === null) {
$this->fail('api', 'Request failed — could not connect');
return;
}
if ($result['http_code'] !== 200 && $result['http_code'] !== 401) {
$this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}");
return;
}
if ($result['http_code'] !== 200 && $result['http_code'] !== 401) {
$this->fail('api', "Expected HTTP 200 or 401, got {$result['http_code']}");
return;
}
$this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)");
}
$this->pass('api', "HTTP {$result['http_code']} — API is alive ({$result['time_ms']}ms)");
}
private function curlGet(string $url): ?array
{
$ch = curl_init();
private function curlGet(string $url): ?array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'MokoHealthCheck/1.0',
]);
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => $this->timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'MokoHealthCheck/1.0',
]);
$body = curl_exec($ch);
$body = curl_exec($ch);
if (curl_errno($ch)) {
$error = curl_error($ch);
$this->log('ERROR', " cURL error: {$error}");
curl_close($ch);
return null;
}
if (curl_errno($ch)) {
$error = curl_error($ch);
$this->log('ERROR', " cURL error: {$error}");
curl_close($ch);
return null;
}
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$totalTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch);
return [
'http_code' => $httpCode,
'body' => is_string($body) ? $body : '',
'time_ms' => (int) round($totalTime * 1000),
];
}
return [
'http_code' => $httpCode,
'body' => is_string($body) ? $body : '',
'time_ms' => (int) round($totalTime * 1000),
];
}
private function containsFatalError(string $body): bool
{
$patterns = [
'Fatal error:',
'Fatal Error',
'Parse error:',
'Uncaught Error:',
'Uncaught Exception:',
];
private function containsFatalError(string $body): bool
{
$patterns = [
'Fatal error:',
'Fatal Error',
'Parse error:',
'Uncaught Error:',
'Uncaught Exception:',
];
foreach ($patterns as $pattern) {
if (stripos($body, $pattern) !== false) {
return true;
}
}
foreach ($patterns as $pattern) {
if (stripos($body, $pattern) !== false) {
return true;
}
}
return false;
}
return false;
}
private function pass(string $check, string $message): void
{
$this->passed++;
$this->log('INFO', "[{$check}] PASS: {$message}");
}
private function pass(string $check, string $message): void
{
$this->passed++;
$this->log('INFO', "[{$check}] PASS: {$message}");
}
private function fail(string $check, string $message): void
{
$this->failed++;
$this->log('ERROR', "[{$check}] FAIL: {$message}");
}
private function fail(string $check, string $message): void
{
$this->failed++;
$this->log('ERROR', "[{$check}] FAIL: {$message}");
}
}
$app = new HealthCheckCli();
+130 -129
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -23,164 +24,164 @@ use MokoEnterprise\CliFramework;
class RollbackJoomlaCli extends CliFramework
{
private const JOOMLA_DIRS = [
'administrator/components',
'administrator/language',
'administrator/modules',
'administrator/templates',
'components',
'language',
'layouts',
'libraries',
'media',
'modules',
'plugins',
'templates',
];
private const JOOMLA_DIRS = [
'administrator/components',
'administrator/language',
'administrator/modules',
'administrator/templates',
'components',
'language',
'layouts',
'libraries',
'media',
'modules',
'plugins',
'templates',
];
protected function configure(): void
{
$this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--snapshot-dir', 'Path to snapshot directory', '');
}
protected function configure(): void
{
$this->setDescription('Rollback a Joomla deployment by restoring from a pre-deploy snapshot');
$this->addArgument('--config', 'Path to sftp-config.json', '');
$this->addArgument('--snapshot-dir', 'Path to snapshot directory', '');
}
protected function run(): int
{
$configPath = $this->getArgument('--config');
$snapshotDir = $this->getArgument('--snapshot-dir');
protected function run(): int
{
$configPath = $this->getArgument('--config');
$snapshotDir = $this->getArgument('--snapshot-dir');
if ($configPath === '' || $snapshotDir === '') {
$this->log('ERROR', 'Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]');
return 1;
}
if ($configPath === '' || $snapshotDir === '') {
$this->log('ERROR', 'Usage: rollback-joomla.php --config <sftp-config.json> --snapshot-dir <path> [--dry-run] [--verbose]');
return 1;
}
if (!is_dir($snapshotDir)) {
$this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}");
return 1;
}
if (!is_dir($snapshotDir)) {
$this->log('ERROR', "Snapshot directory does not exist: {$snapshotDir}");
return 1;
}
$config = $this->loadConfig($configPath);
if ($config === null) {
return 1;
}
$config = $this->loadConfig($configPath);
if ($config === null) {
return 1;
}
$host = $config['host'] ?? '';
$user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? '';
$host = $config['host'] ?? '';
$user = $config['user'] ?? '';
$port = (int) ($config['port'] ?? 22);
$remotePath = rtrim($config['remote_path'] ?? '', '/');
$sshKey = $config['ssh_key_file'] ?? '';
if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1;
}
if ($host === '' || $user === '' || $remotePath === '') {
$this->log('ERROR', 'Config must contain host, user, and remote_path.');
return 1;
}
$this->log('INFO', 'Starting Joomla rollback from snapshot...');
$this->log('INFO', "Snapshot: {$snapshotDir}");
$this->log('INFO', "Target: {$user}@{$host}:{$remotePath}");
$this->log('INFO', 'Starting Joomla rollback from snapshot...');
$this->log('INFO', "Snapshot: {$snapshotDir}");
$this->log('INFO', "Target: {$user}@{$host}:{$remotePath}");
if ($this->dryRun) {
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
}
if ($this->dryRun) {
$this->log('INFO', '*** DRY RUN — no changes will be made ***');
}
$failed = 0;
$failed = 0;
foreach (self::JOOMLA_DIRS as $dir) {
$localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/';
foreach (self::JOOMLA_DIRS as $dir) {
$localDir = rtrim($snapshotDir, '/\\') . '/' . $dir . '/';
if (!is_dir($localDir)) {
if ($this->verbose) {
$this->log('INFO', "SKIP: {$dir} (not present in snapshot)");
}
continue;
}
if (!is_dir($localDir)) {
if ($this->verbose) {
$this->log('INFO', "SKIP: {$dir} (not present in snapshot)");
}
continue;
}
$remoteTarget = "{$remotePath}/{$dir}/";
$sshCmd = "ssh -p {$port}";
if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey);
}
$remoteTarget = "{$remotePath}/{$dir}/";
$sshCmd = "ssh -p {$port}";
if ($sshKey !== '') {
$sshCmd .= " -i " . escapeshellarg($sshKey);
}
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$cmd = $this->buildRsyncCommand($sshCmd, $localDir, "{$user}@{$host}:{$remoteTarget}");
$this->log('INFO', "Restoring: {$dir}");
if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}");
}
$this->log('INFO', "Restoring: {$dir}");
if ($this->verbose) {
$this->log('INFO', "CMD: {$cmd}");
}
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) {
$this->log('ERROR', " {$line}");
}
$failed++;
} else {
if ($this->verbose) {
foreach ($output as $line) {
$this->log('INFO', " {$line}");
}
}
}
}
if ($exitCode !== 0) {
$this->log('ERROR', "rsync failed for {$dir} (exit code {$exitCode})");
foreach ($output as $line) {
$this->log('ERROR', " {$line}");
}
$failed++;
} else {
if ($this->verbose) {
foreach ($output as $line) {
$this->log('INFO', " {$line}");
}
}
}
}
if ($failed > 0) {
$this->log('ERROR', "Rollback completed with {$failed} error(s).");
return 1;
}
if ($failed > 0) {
$this->log('ERROR', "Rollback completed with {$failed} error(s).");
return 1;
}
$this->log('INFO', 'Rollback completed successfully.');
return 0;
}
$this->log('INFO', 'Rollback completed successfully.');
return 0;
}
private function loadConfig(string $path): ?array
{
if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}");
return null;
}
private function loadConfig(string $path): ?array
{
if (!is_file($path)) {
$this->log('ERROR', "Config file not found: {$path}");
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}");
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
$this->log('ERROR', "Could not read config file: {$path}");
return null;
}
// Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true);
// Strip // comments (sftp-config.json style)
$cleaned = preg_replace('#^\s*//.*$#m', '', $raw);
$config = json_decode($cleaned, true);
if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.');
return null;
}
if (!is_array($config)) {
$this->log('ERROR', 'Invalid JSON in config file.');
return null;
}
return $config;
}
return $config;
}
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
{
$parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php'];
private function buildRsyncCommand(string $sshCmd, string $source, string $dest): string
{
$parts = ['rsync', '-rlptz', '--delete', '--exclude=configuration.php'];
if ($this->dryRun) {
$parts[] = '--dry-run';
}
if ($this->dryRun) {
$parts[] = '--dry-run';
}
if ($this->verbose) {
$parts[] = '-v';
}
if ($this->verbose) {
$parts[] = '-v';
}
$parts[] = '-e';
$parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest);
$parts[] = '-e';
$parts[] = escapeshellarg($sshCmd);
$parts[] = escapeshellarg($source);
$parts[] = escapeshellarg($dest);
return implode(' ', $parts);
}
return implode(' ', $parts);
}
}
$app = new RollbackJoomlaCli();
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
+8 -5
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* 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
{
if (!preg_match(
'/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/',
$line,
$m
)) {
if (
!preg_match(
'/^(\s+uses:\s+)([\w.\-]+\/[\w.\-\/]+)@([^\s#]+)((?:\s+#.*)?)$/',
$line,
$m
)
) {
return null;
}
+202 -110
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -22,125 +23,216 @@ use MokoEnterprise\CliFramework;
class RepoInventoryCli extends CliFramework
{
private $api = null;
private string $token = '';
private $platformConfig = null;
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
private $api = null;
private string $token = '';
private $platformConfig = null;
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
protected function configure(): void
{
$this->setDescription('Generate a live inventory dashboard of all governed repos');
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
$this->addArgument('--json', 'JSON output to stdout', false);
}
protected function configure(): void
{
$this->setDescription('Generate a live inventory dashboard of all governed repos');
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
$this->addArgument('--json', 'JSON output to stdout', false);
}
protected function initialize(): void
{
$this->platformConfig = \MokoEnterprise\Config::load();
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig);
$this->api = $adapter->getApiClient();
} catch (\Exception $e) {
$this->log('ERROR', "Platform init failed: " . $e->getMessage());
exit(1);
}
$this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea'
? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', '');
}
protected function initialize(): void
{
$this->platformConfig = \MokoEnterprise\Config::load();
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($this->platformConfig);
$this->api = $adapter->getApiClient();
} catch (\Exception $e) {
$this->log('ERROR', "Platform init failed: " . $e->getMessage());
exit(1);
}
$this->token = $this->platformConfig->getString('platform', 'gitea') === 'gitea'
? $this->platformConfig->getString('gitea.token', '') : $this->platformConfig->getString('github.token', '');
}
protected function run(): int
{
$org = $this->getArgument('--org');
$jsonOut = (bool) $this->getArgument('--json');
if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; }
$allRepos = []; $page = 1;
do {
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null);
$allRepos = array_merge($allRepos, $batch); $page++;
} while (count($batch) === 100);
if (!$jsonOut) { echo "Found " . count($allRepos) . " total repositories\n\n"; }
protected function run(): int
{
$org = $this->getArgument('--org');
$jsonOut = (bool) $this->getArgument('--json');
if (!$jsonOut) {
echo "Fetching repositories from {$org}...\n";
}
$allRepos = [];
$page = 1;
do {
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all&sort=full_name", null);
$allRepos = array_merge($allRepos, $batch);
$page++;
} while (count($batch) === 100);
if (!$jsonOut) {
echo "Found " . count($allRepos) . " total repositories\n\n";
}
$inventory = [];
foreach ($allRepos as $repo) {
$name = $repo['name'];
if (in_array($name, self::ALWAYS_EXCLUDE, true)) { continue; }
$entry = ['name' => $name, 'visibility' => $repo['private'] ? 'private' : 'public', 'archived' => $repo['archived'] ?? false, 'platform' => '-', 'version' => '-', 'last_push' => $repo['pushed_at'] ?? '-', 'open_issues' => $repo['open_issues_count'] ?? 0, 'has_project' => false, 'rulesets' => 0];
if ($entry['archived']) { $inventory[] = $entry; continue; }
foreach (['.github/.mokostandards', '.mokostandards'] as $path) {
[$status, $data] = $this->ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null);
if ($status === 200 && !empty($data['content'])) {
$content = base64_decode($data['content']);
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) { $entry['platform'] = trim($m[1], " \t\n\r\"'"); }
if (preg_match('/^version:\s*(.+)/m', $content, $m)) { $entry['version'] = trim($m[1], " \t\n\r\"'"); }
break;
}
}
[$status, $rulesets] = $this->ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null);
if ($status === 200 && is_array($rulesets)) { $entry['rulesets'] = count($rulesets); }
$gql = $this->graphql('query($owner:String!,$name:String!){repository(owner:$owner,name:$name){projectsV2(first:1){totalCount}}}', ['owner' => $org, 'name' => $name]);
$entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0;
$inventory[] = $entry;
if (!$jsonOut) { echo " {$name}: {$entry['platform']} | v{$entry['version']} | rulesets:{$entry['rulesets']} | project:" . ($entry['has_project'] ? 'yes' : 'no') . "\n"; }
}
$inventory = [];
foreach ($allRepos as $repo) {
$name = $repo['name'];
if (in_array($name, self::ALWAYS_EXCLUDE, true)) {
continue;
}
$entry = [
'name' => $name,
'visibility' => $repo['private'] ? 'private' : 'public',
'archived' => $repo['archived'] ?? false,
'platform' => '-',
'version' => '-',
'last_push' => $repo['pushed_at'] ?? '-',
'open_issues' => $repo['open_issues_count'] ?? 0,
'has_project' => false,
'rulesets' => 0,
];
if ($entry['archived']) {
$inventory[] = $entry;
continue;
}
foreach (['.github/.mokostandards', '.mokostandards'] as $path) {
[$status, $data] = $this->ghApi('GET', "repos/{$org}/{$name}/contents/{$path}", null);
if ($status === 200 && !empty($data['content'])) {
$content = base64_decode($data['content']);
if (preg_match('/^platform:\s*(.+)/m', $content, $m)) {
$entry['platform'] = trim($m[1], " \t\n\r\"'");
}
if (preg_match('/^version:\s*(.+)/m', $content, $m)) {
$entry['version'] = trim($m[1], " \t\n\r\"'");
}
break;
}
}
[$status, $rulesets] = $this->ghApi('GET', "repos/{$org}/{$name}/rulesets?per_page=100&includes_parents=true", null);
if ($status === 200 && is_array($rulesets)) {
$entry['rulesets'] = count($rulesets);
}
$gql = $this->graphql(
'query($owner:String!,$name:String!)'
. '{repository(owner:$owner,name:$name)'
. '{projectsV2(first:1){totalCount}}}',
['owner' => $org, 'name' => $name]
);
$entry['has_project'] = ($gql['repository']['projectsV2']['totalCount'] ?? 0) > 0;
$inventory[] = $entry;
if (!$jsonOut) {
$proj = $entry['has_project'] ? 'yes' : 'no';
echo " {$name}: {$entry['platform']}"
. " | v{$entry['version']}"
. " | rulesets:{$entry['rulesets']}"
. " | project:{$proj}\n";
}
}
if ($jsonOut) { echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n"; return 0; }
if ($jsonOut) {
echo json_encode($inventory, JSON_PRETTY_PRINT) . "\n";
return 0;
}
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$active = array_filter($inventory, fn($r) => !$r['archived']);
$archived = array_filter($inventory, fn($r) => $r['archived']);
$withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3));
$withProj = count(array_filter($active, fn($r) => $r['has_project']));
$activeN = count($active); $archivedN = count($archived);
$rows = [];
foreach ($inventory as $r) {
$vis = $r['visibility'] === 'private' ? 'prv' : 'pub';
$arch = $r['archived'] ? ' archived' : '';
$proj = $r['has_project'] ? 'yes' : '-';
$rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3");
$rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |";
}
$table = implode("\n", $rows);
$body = "## Repository Inventory Dashboard\n\n**Organisation:** `{$org}`\n**Generated:** {$now}\n**Active:** {$activeN} | **Archived:** {$archivedN} | **Rulesets 3/3:** {$withRules} | **Projects:** {$withProj}\n\n| Repository | Visibility | Platform | Version | Rulesets | Project | Issues |\n|---|---|---|---|---|---|---|\n{$table}\n\n---\n*Auto-generated by `repo_inventory.php`*\n";
echo "\n" . str_repeat('-', 50) . "\n";
echo "Active: {$activeN} | Archived: {$archivedN} | Rulesets 3/3: {$withRules} | Projects: {$withProj}\n";
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$active = array_filter($inventory, fn($r) => !$r['archived']);
$archived = array_filter($inventory, fn($r) => $r['archived']);
$withRules = count(array_filter($active, fn($r) => $r['rulesets'] >= 3));
$withProj = count(array_filter($active, fn($r) => $r['has_project']));
$activeN = count($active);
$archivedN = count($archived);
$rows = [];
foreach ($inventory as $r) {
$vis = $r['visibility'] === 'private' ? 'prv' : 'pub';
$arch = $r['archived'] ? ' archived' : '';
$proj = $r['has_project'] ? 'yes' : '-';
$rs = $r['archived'] ? '-' : ($r['rulesets'] >= 3 ? '3/3' : "{$r['rulesets']}/3");
$rows[] = "| `{$r['name']}` | {$vis}{$arch} | {$r['platform']} | {$r['version']} | {$rs} | {$proj} | {$r['open_issues']} |";
}
$table = implode("\n", $rows);
$body = "## Repository Inventory Dashboard\n\n"
. "**Organisation:** `{$org}`\n"
. "**Generated:** {$now}\n"
. "**Active:** {$activeN} | **Archived:** {$archivedN}"
. " | **Rulesets 3/3:** {$withRules}"
. " | **Projects:** {$withProj}\n\n"
. "| Repository | Visibility | Platform"
. " | Version | Rulesets | Project | Issues |\n"
. "|---|---|---|---|---|---|---|\n"
. "{$table}\n\n---\n"
. "*Auto-generated by `repo_inventory.php`*\n";
echo "\n" . str_repeat('-', 50) . "\n";
echo "Active: {$activeN} | Archived: {$archivedN}"
. " | Rulesets 3/3: {$withRules}"
. " | Projects: {$withProj}\n";
if (!$this->dryRun) {
$title = "dashboard: repository inventory ({$org})";
[$_, $existing] = $this->ghApi('GET', "repos/{$org}/moko-platform/issues?labels=inventory&state=all&per_page=1&sort=created&direction=desc", null);
if (!empty($existing[0]['number'])) {
$num = $existing[0]['number'];
$this->ghApi('PATCH', "repos/{$org}/moko-platform/issues/{$num}", ['title' => $title, 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']]);
echo "Updated inventory issue #{$num}\n";
} else {
[$_, $issue] = $this->ghApi('POST', "repos/{$org}/moko-platform/issues", ['title' => $title, 'body' => $body, 'labels' => ['inventory', 'type: chore', 'automation'], 'assignees' => ['jmiller']]);
echo "Created inventory issue #{$issue['number']}\n";
}
} else { echo "(dry-run) would post inventory dashboard issue\n"; }
return 0;
}
if (!$this->dryRun) {
$title = "dashboard: repository inventory ({$org})";
$issueQuery = "repos/{$org}/moko-platform/issues"
. "?labels=inventory&state=all&per_page=1"
. "&sort=created&direction=desc";
[$_, $existing] = $this->ghApi('GET', $issueQuery, null);
if (!empty($existing[0]['number'])) {
$num = $existing[0]['number'];
$this->ghApi(
'PATCH',
"repos/{$org}/moko-platform/issues/{$num}",
[
'title' => $title,
'body' => $body,
'state' => 'open',
'assignees' => ['jmiller'],
]
);
echo "Updated inventory issue #{$num}\n";
} else {
[$_, $issue] = $this->ghApi(
'POST',
"repos/{$org}/moko-platform/issues",
[
'title' => $title,
'body' => $body,
'labels' => ['inventory', 'type: chore', 'automation'],
'assignees' => ['jmiller'],
]
);
echo "Created inventory issue #{$issue['number']}\n";
}
} else {
echo "(dry-run) would post inventory dashboard issue\n";
}
return 0;
}
private function ghApi(string $method, string $path, ?array $body): array
{
try {
$result = match ($method) {
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []),
'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported: {$method}"),
};
return [200, $result];
} catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; }
}
private function ghApi(string $method, string $path, ?array $body): array
{
try {
$result = match ($method) {
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []),
'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported: {$method}"),
};
return [200, $result];
} catch (\Exception $e) {
return [500, ['message' => $e->getMessage()]];
}
}
private function graphql(string $query, array $variables): array
{
$pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea';
if ($pf !== 'github') { return []; }
$payload = json_encode(['query' => $query, 'variables' => $variables]);
$ch = curl_init('https://api.github.com/graphql');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Authorization: bearer ' . $this->token, 'Content-Type: application/json', 'User-Agent: moko-platform-Inventory']]);
$body = (string) curl_exec($ch); curl_close($ch);
return json_decode($body, true)['data'] ?? [];
}
private function graphql(string $query, array $variables): array
{
$pf = $this->platformConfig !== null ? $this->platformConfig->getString('platform', 'gitea') : 'gitea';
if ($pf !== 'github') {
return [];
}
$payload = json_encode(['query' => $query, 'variables' => $variables]);
$ch = curl_init('https://api.github.com/graphql');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: bearer ' . $this->token,
'Content-Type: application/json',
'User-Agent: moko-platform-Inventory',
],
]);
$body = (string) curl_exec($ch);
curl_close($ch);
return json_decode($body, true)['data'] ?? [];
}
}
$app = new RepoInventoryCli();
+224 -145
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -22,164 +23,242 @@ use MokoEnterprise\CliFramework;
class RotateSecretsCli extends CliFramework
{
private $api = null;
private string $token = '';
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
private const ENVS = [
'DEV' => ['vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'], 'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD']],
'DEMO' => ['vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'], 'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD']],
'RS' => ['vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'], 'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD']],
];
private $api = null;
private string $token = '';
private const ALWAYS_EXCLUDE = ['moko-platform', '.github-private'];
private const ENVS = [
'DEV' => [
'vars' => ['DEV_FTP_HOST', 'DEV_FTP_PATH', 'DEV_FTP_USERNAME', 'DEV_FTP_SUFFIX'],
'secrets' => ['DEV_FTP_KEY', 'DEV_FTP_PASSWORD'],
],
'DEMO' => [
'vars' => ['DEMO_FTP_HOST', 'DEMO_FTP_PATH', 'DEMO_FTP_USERNAME', 'DEMO_FTP_SUFFIX'],
'secrets' => ['DEMO_FTP_KEY', 'DEMO_FTP_PASSWORD'],
],
'RS' => [
'vars' => ['RS_FTP_HOST', 'RS_FTP_PATH', 'RS_FTP_USERNAME', 'RS_FTP_SUFFIX'],
'secrets' => ['RS_FTP_KEY', 'RS_FTP_PASSWORD'],
],
];
protected function configure(): void
{
$this->setDescription('Audit FTP secrets and variables across all governed repos');
$this->addArgument('--all', 'Audit all repos', false);
$this->addArgument('--repo', 'Single repo name', null);
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
$this->addArgument('--json', 'JSON output', false);
$this->addArgument('--create-issue', 'Post results as issue', false);
}
protected function configure(): void
{
$this->setDescription('Audit FTP secrets and variables across all governed repos');
$this->addArgument('--all', 'Audit all repos', false);
$this->addArgument('--repo', 'Single repo name', null);
$this->addArgument('--org', 'Organization', 'mokoconsulting-tech');
$this->addArgument('--json', 'JSON output', false);
$this->addArgument('--create-issue', 'Post results as issue', false);
}
protected function initialize(): void
{
$config = \MokoEnterprise\Config::load();
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->api = $adapter->getApiClient();
} catch (\Exception $e) {
$this->log('ERROR', "Platform init failed: " . $e->getMessage());
exit(1);
}
$this->token = $config->getString('platform', 'gitea') === 'gitea'
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
}
protected function initialize(): void
{
$config = \MokoEnterprise\Config::load();
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->api = $adapter->getApiClient();
} catch (\Exception $e) {
$this->log('ERROR', "Platform init failed: " . $e->getMessage());
exit(1);
}
$this->token = $config->getString('platform', 'gitea') === 'gitea'
? $config->getString('gitea.token', '')
: $config->getString('github.token', '');
}
protected function run(): int
{
$allMode = (bool) $this->getArgument('--all');
$jsonOut = (bool) $this->getArgument('--json');
$createIssue = (bool) $this->getArgument('--create-issue');
$org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo');
protected function run(): int
{
$allMode = (bool) $this->getArgument('--all');
$jsonOut = (bool) $this->getArgument('--json');
$createIssue = (bool) $this->getArgument('--create-issue');
$org = $this->getArgument('--org');
$repoName = $this->getArgument('--repo');
if (!$repoName && !$allMode) {
$this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo <name> [--json] [--create-issue]");
return 2;
}
if (!$repoName && !$allMode) {
$this->log('ERROR', "Usage: php rotate_secrets.php --all | --repo <name> [--json] [--create-issue]");
return 2;
}
$repos = [];
if ($allMode) {
if (!$jsonOut) { echo "Fetching repositories from {$org}...\n"; }
$page = 1;
do {
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null);
foreach ($batch as $r) {
if (!($r['archived'] ?? false) && !in_array($r['name'], self::ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
$page++;
} while (count($batch) === 100);
sort($repos);
if (!$jsonOut) { echo "Found " . count($repos) . " repositories\n\n"; }
} else {
$repos = [$repoName];
}
$repos = [];
if ($allMode) {
if (!$jsonOut) {
echo "Fetching repositories from {$org}...\n";
}
$page = 1;
do {
[$_, $batch] = $this->ghApi('GET', "orgs/{$org}/repos?per_page=100&page={$page}&type=all", null);
foreach ($batch as $r) {
if (!($r['archived'] ?? false) && !in_array($r['name'], self::ALWAYS_EXCLUDE, true)) {
$repos[] = $r['name'];
}
}
$page++;
} while (count($batch) === 100);
sort($repos);
if (!$jsonOut) {
echo "Found " . count($repos) . " repositories\n\n";
}
} else {
$repos = [$repoName];
}
$results = [];
$issueCount = 0;
$results = [];
$issueCount = 0;
foreach ($repos as $repo) {
$fullRepo = "{$org}/{$repo}";
$repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables');
$repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets');
$result = ['repo' => $repo, 'envs' => [], 'missing' => []];
foreach ($repos as $repo) {
$fullRepo = "{$org}/{$repo}";
$repoVars = $this->listNames("repos/{$fullRepo}/actions/variables", 'variables');
$repoSecrets = $this->listNames("repos/{$fullRepo}/actions/secrets", 'secrets');
$result = ['repo' => $repo, 'envs' => [], 'missing' => []];
foreach (self::ENVS as $env => $envConfig) {
$missingVars = array_diff($envConfig['vars'], $repoVars);
$hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets));
$hostVar = "{$env}_FTP_HOST";
$configured = in_array($hostVar, $repoVars, true);
$result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth];
if ($configured) {
foreach ($missingVars as $v) {
if ($v !== "{$env}_FTP_SUFFIX") { $result['missing'][] = "{$env}: missing {$v}"; $issueCount++; }
}
if (!$hasAuth) { $result['missing'][] = "{$env}: no auth key/password"; $issueCount++; }
}
}
foreach (self::ENVS as $env => $envConfig) {
$missingVars = array_diff($envConfig['vars'], $repoVars);
$hasAuth = !empty(array_intersect($envConfig['secrets'], $repoSecrets));
$hostVar = "{$env}_FTP_HOST";
$configured = in_array($hostVar, $repoVars, true);
$result['envs'][$env] = ['configured' => $configured, 'missing_vars' => array_values($missingVars), 'has_auth' => $hasAuth];
if ($configured) {
foreach ($missingVars as $v) {
if ($v !== "{$env}_FTP_SUFFIX") {
$result['missing'][] = "{$env}: missing {$v}";
$issueCount++;
}
}
if (!$hasAuth) {
$result['missing'][] = "{$env}: no auth key/password";
$issueCount++;
}
}
}
if (!$jsonOut) {
$parts = [];
foreach (self::ENVS as $env => $_) {
$e = $result['envs'][$env];
if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) { $parts[] = "{$env}:OK"; }
elseif ($e['configured']) { $parts[] = "{$env}:INCOMPLETE"; }
else { $parts[] = "{$env}:--"; }
}
echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n";
}
$results[] = $result;
}
if (!$jsonOut) {
$parts = [];
foreach (self::ENVS as $env => $_) {
$e = $result['envs'][$env];
if ($e['configured'] && $e['has_auth'] && empty($e['missing_vars'])) {
$parts[] = "{$env}:OK";
} elseif ($e['configured']) {
$parts[] = "{$env}:INCOMPLETE";
} else {
$parts[] = "{$env}:--";
}
}
echo "{$repo}: " . implode(' | ', $parts) . (empty($result['missing']) ? '' : ' [' . implode('; ', $result['missing']) . ']') . "\n";
}
$results[] = $result;
}
if ($jsonOut) {
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
} else {
echo "\n" . str_repeat('-', 50) . "\n";
$total = count($results);
$devReady = count(array_filter($results, fn($r) => ($r['envs']['DEV']['configured'] ?? false) && ($r['envs']['DEV']['has_auth'] ?? false)));
$demoReady = count(array_filter($results, fn($r) => ($r['envs']['DEMO']['configured'] ?? false) && ($r['envs']['DEMO']['has_auth'] ?? false)));
$rsReady = count(array_filter($results, fn($r) => ($r['envs']['RS']['configured'] ?? false) && ($r['envs']['RS']['has_auth'] ?? false)));
echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n";
}
if ($jsonOut) {
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
} else {
echo "\n" . str_repeat('-', 50) . "\n";
$total = count($results);
$devReady = count(array_filter(
$results,
fn($r) => ($r['envs']['DEV']['configured'] ?? false)
&& ($r['envs']['DEV']['has_auth'] ?? false)
));
$demoReady = count(array_filter(
$results,
fn($r) => ($r['envs']['DEMO']['configured'] ?? false)
&& ($r['envs']['DEMO']['has_auth'] ?? false)
));
$rsReady = count(array_filter(
$results,
fn($r) => ($r['envs']['RS']['configured'] ?? false)
&& ($r['envs']['RS']['has_auth'] ?? false)
));
echo "Total: {$total} | DEV: {$devReady} | DEMO: {$demoReady} | RS: {$rsReady} | Issues: {$issueCount}\n";
}
if ($createIssue && $issueCount > 0) {
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$rows = [];
foreach ($results as $r) {
foreach ($r['missing'] as $m) { $rows[] = "| `{$r['repo']}` | {$m} |"; }
}
$table = implode("\n", $rows);
$body = "## FTP Secret/Variable Audit\n\n**Date:** {$now}\n**Issues:** {$issueCount}\n\n| Repository | Issue |\n|---|---|\n{$table}\n\n---\n*Auto-created by `rotate_secrets.php`*\n";
[$_, $existing] = $this->ghApi('GET', "repos/{$org}/moko-platform/issues?labels=secret-audit&state=all&per_page=1&sort=created&direction=desc", null);
if (!empty($existing[0]['number'])) {
$num = $existing[0]['number'];
$this->ghApi('PATCH', "repos/{$org}/moko-platform/issues/{$num}", ['title' => "audit: FTP secrets -- {$issueCount} issues", 'body' => $body, 'state' => 'open', 'assignees' => ['jmiller']]);
if (!$jsonOut) { echo "Updated audit issue #{$num}\n"; }
} else {
[$_, $issue] = $this->ghApi('POST', "repos/{$org}/moko-platform/issues", ['title' => "audit: FTP secrets -- {$issueCount} issues", 'body' => $body, 'labels' => ['secret-audit', 'type: chore', 'automation'], 'assignees' => ['jmiller']]);
if (!$jsonOut) { echo "Created audit issue #{$issue['number']}\n"; }
}
}
return $issueCount > 0 ? 1 : 0;
}
if ($createIssue && $issueCount > 0) {
$now = gmdate('Y-m-d H:i:s') . ' UTC';
$rows = [];
foreach ($results as $r) {
foreach ($r['missing'] as $m) {
$rows[] = "| `{$r['repo']}` | {$m} |";
}
}
$table = implode("\n", $rows);
$body = "## FTP Secret/Variable Audit\n\n"
. "**Date:** {$now}\n"
. "**Issues:** {$issueCount}\n\n"
. "| Repository | Issue |\n|---|---|\n"
. "{$table}\n\n---\n"
. "*Auto-created by `rotate_secrets.php`*\n";
$auditQuery = "repos/{$org}/moko-platform/issues"
. "?labels=secret-audit&state=all"
. "&per_page=1&sort=created&direction=desc";
[$_, $existing] = $this->ghApi('GET', $auditQuery, null);
$auditTitle = "audit: FTP secrets"
. " -- {$issueCount} issues";
if (!empty($existing[0]['number'])) {
$num = $existing[0]['number'];
$this->ghApi(
'PATCH',
"repos/{$org}/moko-platform/issues/{$num}",
[
'title' => $auditTitle,
'body' => $body,
'state' => 'open',
'assignees' => ['jmiller'],
]
);
if (!$jsonOut) {
echo "Updated audit issue #{$num}\n";
}
} else {
[$_, $issue] = $this->ghApi(
'POST',
"repos/{$org}/moko-platform/issues",
[
'title' => $auditTitle,
'body' => $body,
'labels' => ['secret-audit', 'type: chore', 'automation'],
'assignees' => ['jmiller'],
]
);
if (!$jsonOut) {
echo "Created audit issue #{$issue['number']}\n";
}
}
}
return $issueCount > 0 ? 1 : 0;
}
private function ghApi(string $method, string $path, ?array $body): array
{
try {
$result = match ($method) {
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []),
'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"),
};
return [200, $result];
} catch (\Exception $e) { return [500, ['message' => $e->getMessage()]]; }
}
private function ghApi(string $method, string $path, ?array $body): array
{
try {
$result = match ($method) {
'GET' => $this->api->get("/{$path}"), 'POST' => $this->api->post("/{$path}", $body ?? []),
'PATCH' => $this->api->patch("/{$path}", $body ?? []), 'PUT' => $this->api->put("/{$path}", $body ?? []),
'DELETE' => $this->api->delete("/{$path}"), default => throw new \RuntimeException("Unsupported method: {$method}"),
};
return [200, $result];
} catch (\Exception $e) {
return [500, ['message' => $e->getMessage()]];
}
}
private function listNames(string $path, string $key): array
{
$names = []; $page = 1;
do {
[$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null);
if ($status !== 200) { break; }
$items = ($key === '') ? $data : ($data[$key] ?? []);
foreach ($items as $item) { if (isset($item['name'])) { $names[] = $item['name']; } }
$page++;
} while (count($items) === 100);
return $names;
}
private function listNames(string $path, string $key): array
{
$names = [];
$page = 1;
do {
[$status, $data] = $this->ghApi('GET', "{$path}?per_page=100&page={$page}", null);
if ($status !== 200) {
break;
}
$items = ($key === '') ? $data : ($data[$key] ?? []);
foreach ($items as $item) {
if (isset($item['name'])) {
$names[] = $item['name'];
}
}
$page++;
} while (count($items) === 100);
return $names;
}
}
$app = new RotateSecretsCli();
+189 -188
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* REQUIRED FILE: This file must be present in all moko-platform-compliant repositories
@@ -33,218 +34,218 @@ use MokoEnterprise\PlatformAdapterFactory;
*/
class SetupLabels extends CliFramework
{
private ?GitPlatformAdapter $adapter = null;
/**
* Label definitions — [name, hexColor (no #), description].
*
* @var list<array{0: string, 1: string, 2: string}>
*/
private const LABELS = [
// Project Type
['joomla', '7F52FF', 'Joomla extension or component'],
['dolibarr', 'FF6B6B', 'Dolibarr module or extension'],
['generic', '808080', 'Generic project or library'],
private ?GitPlatformAdapter $adapter = null;
/**
* Label definitions — [name, hexColor (no #), description].
*
* @var list<array{0: string, 1: string, 2: string}>
*/
private const LABELS = [
// Project Type
['joomla', '7F52FF', 'Joomla extension or component'],
['dolibarr', 'FF6B6B', 'Dolibarr module or extension'],
['generic', '808080', 'Generic project or library'],
// Language
['php', '4F5D95', 'PHP code changes'],
['javascript', 'F7DF1E', 'JavaScript code changes'],
['typescript', '3178C6', 'TypeScript code changes'],
['python', '3776AB', 'Python code changes'],
['css', '1572B6', 'CSS/styling changes'],
['html', 'E34F26', 'HTML template changes'],
// Language
['php', '4F5D95', 'PHP code changes'],
['javascript', 'F7DF1E', 'JavaScript code changes'],
['typescript', '3178C6', 'TypeScript code changes'],
['python', '3776AB', 'Python code changes'],
['css', '1572B6', 'CSS/styling changes'],
['html', 'E34F26', 'HTML template changes'],
// Component
['documentation', '0075CA', 'Documentation changes'],
['ci-cd', '000000', 'CI/CD pipeline changes'],
['docker', '2496ED', 'Docker configuration changes'],
['tests', '00FF00', 'Test suite changes'],
['security', 'FF0000', 'Security-related changes'],
['dependencies', '0366D6', 'Dependency updates'],
['config', 'F9D0C4', 'Configuration file changes'],
['build', 'FFA500', 'Build system changes'],
// Component
['documentation', '0075CA', 'Documentation changes'],
['ci-cd', '000000', 'CI/CD pipeline changes'],
['docker', '2496ED', 'Docker configuration changes'],
['tests', '00FF00', 'Test suite changes'],
['security', 'FF0000', 'Security-related changes'],
['dependencies', '0366D6', 'Dependency updates'],
['config', 'F9D0C4', 'Configuration file changes'],
['build', 'FFA500', 'Build system changes'],
// Workflow / Process
['automation', '8B4513', 'Automated processes or scripts'],
['moko-platform', 'B60205', 'moko-platform compliance'],
['needs-review', 'FBCA04', 'Awaiting code review'],
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
// Workflow / Process
['automation', '8B4513', 'Automated processes or scripts'],
['moko-platform', 'B60205', 'moko-platform compliance'],
['needs-review', 'FBCA04', 'Awaiting code review'],
['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'],
['breaking-change', 'D73A4A', 'Breaking API or functionality change'],
// Priority
['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'],
['priority: high', 'D93F0B', 'High priority'],
['priority: medium', 'FBCA04', 'Medium priority'],
['priority: low', '0E8A16', 'Low priority'],
// Priority
['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'],
['priority: high', 'D93F0B', 'High priority'],
['priority: medium', 'FBCA04', 'Medium priority'],
['priority: low', '0E8A16', 'Low priority'],
// Type
['type: bug', 'D73A4A', "Something isn't working"],
['type: feature', 'A2EEEF', 'New feature or request'],
['type: enhancement', '84B6EB', 'Enhancement to existing feature'],
['type: refactor', 'F9D0C4', 'Code refactoring'],
['type: chore', 'FEF2C0', 'Maintenance tasks'],
// Type
['type: bug', 'D73A4A', "Something isn't working"],
['type: feature', 'A2EEEF', 'New feature or request'],
['type: enhancement', '84B6EB', 'Enhancement to existing feature'],
['type: refactor', 'F9D0C4', 'Code refactoring'],
['type: chore', 'FEF2C0', 'Maintenance tasks'],
// Status
['status: pending', 'FBCA04', 'Pending action or decision'],
['status: in-progress', '0E8A16', 'Currently being worked on'],
['status: blocked', 'B60205', 'Blocked by another issue or dependency'],
['status: on-hold', 'D4C5F9', 'Temporarily on hold'],
['status: wontfix', 'FFFFFF', 'This will not be worked on'],
// Status
['status: pending', 'FBCA04', 'Pending action or decision'],
['status: in-progress', '0E8A16', 'Currently being worked on'],
['status: blocked', 'B60205', 'Blocked by another issue or dependency'],
['status: on-hold', 'D4C5F9', 'Temporarily on hold'],
['status: wontfix', 'FFFFFF', 'This will not be worked on'],
// Size
['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'],
['size/s', '6FD1E2', 'Small change (11-30 lines)'],
['size/m', 'F9DD72', 'Medium change (31-100 lines)'],
['size/l', 'FFA07A', 'Large change (101-300 lines)'],
['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'],
['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'],
// Size
['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'],
['size/s', '6FD1E2', 'Small change (11-30 lines)'],
['size/m', 'F9DD72', 'Medium change (31-100 lines)'],
['size/l', 'FFA07A', 'Large change (101-300 lines)'],
['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'],
['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'],
// Health
['health: excellent', '0E8A16', 'Health score 90-100'],
['health: good', 'FBCA04', 'Health score 70-89'],
['health: fair', 'FFA500', 'Health score 50-69'],
['health: poor', 'FF6B6B', 'Health score below 50'],
// Health
['health: excellent', '0E8A16', 'Health score 90-100'],
['health: good', 'FBCA04', 'Health score 70-89'],
['health: fair', 'FFA500', 'Health score 50-69'],
['health: poor', 'FF6B6B', 'Health score below 50'],
// Sync / Automation
['standards-update', 'B60205', 'moko-platform sync update'],
['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'],
['sync-report', '0075CA', 'Bulk sync run report'],
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
['push-failure', 'D73A4A', 'File push failure requiring attention'],
['health-check', '0E8A16', 'Repository health check results'],
['version-drift', 'FFA500', 'Version mismatch detected'],
['deploy-failure', 'CC0000', 'Automated deploy failure tracking'],
['template-validation-failure', 'D73A4A', 'Template workflow validation failure'],
['version', '0E8A16', 'Version bump or release'],
['type: version', '0E8A16', 'Version-related change'],
// Sync / Automation
['standards-update', 'B60205', 'moko-platform sync update'],
['standards-drift', 'FBCA04', 'Repository drifted from moko-platform'],
['sync-report', '0075CA', 'Bulk sync run report'],
['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'],
['push-failure', 'D73A4A', 'File push failure requiring attention'],
['health-check', '0E8A16', 'Repository health check results'],
['version-drift', 'FFA500', 'Version mismatch detected'],
['deploy-failure', 'CC0000', 'Automated deploy failure tracking'],
['template-validation-failure', 'D73A4A', 'Template workflow validation failure'],
['version', '0E8A16', 'Version bump or release'],
['type: version', '0E8A16', 'Version-related change'],
// Testing
['type: test', '00FF00', 'Test suite additions or changes'],
['needs-testing', 'FBCA04', 'Requires manual or automated testing'],
['test-failure', 'D73A4A', 'Automated test failure'],
['regression', 'B60205', 'Regression from a previous working state'],
// Testing
['type: test', '00FF00', 'Test suite additions or changes'],
['needs-testing', 'FBCA04', 'Requires manual or automated testing'],
['test-failure', 'D73A4A', 'Automated test failure'],
['regression', 'B60205', 'Regression from a previous working state'],
// Version & Release
['type: release', '0E8A16', 'Release preparation or tracking'],
['release-candidate', 'BFD4F2', 'Release candidate build'],
['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'],
['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'],
['major-release', 'B60205', 'Major version release (breaking changes)'],
['version-branch', '1D76DB', 'Version branch related'],
];
// Version & Release
['type: release', '0E8A16', 'Release preparation or tracking'],
['release-candidate', 'BFD4F2', 'Release candidate build'],
['minor-release', '0E8A16', 'Minor version release (XX.YY.00)'],
['patch-release', 'C5DEF5', 'Patch version release (XX.YY.ZZ)'],
['major-release', 'B60205', 'Major version release (breaking changes)'],
['version-branch', '1D76DB', 'Version branch related'],
];
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('REQUIRED: Deploy standard labels to repository');
$this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false);
$this->addArgument('--org', 'Organization name', 'mokoconsulting-tech');
$this->addArgument('--repo', 'Repository name (defaults to current repo)', '');
}
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('REQUIRED: Deploy standard labels to repository');
$this->addArgument('--dry-run', 'Show what would be created without actually creating labels', false);
$this->addArgument('--org', 'Organization name', 'mokoconsulting-tech');
$this->addArgument('--repo', 'Repository name (defaults to current repo)', '');
}
/**
* Run the label deployment.
*
* @return int Exit code: 0 on success, 1 on error.
*/
protected function run(): int
{
$dryRun = (bool) $this->getArgument('--dry-run');
/**
* Run the label deployment.
*
* @return int Exit code: 0 on success, 1 on error.
*/
protected function run(): int
{
$dryRun = (bool) $this->getArgument('--dry-run');
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
} catch (\RuntimeException $e) {
$this->log('ERROR', $e->getMessage());
return 1;
}
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
} catch (\RuntimeException $e) {
$this->log('ERROR', $e->getMessage());
return 1;
}
$orgArg = (string) $this->getArgument('--org');
$repoArg = (string) $this->getArgument('--repo');
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
$repo = $repoArg ?: basename(getcwd() ?: '.');
$orgArg = (string) $this->getArgument('--org');
$repoArg = (string) $this->getArgument('--repo');
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
$repo = $repoArg ?: basename(getcwd() ?: '.');
$this->log('INFO', "Setting up labels for repository: {$org}/{$repo} ({$this->adapter->getPlatformName()})");
$this->log('INFO', "Setting up labels for repository: {$org}/{$repo} ({$this->adapter->getPlatformName()})");
echo "\n";
echo "\n";
$this->deployGroup('Creating REQUIRED project type labels...', 0, 2, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED language labels...', 3, 8, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED component labels...', 9, 16, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED workflow labels...', 17, 21, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED priority labels...', 22, 25, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED type labels...', 26, 30, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED status labels...', 31, 35, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED size labels...', 36, 41, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED health labels...', 42, 45, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED sync/automation labels...', 46, 56, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED testing labels...', 57, 60, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED project type labels...', 0, 2, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED language labels...', 3, 8, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED component labels...', 9, 16, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED workflow labels...', 17, 21, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED priority labels...', 22, 25, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED type labels...', 26, 30, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED status labels...', 31, 35, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED size labels...', 36, 41, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED health labels...', 42, 45, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED sync/automation labels...', 46, 56, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED testing labels...', 57, 60, $org, $repo, $dryRun);
$this->deployGroup('Creating REQUIRED version/release labels...', 61, 66, $org, $repo, $dryRun);
echo "\n============================================================\n";
if ($dryRun) {
$this->log('INFO', '[DRY-RUN] Label deployment simulation completed');
} else {
$this->log('INFO', 'Label deployment completed successfully!');
echo "\n - TOTAL: " . count(self::LABELS) . " labels\n";
}
echo "============================================================\n\n";
echo "\n============================================================\n";
if ($dryRun) {
$this->log('INFO', '[DRY-RUN] Label deployment simulation completed');
} else {
$this->log('INFO', 'Label deployment completed successfully!');
echo "\n - TOTAL: " . count(self::LABELS) . " labels\n";
}
echo "============================================================\n\n";
return 0;
}
return 0;
}
// ── Private helpers ───────────────────────────────────────────────────────
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Deploy a named group of labels by index range in self::LABELS.
*
* @param string $heading Informational banner printed before the group.
* @param int $fromIndex First label index (inclusive).
* @param int $toIndex Last label index (inclusive).
* @param string $org Organization name.
* @param string $repo Repository name.
* @param bool $dryRun When true, preview only.
*/
private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void
{
$this->log('INFO', $heading);
for ($i = $fromIndex; $i <= $toIndex; $i++) {
[$name, $color, $desc] = self::LABELS[$i];
$this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun);
}
echo "\n";
}
/**
* Deploy a named group of labels by index range in self::LABELS.
*
* @param string $heading Informational banner printed before the group.
* @param int $fromIndex First label index (inclusive).
* @param int $toIndex Last label index (inclusive).
* @param string $org Organization name.
* @param string $repo Repository name.
* @param bool $dryRun When true, preview only.
*/
private function deployGroup(string $heading, int $fromIndex, int $toIndex, string $org, string $repo, bool $dryRun): void
{
$this->log('INFO', $heading);
for ($i = $fromIndex; $i <= $toIndex; $i++) {
[$name, $color, $desc] = self::LABELS[$i];
$this->createLabelViaApi($name, $color, $desc, $org, $repo, $dryRun);
}
echo "\n";
}
/**
* Create or update a single label via the platform adapter.
*
* @param string $name Label name.
* @param string $color Hex colour without the leading '#'.
* @param string $desc Short description text.
* @param string $org Organization name.
* @param string $repo Repository name.
* @param bool $dryRun When true, preview only.
*/
private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void
{
if ($dryRun) {
echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n";
return;
}
/**
* Create or update a single label via the platform adapter.
*
* @param string $name Label name.
* @param string $color Hex colour without the leading '#'.
* @param string $desc Short description text.
* @param string $org Organization name.
* @param string $repo Repository name.
* @param bool $dryRun When true, preview only.
*/
private function createLabelViaApi(string $name, string $color, string $desc, string $org, string $repo, bool $dryRun): void
{
if ($dryRun) {
echo "[DRY-RUN] Would create label: {$name} (color: #{$color}, description: {$desc})\n";
return;
}
try {
$this->adapter->createLabel($org, $repo, $name, $color, $desc);
$this->log('INFO', "Created/updated label: {$name}");
} catch (\Exception $e) {
// Label may already exist — that's fine
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
$this->log('INFO', "Label already exists: {$name}");
} else {
$this->log('WARNING', "Failed to create label: {$name}" . $e->getMessage());
}
}
}
try {
$this->adapter->createLabel($org, $repo, $name, $color, $desc);
$this->log('INFO', "Created/updated label: {$name}");
} catch (\Exception $e) {
// Label may already exist — that's fine
if (str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), 'already exists')) {
$this->log('INFO', "Label already exists: {$name}");
} else {
$this->log('WARNING', "Failed to create label: {$name}" . $e->getMessage());
}
}
}
}
$script = new SetupLabels('setup_labels', 'REQUIRED: Deploy standard labels to repository');
+242 -232
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -34,215 +35,224 @@ use MokoEnterprise\CliFramework;
*/
class SyncDolibarrReadmes extends CliFramework
{
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Keeps root README.md and src/README.md in sync for Dolibarr module repos');
$this->addArgument('--path', 'Dolibarr module repo root', '.');
$this->addArgument('--dry-run', 'Preview changes without writing', false);
}
/**
* Configure available arguments.
*/
protected function configure(): void
{
$this->setDescription('Keeps root README.md and src/README.md in sync for Dolibarr module repos');
$this->addArgument('--path', 'Dolibarr module repo root', '.');
$this->addArgument('--dry-run', 'Preview changes without writing', false);
}
/**
* Run the sync.
*
* @return int Exit code: 0 on success, 1 on error.
*/
protected function run(): int
{
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
$dryRun = (bool) $this->getArgument('--dry-run');
$rootReadme = $repoRoot . '/README.md';
$srcReadme = $repoRoot . '/src/README.md';
/**
* Run the sync.
*
* @return int Exit code: 0 on success, 1 on error.
*/
protected function run(): int
{
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
$dryRun = (bool) $this->getArgument('--dry-run');
$rootReadme = $repoRoot . '/README.md';
$srcReadme = $repoRoot . '/src/README.md';
if (!is_file($rootReadme)) {
$this->log('ERROR', "Root README.md not found at {$rootReadme}");
return 1;
}
if (!is_file($rootReadme)) {
$this->log('ERROR', "Root README.md not found at {$rootReadme}");
return 1;
}
if (!is_dir($repoRoot . '/src')) {
$this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?');
return 1;
}
if (!is_dir($repoRoot . '/src')) {
$this->log('ERROR', 'src/ directory not found — is this a Dolibarr module repository?');
return 1;
}
$rootContent = (string) file_get_contents($rootReadme);
$rootContent = (string) file_get_contents($rootReadme);
if (!preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $rootContent, $m)) {
$this->log('ERROR', 'Could not find VERSION in root README.md FILE INFORMATION block');
return 1;
}
$version = $m[1];
if (!preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $rootContent, $m)) {
$this->log('ERROR', 'Could not find VERSION in root README.md FILE INFORMATION block');
return 1;
}
$version = $m[1];
$moduleName = $this->extractModuleName($rootContent, $repoRoot);
$repoUrl = $this->extractField($rootContent, 'REPO', 'https://git.mokoconsulting.tech/MokoConsulting');
$defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoPlatform.Module');
$ingroup = $this->extractField($rootContent, 'INGROUP', 'moko-platform');
$brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation");
$moduleName = $this->extractModuleName($rootContent, $repoRoot);
$repoUrl = $this->extractField($rootContent, 'REPO', 'https://git.mokoconsulting.tech/MokoConsulting');
$defgroup = $this->extractField($rootContent, 'DEFGROUP', 'MokoPlatform.Module');
$ingroup = $this->extractField($rootContent, 'INGROUP', 'moko-platform');
$brief = $this->extractField($rootContent, 'BRIEF', "{$moduleName} end-user documentation");
$installSection = $this->extractSection($rootContent, 'Installation');
$configSection = $this->extractSection($rootContent, 'Configuration');
$usageSection = $this->extractSection($rootContent, 'Usage');
$supportSection = $this->extractSection($rootContent, 'Support');
$installSection = $this->extractSection($rootContent, 'Installation');
$configSection = $this->extractSection($rootContent, 'Configuration');
$usageSection = $this->extractSection($rootContent, 'Usage');
$supportSection = $this->extractSection($rootContent, 'Support');
echo "═══════════════════════════════════════════════════════════\n";
echo " Dolibarr README Sync\n";
echo "═══════════════════════════════════════════════════════════\n\n";
echo "Module: {$moduleName}\n";
echo "Version: {$version}\n";
echo "Root: {$rootReadme}\n";
echo "Src: {$srcReadme}\n";
if ($dryRun) {
echo " DRY RUN — no files will be written\n";
}
echo "\n";
echo "═══════════════════════════════════════════════════════════\n";
echo " Dolibarr README Sync\n";
echo "═══════════════════════════════════════════════════════════\n\n";
echo "Module: {$moduleName}\n";
echo "Version: {$version}\n";
echo "Root: {$rootReadme}\n";
echo "Src: {$srcReadme}\n";
if ($dryRun) {
echo " DRY RUN — no files will be written\n";
}
echo "\n";
echo "Step 1: Update root README.md badges and VERSION field...\n";
$this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun);
echo "Step 1: Update root README.md badges and VERSION field...\n";
$this->updateRootReadme($rootReadme, $rootContent, $version, $dryRun);
echo "Step 2: Sync src/README.md...\n";
$today = gmdate('Y-m-d');
$newSrcContent = $this->buildSrcReadme(
$version, $moduleName, $repoUrl, $defgroup, $ingroup, $brief, $today,
$installSection, $configSection, $usageSection, $supportSection
);
$this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun);
echo "Step 2: Sync src/README.md...\n";
$today = gmdate('Y-m-d');
$newSrcContent = $this->buildSrcReadme(
$version,
$moduleName,
$repoUrl,
$defgroup,
$ingroup,
$brief,
$today,
$installSection,
$configSection,
$usageSection,
$supportSection
);
$this->syncSrcReadme($srcReadme, $newSrcContent, $dryRun);
echo "\n═══════════════════════════════════════════════════════════\n";
if ($dryRun) {
echo " Dry Run Complete\n";
echo "═══════════════════════════════════════════════════════════\n";
echo "Run without --dry-run to apply changes.\n";
} else {
echo " Dolibarr README Sync Complete\n";
echo "═══════════════════════════════════════════════════════════\n";
echo "Module version: {$version}\n\n";
echo "Next steps:\n";
echo " git diff && git add README.md src/README.md\n";
echo " git commit -m \"docs(readme): sync src/README.md from root for version {$version}\"\n";
}
echo "\n";
echo "\n═══════════════════════════════════════════════════════════\n";
if ($dryRun) {
echo " Dry Run Complete\n";
echo "═══════════════════════════════════════════════════════════\n";
echo "Run without --dry-run to apply changes.\n";
} else {
echo " Dolibarr README Sync Complete\n";
echo "═══════════════════════════════════════════════════════════\n";
echo "Module version: {$version}\n\n";
echo "Next steps:\n";
echo " git diff && git add README.md src/README.md\n";
echo " git commit -m \"docs(readme): sync src/README.md from root for version {$version}\"\n";
}
echo "\n";
return 0;
}
return 0;
}
// ── Private helpers ───────────────────────────────────────────────────────
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Extract a named field from the FILE INFORMATION block.
*
* @param string $content Full file content.
* @param string $field Field name (e.g. 'REPO').
* @param string $fallback Value to use when the field is absent.
* @return string Field value or fallback.
*/
private function extractField(string $content, string $field, string $fallback): string
{
if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) {
return trim($m[1]);
}
return $fallback;
}
/**
* Extract a named field from the FILE INFORMATION block.
*
* @param string $content Full file content.
* @param string $field Field name (e.g. 'REPO').
* @param string $fallback Value to use when the field is absent.
* @return string Field value or fallback.
*/
private function extractField(string $content, string $field, string $fallback): string
{
if (preg_match('/^\s*' . preg_quote($field, '/') . ':\s*(.+)$/m', $content, $m)) {
return trim($m[1]);
}
return $fallback;
}
/**
* Extract the module name from the first H1 heading after the closing '-->' of the header.
*
* @param string $content Full root README.md content.
* @param string $repoRoot Repository root path (used as fallback).
* @return string Module name.
*/
private function extractModuleName(string $content, string $repoRoot): string
{
if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) {
return trim($m[1]);
}
return basename($repoRoot);
}
/**
* Extract the module name from the first H1 heading after the closing '-->' of the header.
*
* @param string $content Full root README.md content.
* @param string $repoRoot Repository root path (used as fallback).
* @return string Module name.
*/
private function extractModuleName(string $content, string $repoRoot): string
{
if (preg_match('/-->\s*\n+# (.+)/u', $content, $m)) {
return trim($m[1]);
}
return basename($repoRoot);
}
/**
* Extract a Markdown H2 section (from '## Heading' to the next '## ').
*
* @param string $content Full file content.
* @param string $heading Section heading (without '## ' prefix).
* @return string The extracted section text, or '' if not found.
*/
private function extractSection(string $content, string $heading): string
{
$quoted = preg_quote($heading, '/');
if (!preg_match('/^## ' . $quoted . '$/m', $content)) {
return '';
}
if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) {
return '## ' . $heading . $m[1];
}
return '';
}
/**
* Extract a Markdown H2 section (from '## Heading' to the next '## ').
*
* @param string $content Full file content.
* @param string $heading Section heading (without '## ' prefix).
* @return string The extracted section text, or '' if not found.
*/
private function extractSection(string $content, string $heading): string
{
$quoted = preg_quote($heading, '/');
if (!preg_match('/^## ' . $quoted . '$/m', $content)) {
return '';
}
if (preg_match('/^## ' . $quoted . '$(.*?)(?=^## |\Z)/ms', $content, $m)) {
return '## ' . $heading . $m[1];
}
return '';
}
/**
* Update the version badge and VERSION field in root README.md.
*
* @param string $path Path to root README.md.
* @param string $content Current file content.
* @param string $version New version string.
* @param bool $dryRun When true, preview only.
*/
private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void
{
$updated = preg_replace(
'/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i',
'${1}' . $version,
$content
);
$updated = preg_replace(
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $version,
(string) $updated
);
/**
* Update the version badge and VERSION field in root README.md.
*
* @param string $path Path to root README.md.
* @param string $content Current file content.
* @param string $version New version string.
* @param bool $dryRun When true, preview only.
*/
private function updateRootReadme(string $path, string $content, string $version, bool $dryRun): void
{
$updated = preg_replace(
'/(https:\/\/img\.shields\.io\/badge\/MokoStandards-)\d{2}\.\d{2}\.\d{2}/i',
'${1}' . $version,
$content
);
$updated = preg_replace(
'/^(\s*VERSION:\s*)\d{2}\.\d{2}\.\d{2}/m',
'${1}' . $version,
(string) $updated
);
if ($updated === $content) {
echo " ✓ root README.md already current\n";
return;
}
if ($updated === $content) {
echo " ✓ root README.md already current\n";
return;
}
if ($dryRun) {
echo " ~ root README.md (would update version fields)\n";
return;
}
if ($dryRun) {
echo " ~ root README.md (would update version fields)\n";
return;
}
file_put_contents($path, (string) $updated);
echo " ✓ root README.md updated\n";
}
file_put_contents($path, (string) $updated);
echo " ✓ root README.md updated\n";
}
/**
* Build the full content for src/README.md.
*
* @param string $version Version string.
* @param string $moduleName Module display name.
* @param string $repoUrl Repository URL.
* @param string $defgroup DEFGROUP value.
* @param string $ingroup INGROUP value.
* @param string $brief BRIEF value.
* @param string $today ISO date string (YYYY-MM-DD).
* @param string $installSection Extracted Installation section (may be '').
* @param string $configSection Extracted Configuration section (may be '').
* @param string $usageSection Extracted Usage section (may be '').
* @param string $supportSection Extracted Support section (may be '').
* @return string Complete file content.
*/
private function buildSrcReadme(
string $version,
string $moduleName,
string $repoUrl,
string $defgroup,
string $ingroup,
string $brief,
string $today,
string $installSection,
string $configSection,
string $usageSection,
string $supportSection
): string {
$content = <<<SRCREADME
/**
* Build the full content for src/README.md.
*
* @param string $version Version string.
* @param string $moduleName Module display name.
* @param string $repoUrl Repository URL.
* @param string $defgroup DEFGROUP value.
* @param string $ingroup INGROUP value.
* @param string $brief BRIEF value.
* @param string $today ISO date string (YYYY-MM-DD).
* @param string $installSection Extracted Installation section (may be '').
* @param string $configSection Extracted Configuration section (may be '').
* @param string $usageSection Extracted Usage section (may be '').
* @param string $supportSection Extracted Support section (may be '').
* @return string Complete file content.
*/
private function buildSrcReadme(
string $version,
string $moduleName,
string $repoUrl,
string $defgroup,
string $ingroup,
string $brief,
string $today,
string $installSection,
string $configSection,
string $usageSection,
string $supportSection
): string {
$content = <<<SRCREADME
<!--
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;
foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) {
if ($section !== '') {
$content .= "\n" . $section;
}
}
foreach ([$installSection, $configSection, $usageSection, $supportSection] as $section) {
if ($section !== '') {
$content .= "\n" . $section;
}
}
$content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n";
return $content;
}
$content .= "\n---\n\n*Documentation generated from root `README.md` — do not edit this file directly.*\n";
return $content;
}
/**
* Compare and write (or preview) src/README.md.
*
* @param string $path Path to src/README.md.
* @param string $content Desired file content.
* @param bool $dryRun When true, preview only.
*/
private function syncSrcReadme(string $path, string $content, bool $dryRun): void
{
if (is_file($path)) {
$existing = (string) file_get_contents($path);
if ($existing === $content) {
echo " ✓ src/README.md already current\n";
return;
}
if ($dryRun) {
echo " ~ src/README.md (would regenerate)\n";
return;
}
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, $content);
echo " ✓ src/README.md regenerated\n";
return;
}
/**
* Compare and write (or preview) src/README.md.
*
* @param string $path Path to src/README.md.
* @param string $content Desired file content.
* @param bool $dryRun When true, preview only.
*/
private function syncSrcReadme(string $path, string $content, bool $dryRun): void
{
if (is_file($path)) {
$existing = (string) file_get_contents($path);
if ($existing === $content) {
echo " ✓ src/README.md already current\n";
return;
}
if ($dryRun) {
echo " ~ src/README.md (would regenerate)\n";
return;
}
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, $content);
echo " ✓ src/README.md regenerated\n";
return;
}
if ($dryRun) {
echo " ~ src/README.md (would create — file does not exist)\n";
return;
}
if ($dryRun) {
echo " ~ src/README.md (would create — file does not exist)\n";
return;
}
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, $content);
echo " ✓ src/README.md created\n";
}
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
file_put_contents($path, $content);
echo " ✓ src/README.md created\n";
}
}
$script = new SyncDolibarrReadmes('sync_dolibarr_readmes', 'Keeps root README.md and src/README.md in sync for Dolibarr module repos');
+219 -218
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
@@ -36,274 +37,274 @@ use MokoEnterprise\PlatformAdapterFactory;
*/
class UpdateRepoInventory extends CliFramework
{
private ?GitPlatformAdapter $adapter = null;
private ?GitPlatformAdapter $adapter = null;
/** Marker that begins the auto-generated block. */
private const MARKER_START = '<!-- INVENTORY_TABLE_START -->';
/** Marker that begins the auto-generated block. */
private const MARKER_START = '<!-- INVENTORY_TABLE_START -->';
/** Marker that ends the auto-generated block. */
private const MARKER_END = '<!-- INVENTORY_TABLE_END -->';
/** Marker that ends the auto-generated block. */
private const MARKER_END = '<!-- INVENTORY_TABLE_END -->';
/** Path to the Dolibarr module registry relative to repo root. */
private const REGISTRY_PATH = 'docs/development/crm/module-registry.md';
/** Path to the Dolibarr module registry relative to repo root. */
private const REGISTRY_PATH = 'docs/development/crm/module-registry.md';
/** Path to the inventory file relative to repo root. */
private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md';
/** Path to the inventory file relative to repo root. */
private const INVENTORY_PATH = 'docs/reference/REPOSITORY_INVENTORY.md';
protected function configure(): void
{
$this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list');
$this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech');
$this->addArgument('--path', 'Repository root path', '.');
}
protected function configure(): void
{
$this->setDescription('Updates docs/reference/REPOSITORY_INVENTORY.md with current org repo list');
$this->addArgument('--org', 'Organisation to query', 'mokoconsulting-tech');
$this->addArgument('--path', 'Repository root path', '.');
}
protected function run(): int
{
$root = rtrim((string) $this->getArgument('--path'), '/\\');
protected function run(): int
{
$root = rtrim((string) $this->getArgument('--path'), '/\\');
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
} catch (\RuntimeException $e) {
$this->status(false, 'auth', $e->getMessage());
return 2;
}
$config = Config::load();
try {
$this->adapter = PlatformAdapterFactory::create($config);
} catch (\RuntimeException $e) {
$this->status(false, 'auth', $e->getMessage());
return 2;
}
$orgArg = (string) $this->getArgument('--org');
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
$orgArg = (string) $this->getArgument('--org');
$org = $orgArg ?: $config->getString($this->adapter->getPlatformName() . '.organization', 'mokoconsulting-tech');
// ── 1. Fetch repositories ─────────────────────────────────────────────
$this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})");
// ── 1. Fetch repositories ─────────────────────────────────────────────
$this->section("Fetching repositories for {$org} ({$this->adapter->getPlatformName()})");
$repos = $this->fetchAllRepos($org);
if ($repos === null) {
return 1;
}
$repos = $this->fetchAllRepos($org);
if ($repos === null) {
return 1;
}
$this->status(true, 'API', sprintf('Fetched %d repositories', count($repos)));
$this->status(true, 'API', sprintf('Fetched %d repositories', count($repos)));
// ── 2. Load Dolibarr module registry ──────────────────────────────────
$this->section('Loading Dolibarr module registry');
// ── 2. Load Dolibarr module registry ──────────────────────────────────
$this->section('Loading Dolibarr module registry');
$moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH);
$this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap)));
$moduleMap = $this->parseModuleRegistry($root . '/' . self::REGISTRY_PATH);
$this->status(true, 'registry', sprintf('Loaded %d module ID entries', count($moduleMap)));
// ── 3. Build the Markdown tables ──────────────────────────────────────
$this->section('Building inventory tables');
// ── 3. Build the Markdown tables ──────────────────────────────────────
$this->section('Building inventory tables');
$table = $this->buildTables($repos, $moduleMap, $org);
$table = $this->buildTables($repos, $moduleMap, $org);
// ── 4. Rewrite the inventory file ────────────────────────────────────
$this->section('Updating ' . self::INVENTORY_PATH);
// ── 4. Rewrite the inventory file ────────────────────────────────────
$this->section('Updating ' . self::INVENTORY_PATH);
$inventoryPath = $root . '/' . self::INVENTORY_PATH;
if (!is_file($inventoryPath)) {
$this->status(false, self::INVENTORY_PATH, 'file not found');
return 2;
}
$inventoryPath = $root . '/' . self::INVENTORY_PATH;
if (!is_file($inventoryPath)) {
$this->status(false, self::INVENTORY_PATH, 'file not found');
return 2;
}
$original = (string) file_get_contents($inventoryPath);
$updated = $this->replaceSection($original, $table);
$original = (string) file_get_contents($inventoryPath);
$updated = $this->replaceSection($original, $table);
if ($original === $updated) {
$this->status(true, self::INVENTORY_PATH, 'no changes needed');
} elseif (!$this->isDryRun()) {
file_put_contents($inventoryPath, $updated);
$this->status(true, self::INVENTORY_PATH, 'updated');
} else {
$this->status(true, self::INVENTORY_PATH, '[dry-run] would update');
}
if ($original === $updated) {
$this->status(true, self::INVENTORY_PATH, 'no changes needed');
} elseif (!$this->isDryRun()) {
file_put_contents($inventoryPath, $updated);
$this->status(true, self::INVENTORY_PATH, 'updated');
} else {
$this->status(true, self::INVENTORY_PATH, '[dry-run] would update');
}
$this->printSummary(1, 0, $this->elapsed());
$this->printSummary(1, 0, $this->elapsed());
return 0;
}
return 0;
}
// ── Platform API ──────────────────────────────────────────────────────────
// ── Platform API ──────────────────────────────────────────────────────────
/**
* Fetch all repositories for the org via the platform adapter.
*
* @return list<array<string,mixed>>|null Null on API error.
*/
private function fetchAllRepos(string $org): ?array
{
try {
// Use the adapter's paginated listing — returns full repo objects
$repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
$this->progress(count($repos), count($repos), '', true);
return $repos;
} catch (\Exception $e) {
$this->status(false, 'API', $e->getMessage());
return null;
}
}
/**
* Fetch all repositories for the org via the platform adapter.
*
* @return list<array<string,mixed>>|null Null on API error.
*/
private function fetchAllRepos(string $org): ?array
{
try {
// Use the adapter's paginated listing — returns full repo objects
$repos = $this->adapter->paginateAll("/orgs/{$org}/repos", ['type' => 'all']);
$this->progress(count($repos), count($repos), '', true);
return $repos;
} catch (\Exception $e) {
$this->status(false, 'API', $e->getMessage());
return null;
}
}
// ── Module registry ───────────────────────────────────────────────────────
// ── Module registry ───────────────────────────────────────────────────────
/**
* Parse the Dolibarr module registry Markdown table.
*
* @return array<string,int> Map of lower-case repo name → module number.
*/
private function parseModuleRegistry(string $path): array
{
if (!is_file($path)) {
$this->warning("Module registry not found: {$path}");
return [];
}
/**
* Parse the Dolibarr module registry Markdown table.
*
* @return array<string,int> Map of lower-case repo name → module number.
*/
private function parseModuleRegistry(string $path): array
{
if (!is_file($path)) {
$this->warning("Module registry not found: {$path}");
return [];
}
$map = [];
$content = (string) file_get_contents($path);
$map = [];
$content = (string) file_get_contents($path);
// Match table rows: | ModuleName | 185051 | Status | … |
preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER);
// Match table rows: | ModuleName | 185051 | Status | … |
preg_match_all('/^\|\s*(\w+)\s*\|\s*(\d{6})\s*\|/m', $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$id = (int) $match[2];
if ($id >= 100000) {
$map[strtolower($match[1])] = $id;
}
}
foreach ($matches as $match) {
$id = (int) $match[2];
if ($id >= 100000) {
$map[strtolower($match[1])] = $id;
}
}
return $map;
}
return $map;
}
// ── Table builder ─────────────────────────────────────────────────────────
// ── Table builder ─────────────────────────────────────────────────────────
/**
* Build the full Markdown replacement for the inventory tables.
*
* @param list<array<string,mixed>> $repos
* @param array<string,int> $moduleMap
*/
private function buildTables(array $repos, array $moduleMap, string $org): string
{
// Sort: active first, then archived; within each group alphabetically.
usort($repos, static function (array $a, array $b): int {
$aArch = (bool) ($a['archived'] ?? false);
$bArch = (bool) ($b['archived'] ?? false);
if ($aArch !== $bArch) {
return $aArch ? 1 : -1;
}
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
/**
* Build the full Markdown replacement for the inventory tables.
*
* @param list<array<string,mixed>> $repos
* @param array<string,int> $moduleMap
*/
private function buildTables(array $repos, array $moduleMap, string $org): string
{
// Sort: active first, then archived; within each group alphabetically.
usort($repos, static function (array $a, array $b): int {
$aArch = (bool) ($a['archived'] ?? false);
$bArch = (bool) ($b['archived'] ?? false);
if ($aArch !== $bArch) {
return $aArch ? 1 : -1;
}
return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
/** @var array<string,list<array<string,mixed>>> $groups */
$groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []];
/** @var array<string,list<array<string,mixed>>> $groups */
$groups = ['core' => [], 'product' => [], 'extension' => [], 'template' => [], 'internal' => [], 'archived' => []];
foreach ($repos as $repo) {
$name = (string) ($repo['name'] ?? '');
$topics = array_map('strtolower', (array) ($repo['topics'] ?? []));
$archived = (bool) ($repo['archived'] ?? false);
foreach ($repos as $repo) {
$name = (string) ($repo['name'] ?? '');
$topics = array_map('strtolower', (array) ($repo['topics'] ?? []));
$archived = (bool) ($repo['archived'] ?? false);
if ($archived) {
$groups['archived'][] = $repo;
continue;
}
if ($archived) {
$groups['archived'][] = $repo;
continue;
}
$lower = strtolower($name);
$lower = strtolower($name);
if (in_array('mokostandards-core', $topics, true) || $name === 'moko-platform' || $name === '.github-private') {
$groups['core'][] = $repo;
} elseif (
in_array('dolibarr-module', $topics, true)
|| str_starts_with($lower, 'mokodoli')
|| (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme')
) {
$groups['extension'][] = $repo;
} elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) {
$groups['product'][] = $repo;
} elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) {
$groups['template'][] = $repo;
} else {
$groups['internal'][] = $repo;
}
}
if (in_array('mokostandards-core', $topics, true) || $name === 'moko-platform' || $name === '.github-private') {
$groups['core'][] = $repo;
} elseif (
in_array('dolibarr-module', $topics, true)
|| str_starts_with($lower, 'mokodoli')
|| (str_starts_with($lower, 'mokocrm') && $lower !== 'mokocrmtheme')
) {
$groups['extension'][] = $repo;
} elseif (in_array('product', $topics, true) || in_array('platform', $topics, true)) {
$groups['product'][] = $repo;
} elseif (in_array('template', $topics, true) || str_contains($lower, 'template')) {
$groups['template'][] = $repo;
} else {
$groups['internal'][] = $repo;
}
}
$updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T');
$lines = [
"> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.",
'> Do not edit this section manually; it is overwritten on every bulk sync.',
'',
];
$updated = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s T');
$lines = [
"> ⚙️ **Auto-generated** by `update_repo_inventory.php` — last updated {$updated}.",
'> Do not edit this section manually; it is overwritten on every bulk sync.',
'',
];
$groupLabels = [
'core' => 'Core Repositories',
'product' => 'Product Repositories',
'extension' => 'Extension Repositories (Dolibarr / CRM)',
'template' => 'Template Repositories',
'internal' => 'Internal and Testing',
'archived' => 'Archived Repositories',
];
$groupLabels = [
'core' => 'Core Repositories',
'product' => 'Product Repositories',
'extension' => 'Extension Repositories (Dolibarr / CRM)',
'template' => 'Template Repositories',
'internal' => 'Internal and Testing',
'archived' => 'Archived Repositories',
];
foreach ($groupLabels as $key => $label) {
if (empty($groups[$key])) {
continue;
}
foreach ($groupLabels as $key => $label) {
if (empty($groups[$key])) {
continue;
}
$lines[] = "### {$label}";
$lines[] = '';
$isExt = ($key === 'extension');
$lines[] = "### {$label}";
$lines[] = '';
$isExt = ($key === 'extension');
if ($isExt) {
$lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |';
$lines[] = '|------------|--------|-------------|-----------|----------|------------|';
} else {
$lines[] = '| Repository | Status | Description | Language | Visibility |';
$lines[] = '|------------|--------|-------------|----------|------------|';
}
if ($isExt) {
$lines[] = '| Repository | Status | Description | Module ID | Language | Visibility |';
$lines[] = '|------------|--------|-------------|-----------|----------|------------|';
} else {
$lines[] = '| Repository | Status | Description | Language | Visibility |';
$lines[] = '|------------|--------|-------------|----------|------------|';
}
foreach ($groups[$key] as $repo) {
$name = (string) ($repo['name'] ?? '');
$desc = str_replace('|', '\\|', (string) ($repo['description'] ?? ''));
$url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}");
$lang = (string) ($repo['language'] ?? '—');
$private = (bool) ($repo['private'] ?? false);
$archived = (bool) ($repo['archived'] ?? false);
$status = $archived ? '🗄 Archived' : '✅ Active';
$vis = $private ? 'Private' : 'Public';
$modId = $moduleMap[strtolower($name)] ?? null;
$modCell = $modId !== null ? (string) $modId : '—';
foreach ($groups[$key] as $repo) {
$name = (string) ($repo['name'] ?? '');
$desc = str_replace('|', '\\|', (string) ($repo['description'] ?? ''));
$url = (string) ($repo['html_url'] ?? "https://github.com/{$org}/{$name}");
$lang = (string) ($repo['language'] ?? '—');
$private = (bool) ($repo['private'] ?? false);
$archived = (bool) ($repo['archived'] ?? false);
$status = $archived ? '🗄 Archived' : '✅ Active';
$vis = $private ? 'Private' : 'Public';
$modId = $moduleMap[strtolower($name)] ?? null;
$modCell = $modId !== null ? (string) $modId : '—';
if ($isExt) {
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |";
} else {
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |";
}
}
if ($isExt) {
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$modCell} | {$lang} | {$vis} |";
} else {
$lines[] = "| [{$name}]({$url}) | {$status} | {$desc} | {$lang} | {$vis} |";
}
}
$lines[] = '';
}
$lines[] = '';
}
return implode("\n", $lines);
}
return implode("\n", $lines);
}
// ── File rewriter ─────────────────────────────────────────────────────────
// ── File rewriter ─────────────────────────────────────────────────────────
/**
* Replace content between the start/end markers in the inventory file.
* If markers are absent, appends a new section at the end.
*/
private function replaceSection(string $original, string $newContent): string
{
$startPos = strpos($original, self::MARKER_START);
$endPos = strpos($original, self::MARKER_END);
/**
* Replace content between the start/end markers in the inventory file.
* If markers are absent, appends a new section at the end.
*/
private function replaceSection(string $original, string $newContent): string
{
$startPos = strpos($original, self::MARKER_START);
$endPos = strpos($original, self::MARKER_END);
if ($startPos === false || $endPos === false) {
$this->warning('Inventory markers not found; appending section to end of file.');
return $original
. "\n\n## Active Repositories\n\n"
. self::MARKER_START . "\n"
. $newContent . "\n"
. self::MARKER_END . "\n";
}
if ($startPos === false || $endPos === false) {
$this->warning('Inventory markers not found; appending section to end of file.');
return $original
. "\n\n## Active Repositories\n\n"
. self::MARKER_START . "\n"
. $newContent . "\n"
. self::MARKER_END . "\n";
}
$before = substr($original, 0, $startPos + strlen(self::MARKER_START));
$after = substr($original, $endPos);
$before = substr($original, 0, $startPos + strlen(self::MARKER_START));
$after = substr($original, $endPos);
return $before . "\n" . $newContent . "\n" . $after;
}
return $before . "\n" . $newContent . "\n" . $after;
}
}
$script = new UpdateRepoInventory('update_repo_inventory', 'Updates the repository inventory documentation after sync');
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
+394 -391
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
@@ -36,449 +37,451 @@ use MokoEnterprise\{ApiClient, AuditLogger, CliFramework};
*/
class UpdateVersionFromReadme extends CliFramework
{
private AuditLogger $logger;
private ?ApiClient $apiClient = null;
private AuditLogger $logger;
private ?ApiClient $apiClient = null;
/** Files updated during this run */
private array $updatedFiles = [];
/** Files updated during this run */
private array $updatedFiles = [];
/** Errors encountered during this run */
private array $errors = [];
/** Errors encountered during this run */
private array $errors = [];
protected function configure(): void
{
$this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--dry-run', 'Preview changes without writing', false);
$this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false);
$this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', '');
}
protected function configure(): void
{
$this->setDescription('Propagate README.md version to all badges and FILE INFORMATION headers');
$this->addArgument('--path', 'Repository root path', '.');
$this->addArgument('--dry-run', 'Preview changes without writing', false);
$this->addArgument('--create-issue', 'Create GitHub issue if version mismatches remain', false);
$this->addArgument('--repo', 'GitHub repo for issue creation (owner/repo)', '');
}
protected function initialize(): void
{
parent::initialize();
$this->logger = new AuditLogger('update_version_from_readme');
}
protected function initialize(): void
{
parent::initialize();
$this->logger = new AuditLogger('update_version_from_readme');
}
protected function run(): int
{
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
$dryRun = (bool) $this->getArgument('--dry-run');
$createIssue = (bool) $this->getArgument('--create-issue');
$repo = (string) $this->getArgument('--repo');
protected function run(): int
{
$repoRoot = rtrim((string) $this->getArgument('--path'), '/');
$dryRun = (bool) $this->getArgument('--dry-run');
$createIssue = (bool) $this->getArgument('--create-issue');
$repo = (string) $this->getArgument('--repo');
$readmePath = $repoRoot . '/README.md';
if (!file_exists($readmePath)) {
$this->error("README.md not found at {$readmePath}");
return 1;
}
$readmePath = $repoRoot . '/README.md';
if (!file_exists($readmePath)) {
$this->error("README.md not found at {$readmePath}");
return 1;
}
// ── 1. Extract version from README.md ────────────────────────────
$version = $this->extractVersionFromReadme($readmePath);
if ($version === null) {
$this->error("Could not find VERSION field in README.md FILE INFORMATION block");
return 1;
}
// ── 1. Extract version from README.md ────────────────────────────
$version = $this->extractVersionFromReadme($readmePath);
if ($version === null) {
$this->error("Could not find VERSION field in README.md FILE INFORMATION block");
return 1;
}
$this->log("✅ README.md version: {$version}");
if ($dryRun) {
$this->log("🔍 DRY RUN — no files will be written");
}
$this->log("✅ README.md version: {$version}");
if ($dryRun) {
$this->log("🔍 DRY RUN — no files will be written");
}
// ── 2. Scan and update every tracked file ────────────────────────
$this->processFiles($repoRoot, $version, $dryRun);
// ── 2. Scan and update every tracked file ────────────────────────
$this->processFiles($repoRoot, $version, $dryRun);
// ── 3. Update composer.json ──────────────────────────────────────
$this->updateComposerJson($repoRoot, $version, $dryRun);
// ── 3. Update composer.json ──────────────────────────────────────
$this->updateComposerJson($repoRoot, $version, $dryRun);
// ── 4. Summary ───────────────────────────────────────────────────
$count = count($this->updatedFiles);
if ($dryRun) {
$this->log("🔍 DRY RUN complete — {$count} file(s) would be updated");
} else {
$this->log("✅ Updated {$count} file(s) to version {$version}");
}
// ── 4. Summary ───────────────────────────────────────────────────
$count = count($this->updatedFiles);
if ($dryRun) {
$this->log("🔍 DRY RUN complete — {$count} file(s) would be updated");
} else {
$this->log("✅ Updated {$count} file(s) to version {$version}");
}
foreach ($this->updatedFiles as $f) {
$this->log("{$f}");
}
foreach ($this->updatedFiles as $f) {
$this->log("{$f}");
}
// ── 5. Create issue if mismatches remain (non-dry-run only) ──────
if (!$dryRun && $createIssue && !empty($repo)) {
$remaining = $this->countRemainingMismatches($repoRoot, $version);
if ($remaining > 0) {
$this->log("{$remaining} version reference(s) could not be auto-updated");
$this->createDriftIssue($repo, $version, $remaining);
}
}
// ── 5. Create issue if mismatches remain (non-dry-run only) ──────
if (!$dryRun && $createIssue && !empty($repo)) {
$remaining = $this->countRemainingMismatches($repoRoot, $version);
if ($remaining > 0) {
$this->log("{$remaining} version reference(s) could not be auto-updated");
$this->createDriftIssue($repo, $version, $remaining);
}
}
return empty($this->errors) ? 0 : 1;
}
return empty($this->errors) ? 0 : 1;
}
// ────────────────────────────────────────────────────────────────────
// Version extraction
// ────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────
// Version extraction
// ────────────────────────────────────────────────────────────────────
/**
* Extract the VERSION value from the FILE INFORMATION block in README.md.
*
* Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms.
*
* @param string $path Full path to README.md
* @return string|null Version string (e.g. "04.00.04"), or null if not found
*/
private function extractVersionFromReadme(string $path): ?string
{
$content = file_get_contents($path);
if ($content === false) {
return null;
}
// Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab
if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) {
return $m[1];
}
return null;
}
/**
* Extract the VERSION value from the FILE INFORMATION block in README.md.
*
* Handles both indented (` VERSION: X`) and unindented (`VERSION: X`) forms.
*
* @param string $path Full path to README.md
* @return string|null Version string (e.g. "04.00.04"), or null if not found
*/
private function extractVersionFromReadme(string $path): ?string
{
$content = file_get_contents($path);
if ($content === false) {
return null;
}
// Match "VERSION: XX.YY.ZZ" allowing leading whitespace/tab
if (preg_match('/^\s*VERSION:\s*([0-9]{2}\.[0-9]{2}\.[0-9]{2})\s*$/m', $content, $m)) {
return $m[1];
}
return null;
}
// ────────────────────────────────────────────────────────────────────
// File processing
// ────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────
// File processing
// ────────────────────────────────────────────────────────────────────
/**
* Walk the repository tree and update every eligible file.
*
* @param string $repoRoot Absolute path to repository root
* @param string $version Target version string
* @param bool $dryRun If true, compute but do not write changes
*/
private function processFiles(string $repoRoot, string $version, bool $dryRun): void
{
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf'];
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
/**
* Walk the repository tree and update every eligible file.
*
* @param string $repoRoot Absolute path to repository root
* @param string $version Target version string
* @param bool $dryRun If true, compute but do not write changes
*/
private function processFiles(string $repoRoot, string $version, bool $dryRun): void
{
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'ps1', 'py', 'tf'];
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
$iterator = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator(
$repoRoot,
RecursiveDirectoryIterator::SKIP_DOTS
),
function (\SplFileInfo $fi) use ($excludeDirs): bool {
if ($fi->isDir()) {
return !in_array($fi->getFilename(), $excludeDirs, true);
}
return true;
}
)
);
$iterator = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator(
$repoRoot,
RecursiveDirectoryIterator::SKIP_DOTS
),
function (\SplFileInfo $fi) use ($excludeDirs): bool {
if ($fi->isDir()) {
return !in_array($fi->getFilename(), $excludeDirs, true);
}
return true;
}
)
);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (!$file->isFile()) {
continue;
}
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (!$file->isFile()) {
continue;
}
$ext = strtolower($file->getExtension());
// Strip .template suffix for extension matching
if ($ext === 'template') {
$inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
if (in_array($inner, $extensions, true)) {
$ext = $inner;
} else {
continue;
}
} elseif (!in_array($ext, $extensions, true)) {
continue;
}
$ext = strtolower($file->getExtension());
// Strip .template suffix for extension matching
if ($ext === 'template') {
$inner = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
if (in_array($inner, $extensions, true)) {
$ext = $inner;
} else {
continue;
}
} elseif (!in_array($ext, $extensions, true)) {
continue;
}
$this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext);
}
}
$this->processFile($file->getPathname(), $repoRoot, $version, $dryRun, $ext);
}
}
/**
* Apply version replacements to a single file.
*
* @param string $path Absolute file path
* @param string $repoRoot Repository root (for display)
* @param string $version Target version
* @param bool $dryRun If true, do not write
* @param string $ext Canonical extension (without .template)
*/
private function processFile(
string $path,
string $repoRoot,
string $version,
bool $dryRun,
string $ext
): void {
$original = file_get_contents($path);
if ($original === false) {
$this->errors[] = "Cannot read: {$path}";
return;
}
/**
* Apply version replacements to a single file.
*
* @param string $path Absolute file path
* @param string $repoRoot Repository root (for display)
* @param string $version Target version
* @param bool $dryRun If true, do not write
* @param string $ext Canonical extension (without .template)
*/
private function processFile(
string $path,
string $repoRoot,
string $version,
bool $dryRun,
string $ext
): void {
$original = file_get_contents($path);
if ($original === false) {
$this->errors[] = "Cannot read: {$path}";
return;
}
$updated = $original;
$updated = $original;
// ── Badge replacement (all file types) ───────────────────────────
// shields.io badge: [![moko-platform](...badge/moko--platform-XX.YY.ZZ-color)]
$updated = preg_replace(
'/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/',
'${1}' . $version . '${2}',
$updated
);
// Plain text version badge: [VERSION: XX.YY.ZZ]
$updated = preg_replace(
'/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/',
'[VERSION: ' . $version . ']',
$updated
);
// ── Badge replacement (all file types) ───────────────────────────
// shields.io badge: [![moko-platform](...badge/moko--platform-XX.YY.ZZ-color)]
$updated = preg_replace(
'/(\[!\[MokoStandards\]\(https:\/\/img\.shields\.io\/badge\/MokoStandards-)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(-[a-z]+\)\])/',
'${1}' . $version . '${2}',
$updated
);
// Plain text version badge: [VERSION: XX.YY.ZZ]
$updated = preg_replace(
'/\[VERSION:\s*[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]/',
'[VERSION: ' . $version . ']',
$updated
);
// ── FILE INFORMATION VERSION replacement ──────────────────────────
// Markdown inside <!-- -->: VERSION: OLD or <tab>VERSION: OLD
if ($ext === 'md') {
$updated = preg_replace(
'/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
}
// ── FILE INFORMATION VERSION replacement ──────────────────────────
// Markdown inside <!-- -->: VERSION: OLD or <tab>VERSION: OLD
if ($ext === 'md') {
$updated = preg_replace(
'/^(\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
}
// PHP inside /** */ or /* */: * VERSION: OLD
if ($ext === 'php') {
$updated = preg_replace(
'/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
// PHP inside /** */ or /* */: * VERSION: OLD
if ($ext === 'php') {
$updated = preg_replace(
'/^(\s*\*\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
// PHP class VERSION constants:
// private const VERSION = '09.22.00';
// public const VERSION = '09.22.00';
// private const VERSION = '09.22.00';
$updated = preg_replace(
'/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/',
'${1}' . $version . '${2}',
$updated
);
// PHP class VERSION constants:
// private const VERSION = '09.22.00';
// public const VERSION = '09.22.00';
// private const VERSION = '09.22.00';
$updated = preg_replace(
'/((?:private|public|protected)\s+const\s+VERSION\s*=\s*[\'"])[0-9]{2}\.[0-9]{2}\.[0-9]{2}([\'"])/',
'${1}' . $version . '${2}',
$updated
);
// composer.json "version" field (handled separately for JSON files)
}
// composer.json "version" field (handled separately for JSON files)
}
// YAML / Shell / PowerShell / Python: # VERSION: OLD
if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) {
$updated = preg_replace(
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
}
// YAML / Shell / PowerShell / Python: # VERSION: OLD
if (in_array($ext, ['yml', 'yaml', 'sh', 'ps1', 'py'], true)) {
$updated = preg_replace(
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
}
// Terraform (.tf / .tf.template) — three locations:
// 1. # VERSION: OLD (hash-comment header, template-style files)
// 2. * Version: OLD (block-comment header, definition files)
// 3. version = "OLD" (HCL metadata field)
if ($ext === 'tf') {
$updated = preg_replace(
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
$updated = preg_replace(
'/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
$updated = preg_replace(
'/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
}
// Terraform (.tf / .tf.template) — three locations:
// 1. # VERSION: OLD (hash-comment header, template-style files)
// 2. * Version: OLD (block-comment header, definition files)
// 3. version = "OLD" (HCL metadata field)
if ($ext === 'tf') {
$updated = preg_replace(
'/^(#\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
$updated = preg_replace(
'/^(\s*\*\s*Version:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}(\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
$updated = preg_replace(
'/^(\s*version\s*=\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}("\s*)$/m',
'${1}' . $version . '${2}',
$updated
);
}
if ($updated === $original) {
return; // Nothing to change
}
if ($updated === $original) {
return; // Nothing to change
}
$rel = ltrim(str_replace($repoRoot, '', $path), '/');
$rel = ltrim(str_replace($repoRoot, '', $path), '/');
if (!$dryRun) {
if (file_put_contents($path, $updated) === false) {
$this->errors[] = "Cannot write: {$path}";
return;
}
}
if (!$dryRun) {
if (file_put_contents($path, $updated) === false) {
$this->errors[] = "Cannot write: {$path}";
return;
}
}
$this->updatedFiles[] = $rel;
}
$this->updatedFiles[] = $rel;
}
/**
* Update the "version" key in composer.json if it exists.
*
* @param string $repoRoot Repository root
* @param string $version Target version
* @param bool $dryRun If true, do not write
*/
private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void
{
$path = $repoRoot . '/composer.json';
if (!file_exists($path)) {
return;
}
/**
* Update the "version" key in composer.json if it exists.
*
* @param string $repoRoot Repository root
* @param string $version Target version
* @param bool $dryRun If true, do not write
*/
private function updateComposerJson(string $repoRoot, string $version, bool $dryRun): void
{
$path = $repoRoot . '/composer.json';
if (!file_exists($path)) {
return;
}
$content = file_get_contents($path);
if ($content === false) {
return;
}
$content = file_get_contents($path);
if ($content === false) {
return;
}
$updated = preg_replace(
'/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m',
'${1}' . $version . '${2}',
$content
);
$updated = preg_replace(
'/("version"\s*:\s*")[0-9]{2}\.[0-9]{2}\.[0-9]{2}(")/m',
'${1}' . $version . '${2}',
$content
);
if ($updated === $content) {
return;
}
if ($updated === $content) {
return;
}
if (!$dryRun) {
file_put_contents($path, $updated);
}
if (!$dryRun) {
file_put_contents($path, $updated);
}
$this->updatedFiles[] = 'composer.json';
}
$this->updatedFiles[] = 'composer.json';
}
// ────────────────────────────────────────────────────────────────────
// Drift detection
// ────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────
// Drift detection
// ────────────────────────────────────────────────────────────────────
/**
* Count FILE INFORMATION VERSION lines that still differ from $version.
*
* @param string $repoRoot Repository root
* @param string $version Expected version
* @return int Number of remaining mismatches
*/
private function countRemainingMismatches(string $repoRoot, string $version): int
{
$escaped = preg_quote($version, '/');
$count = 0;
$versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/';
/**
* Count FILE INFORMATION VERSION lines that still differ from $version.
*
* @param string $repoRoot Repository root
* @param string $version Expected version
* @return int Number of remaining mismatches
*/
private function countRemainingMismatches(string $repoRoot, string $version): int
{
$escaped = preg_quote($version, '/');
$count = 0;
$versionRe = '/VERSION:\s*(?!' . $escaped . ')[0-9]{2}\.[0-9]{2}\.[0-9]{2}/';
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf'];
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
$extensions = ['md', 'php', 'yml', 'yaml', 'sh', 'tf'];
$excludeDirs = ['vendor', '.git', 'node_modules', 'logs'];
$iterator = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
function (\SplFileInfo $fi) use ($excludeDirs): bool {
return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true));
}
)
);
$iterator = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($repoRoot, RecursiveDirectoryIterator::SKIP_DOTS),
function (\SplFileInfo $fi) use ($excludeDirs): bool {
return !($fi->isDir() && in_array($fi->getFilename(), $excludeDirs, true));
}
)
);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (!$file->isFile()) {
continue;
}
$ext = strtolower($file->getExtension());
if ($ext === 'template') {
$ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
}
if (!in_array($ext, $extensions, true)) {
continue;
}
$content = file_get_contents($file->getPathname());
if ($content !== false && preg_match($versionRe, $content)) {
$count++;
}
}
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (!$file->isFile()) {
continue;
}
$ext = strtolower($file->getExtension());
if ($ext === 'template') {
$ext = strtolower(pathinfo($file->getBasename('.template'), PATHINFO_EXTENSION));
}
if (!in_array($ext, $extensions, true)) {
continue;
}
$content = file_get_contents($file->getPathname());
if ($content !== false && preg_match($versionRe, $content)) {
$count++;
}
}
return $count;
}
return $count;
}
// ────────────────────────────────────────────────────────────────────
// GitHub issue creation
// ────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────
// GitHub issue creation
// ────────────────────────────────────────────────────────────────────
/**
* Create or update a GitHub issue listing files that could not be auto-updated.
*
* @param string $repo owner/repo
* @param string $version Expected version
* @param int $remaining Number of remaining mismatches
*/
private function createDriftIssue(string $repo, string $version, int $remaining): void
{
if (!isset($this->apiClient)) {
$config = \MokoEnterprise\Config::load();
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->apiClient = $adapter->getApiClient();
} catch (\Exception $e) {
$this->error('Platform initialization failed: ' . $e->getMessage());
return;
}
}
/**
* Create or update a GitHub issue listing files that could not be auto-updated.
*
* @param string $repo owner/repo
* @param string $version Expected version
* @param int $remaining Number of remaining mismatches
*/
private function createDriftIssue(string $repo, string $version, int $remaining): void
{
if (!isset($this->apiClient)) {
$config = \MokoEnterprise\Config::load();
try {
$adapter = \MokoEnterprise\PlatformAdapterFactory::create($config);
$this->apiClient = $adapter->getApiClient();
} catch (\Exception $e) {
$this->error('Platform initialization failed: ' . $e->getMessage());
return;
}
}
$title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}";
$labels = ['version-drift', 'maintenance', 'type: chore', 'automation'];
$body = implode("\n", [
"## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated",
"",
"**Target version:** `{$version}` (from README.md)",
"",
"After the automatic version propagation run, **{$remaining}** file(s) still contain",
"a VERSION field that does not match the README.md version.",
"",
"### How to fix",
"",
"1. Run the sync script locally:",
" ```bash",
" php maintenance/update_version_from_readme.php --path . --dry-run",
" php maintenance/update_version_from_readme.php --path .",
" ```",
"2. Inspect any files still flagged — they may use a non-standard VERSION format.",
"3. Update them manually to match `VERSION: {$version}`.",
"4. Commit and push — this issue will be closed automatically on the next successful sync.",
"",
"---",
"*Automatically created by [update_version_from_readme.php](maintenance/update_version_from_readme.php)*",
]);
$title = "⚠️ Version drift: {$remaining} file(s) not updated to {$version}";
$labels = ['version-drift', 'maintenance', 'type: chore', 'automation'];
$body = implode("\n", [
"## ⚠️ Version Sync: {$remaining} file(s) could not be auto-updated",
"",
"**Target version:** `{$version}` (from README.md)",
"",
"After the automatic version propagation run, **{$remaining}** file(s) still contain",
"a VERSION field that does not match the README.md version.",
"",
"### How to fix",
"",
"1. Run the sync script locally:",
" ```bash",
" php maintenance/update_version_from_readme.php --path . --dry-run",
" php maintenance/update_version_from_readme.php --path .",
" ```",
"2. Inspect any files still flagged — they may use a non-standard VERSION format.",
"3. Update them manually to match `VERSION: {$version}`.",
"4. Commit and push — this issue will be closed automatically on the next successful sync.",
"",
"---",
"*Automatically created by [update_version_from_readme.php](maintenance/update_version_from_readme.php)*",
]);
try {
// Check for an existing version-drift issue to avoid duplicates
$existing = $this->apiClient->get("/repos/{$repo}/issues", [
'labels' => 'version-drift',
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction' => 'desc',
]);
try {
// Check for an existing version-drift issue to avoid duplicates
$existing = $this->apiClient->get("/repos/{$repo}/issues", [
'labels' => 'version-drift',
'state' => 'all',
'per_page' => 1,
'sort' => 'created',
'direction' => 'desc',
]);
if (!empty($existing[0]['number'])) {
$num = (int) $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch);
try {
$this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
} catch (\Exception $le) { /* non-fatal */ }
$this->log("✅ Updated issue #{$num} in {$repo}");
} else {
$issue = $this->apiClient->post("/repos/{$repo}/issues", [
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => ['jmiller'],
]);
$this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}");
}
} catch (\Exception $e) {
$this->error('Failed to create/update issue: ' . $e->getMessage());
}
}
if (!empty($existing[0]['number'])) {
$num = (int) $existing[0]['number'];
$patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']];
if (($existing[0]['state'] ?? 'open') === 'closed') {
$patch['state'] = 'open';
}
$this->apiClient->patch("/repos/{$repo}/issues/{$num}", $patch);
try {
$this->apiClient->post("/repos/{$repo}/issues/{$num}/labels", ['labels' => $labels]);
} catch (\Exception $le) {
/* non-fatal */
}
$this->log("✅ Updated issue #{$num} in {$repo}");
} else {
$issue = $this->apiClient->post("/repos/{$repo}/issues", [
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => ['jmiller'],
]);
$this->log('✅ Created issue #' . ($issue['number'] ?? '?') . " in {$repo}");
}
} catch (\Exception $e) {
$this->error('Failed to create/update issue: ' . $e->getMessage());
}
}
}
$script = new UpdateVersionFromReadme();