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
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:
@@ -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);
|
||||
|
||||
@@ -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']++;
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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,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
@@ -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();
|
||||
|
||||
@@ -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,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
@@ -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,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
@@ -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})");
|
||||
|
||||
@@ -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
@@ -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,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,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,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,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,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
@@ -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,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
File diff suppressed because it is too large
Load Diff
+100
-24
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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,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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+151
-150
@@ -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
@@ -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,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,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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
|
||||
@@ -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: []
|
||||
$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: []
|
||||
$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();
|
||||
|
||||
Reference in New Issue
Block a user